feat: add plugin support for custom page integration (#12900)

* Added plugin UI route functionality.

* Updated Sample UI plugin with route example.

* Added UI route tests.

* Plugin name added to UI Route URL.

* Updated docs.

* Docs fix.

* Updated sample_route text.

* Update src/backend/InvenTree/plugin/base/ui/mixins.py

Co-authored-by: Matthias Mair <code@mjmair.com>

* Fixed nav dynamic link highlighting.

* Added changelog entry.

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
James Todd
2026-09-27 08:13:54 +10:00
committed by GitHub
co-authored by Matthias Mair Oliver
parent ba848a83aa
commit d33d77d679
11 changed files with 199 additions and 5 deletions
@@ -22,6 +22,7 @@ FeatureType = Literal[
'template_preview', # Custom template preview
'navigation', # Custom navigation items
'primary_action', # Custom primary action buttons
'route', # Custom react router path
]
@@ -108,6 +109,7 @@ class UserInterfaceMixin:
'template_editor': self.get_ui_template_editors,
'template_preview': self.get_ui_template_previews,
'primary_action': self.get_ui_primary_actions,
'route': self.get_ui_routes,
}
if feature_type in feature_map:
@@ -231,3 +233,7 @@ class UserInterfaceMixin:
# Default implementation returns an empty list
return []
def get_ui_routes(self, request, context, **kwargs):
"""Return a list of custom React routes."""
return []
@@ -264,3 +264,42 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase):
self.assertEqual(response.data[0]['plugin_name'], 'sampleui')
self.assertEqual(response.data[0]['key'], 'sample-primary-action')
self.assertEqual(response.data[0]['title'], 'Sample Primary Action')
def test_ui_routes(self):
"""Test that the sample UI plugin provides custom routes."""
response = self.get(
reverse('api-plugin-ui-feature-list', kwargs={'feature': 'route'})
)
self.assertEqual(2, len(response.data))
routes = {route['key']: route for route in response.data}
self.assertIn('sample-route', routes)
self.assertIn('sample-route-arg', routes)
self.assertEqual(
routes['sample-route'],
{
'plugin_name': 'sampleui',
'feature_type': 'route',
'key': 'sample-route',
'title': 'Sample Route',
'options': {'path': 'test'},
'context': None,
'source': '/static/plugins/sampleui/sample_route.js:getBasicPage',
},
)
self.assertEqual(
routes['sample-route-arg'],
{
'plugin_name': 'sampleui',
'feature_type': 'route',
'key': 'sample-route-arg',
'title': 'Sample Route Arg',
'options': {'path': 'test/:arg1'},
'context': None,
'source': '/static/plugins/sampleui/sample_route.js:getArgPage',
},
)
@@ -222,7 +222,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
'key': 'sample-nav-item',
'title': 'Sample Nav Item',
'icon': 'ti:menu',
'options': {'url': '/sample/page/'},
'options': {'url': 'plugin/sampleui/test'},
}
]
@@ -237,6 +237,25 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
}
]
def get_ui_routes(self, request, context, **kwargs):
"""Return a list of custom UI routes."""
return [
{
# Adds a simple route to /web/plugin/sampleui/test
'key': 'sample-route',
'title': 'Sample Route',
'source': self.plugin_static_file('sample_route.js:getBasicPage'),
'options': {'path': 'test'},
},
{
# Adds a simple route, with an argument to /web/plugin/sampleui/test/:arg1 (e.g., /web/plugin/sampleui/test/1)
'key': 'sample-route-arg',
'title': 'Sample Route Arg',
'source': self.plugin_static_file('sample_route.js:getArgPage'),
'options': {'path': 'test/:arg1'},
},
]
def get_admin_context(self) -> dict:
"""Return custom context data which can be rendered in the admin panel."""
return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}
@@ -0,0 +1,20 @@
export function getBasicPage(_context) {
return React.createElement(
'div',
{ style: { padding: 24 } },
React.createElement('h1', null, 'Sample Plugin Route'),
React.createElement('p', null, 'This page has been dynamically rendered by the plugin system.')
);
}
export function getArgPage(_context) {
const arg1 = window.location.pathname.split('/').pop();
return React.createElement(
'div',
{ style: { padding: 24 } },
React.createElement('h1', null, 'Sample Plugin Route'),
React.createElement('p', null, 'This page has been dynamically rendered by the plugin system.'),
React.createElement('p', null, `Arg 1: ${arg1}.`)
);
}
+26 -3
View File
@@ -14,7 +14,12 @@ import { useDisclosure, useDocumentVisibility } from '@mantine/hooks';
import { IconBell, IconSearch, IconUserBolt } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { useMatch, useNavigate } from 'react-router-dom';
import {
matchPath,
useLocation,
useMatch,
useNavigate
} from 'react-router-dom';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
@@ -252,6 +257,8 @@ function NavTabs() {
const tabValue = match?.params.tabName;
const navTabs = getNavTabs(user);
const userSettings = useUserSettingsState();
// Get the current URL
const location = useLocation();
const withIcons: boolean = useMemo(
() => userSettings.isSet('ICONS_IN_NAVBAR', false),
@@ -263,6 +270,21 @@ function NavTabs() {
context: {}
});
// Find dynamic navigation URLs that match the current location, with a preference towards more specific URLs.
const dynamicTabValue = extraNavs
.filter((nav) =>
matchPath(
{
path: `/${nav.options.options.url}`,
end: false
},
location.pathname
)
)
.sort(
(a, b) => b.options.options.url.length - a.options.options.url.length
)[0]?.options.key;
const tabs: ReactNode[] = useMemo(() => {
const _tabs: ReactNode[] = [];
@@ -301,7 +323,7 @@ function NavTabs() {
extraNavs.forEach((nav) => {
_tabs.push(
<Tabs.Tab
value={nav.options.title}
value={nav.options.key}
key={nav.options.key}
onClick={(event: any) =>
navigateToLink(nav.options.options.url, navigate, event)
@@ -323,7 +345,8 @@ function NavTabs() {
list: classes.tabsList,
tab: classes.tab
}}
value={tabValue}
// Select either a static or dynamic tab to be highlighted.
value={dynamicTabValue ?? tabValue}
>
<Tabs.List>{tabs.map((tab) => tab)}</Tabs.List>
</Tabs>
@@ -0,0 +1,38 @@
import { Route, Routes } from 'react-router-dom';
import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature';
import type { RouteUIFeature } from './PluginUIFeatureTypes';
import RemoteComponent from './RemoteComponent';
import { useInvenTreeContext } from './PluginContext';
import NotFound from '../errors/NotFound';
export function PluginRoutes() {
const routes = usePluginUIFeature<RouteUIFeature>({
featureType: 'route',
context: {}
});
const pluginContext = useInvenTreeContext();
return (
<Routes>
{routes.map((route) => (
<Route
key={route.options.key}
path={`${route.options.plugin_name}/${route.options.options.path}`}
element={
<RemoteComponent
source={route.options.source}
defaultFunctionName='getFeature'
context={pluginContext}
/>
}
/>
))}
<Route path='*' element={<NotFound />} />
</Routes>
);
}
@@ -31,7 +31,8 @@ export enum PluginUIFeatureType {
template_editor = 'template_editor',
template_preview = 'template_preview',
navigation = 'navigation',
primary_action = 'primary_action'
primary_action = 'primary_action',
route = 'route'
}
/**
@@ -92,3 +92,13 @@ export type PrimaryActionUIFeature = {
featureContext: {};
featureReturnType: undefined;
};
export type RouteUIFeature = BaseUIFeature & {
featureType: 'route';
requestContext: {};
responseOptions: {
path: string;
};
featureContext: {};
featureReturnType: any;
};
+3
View File
@@ -4,6 +4,8 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { EagerLoadable, Loadable } from './functions/loading';
import { onLocaleReady } from './functions/localeReady';
import { PluginRoutes } from './components/plugins/PluginRoutes';
// Lazy loaded pages
// These two are mutually exclusive and one of them is always needed
// immediately on initial load, so they're loaded eagerly rather than via
@@ -220,6 +222,7 @@ export const routes = (
<Route path='user/:id/*' element={<UserDetail />} />
<Route path='group/:id/*' element={<GroupDetail />} />
</Route>
<Route path='plugin/*' element={<PluginRoutes />} />
</Route>
<Route
path='/'