diff --git a/CHANGELOG.md b/CHANGELOG.md index 82926e298a..0974420d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/docs/plugins/mixins/ui.md b/docs/docs/plugins/mixins/ui.md index 4510ae0793..90329832c2 100644 --- a/docs/docs/plugins/mixins/ui.md +++ b/docs/docs/plugins/mixins/ui.md @@ -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: diff --git a/src/backend/InvenTree/plugin/base/ui/mixins.py b/src/backend/InvenTree/plugin/base/ui/mixins.py index 67dab02ad4..cbffd1fb9a 100644 --- a/src/backend/InvenTree/plugin/base/ui/mixins.py +++ b/src/backend/InvenTree/plugin/base/ui/mixins.py @@ -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]: diff --git a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py index 8bb11692d5..70c47f220a 100644 --- a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py @@ -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 = [] diff --git a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_action.js b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_action.js new file mode 100644 index 0000000000..6fc1f1b974 --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_action.js @@ -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); +} diff --git a/src/frontend/src/components/nav/Layout.tsx b/src/frontend/src/components/nav/Layout.tsx index b808959436..6122133c8c 100644 --- a/src/frontend/src/components/nav/Layout.tsx +++ b/src/frontend/src/components/nav/Layout.tsx @@ -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(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 && , + onClick: () => { + callExternalPluginFunction( + item.source, + 'executeAction', + pluginContext + ); + } + }; + }) ?? [] + ); + }, [pluginActionsQuery?.data, inventreeContext]); + + useEffect(() => { + setActions([...defaultActions, ...pluginActions]); + }, [defaultActions.length, pluginActions.length, location]); return ( diff --git a/src/frontend/src/components/plugins/PluginSource.tsx b/src/frontend/src/components/plugins/PluginSource.tsx index 964c6144cf..57a5eb1832 100644 --- a/src/frontend/src/components/plugins/PluginSource.tsx +++ b/src/frontend/src/components/plugins/PluginSource.tsx @@ -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 { + findExternalPluginFunction(source, functionName).then((func) => { + if (func) { + return func(context); + } + }); +} diff --git a/src/frontend/src/components/plugins/PluginUIFeature.tsx b/src/frontend/src/components/plugins/PluginUIFeature.tsx index 3eee26283b..cb11faa981 100644 --- a/src/frontend/src/components/plugins/PluginUIFeature.tsx +++ b/src/frontend/src/components/plugins/PluginUIFeature.tsx @@ -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', diff --git a/src/frontend/tests/pui_modals.spec.ts b/src/frontend/tests/pui_modals.spec.ts index a4270f5dd9..5486daf4c2 100644 --- a/src/frontend/tests/pui_modals.spec.ts +++ b/src/frontend/tests/pui_modals.spec.ts @@ -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');