mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-27 22:26:00 +00:00
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:
co-authored by
Matthias Mair
Oliver
parent
ba848a83aa
commit
d33d77d679
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management.
|
- [#12713](https://github.com/inventree/InvenTree/pull/12713) adds SCIM 2 provisioning support, allowing InvenTree to be integrated with external identity providers for user management.
|
||||||
- [#12731](https://github.com/inventree/InvenTree/pull/12731) adds OIDC provider settings to the Admin Center - making all Identity Federation settings now available in one place without the need to use the database admin interface.
|
- [#12731](https://github.com/inventree/InvenTree/pull/12731) adds OIDC provider settings to the Admin Center - making all Identity Federation settings now available in one place without the need to use the database admin interface.
|
||||||
- [#12837](https://github.com/inventree/InvenTree/pull/12837) adds a user setting `ROTATE_TABLE_HEADERS` which rotates table headers by 90 degrees, improving readability for tables with long column titles.
|
- [#12837](https://github.com/inventree/InvenTree/pull/12837) adds a user setting `ROTATE_TABLE_HEADERS` which rotates table headers by 90 degrees, improving readability for tables with long column titles.
|
||||||
|
- [#12900](https://github.com/Inventree/InvenTree/pull/12900) adds a UI feature `route` to the `UIMixin`, giving plugins the ability to register custom pages.
|
||||||
- [#12911](https://github.com/inventree/InvenTree/pull/12911) adds global setting for default receive location against purchase orders
|
- [#12911](https://github.com/inventree/InvenTree/pull/12911) adds global setting for default receive location against purchase orders
|
||||||
- [#12914](https://github.com/inventree/InvenTree/pull/12914) adds the AllocateMixin, allowing plugins to customize automatic stock allocation for build orders and sales orders.
|
- [#12914](https://github.com/inventree/InvenTree/pull/12914) adds the AllocateMixin, allowing plugins to customize automatic stock allocation for build orders and sales orders.
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,40 @@ The `get_ui_primary_actions` method can be used to provide custom primary action
|
|||||||
summary: False
|
summary: False
|
||||||
members: []
|
members: []
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
|
||||||
|
The `get_ui_routes` method can be used to provide custom routes (and therefore pages) within the InvenTree web interface.
|
||||||
|
|
||||||
|
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_routes
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
extra:
|
||||||
|
show_source: True
|
||||||
|
|
||||||
|
A route is defined by returning a list of route definitions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_ui_routes(self, request, context, **kwargs):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'key': 'my-plugin-page',
|
||||||
|
'title': 'My Plugin Page',
|
||||||
|
'source': self.plugin_static_file('my_page.js:getFeature'),
|
||||||
|
'options': {
|
||||||
|
'path': 'page/:pk',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes are exposed below `/web/plugin/<plugin-name>/`. For example, the route above would be available at: `/web/plugin/my-plugin/page/<pk>`
|
||||||
|
|
||||||
|
The path is relative to the plugin's own URL namespace and uses React Router path syntax, so parameters can be specified using :parameter notation.
|
||||||
|
|
||||||
## Plugin Context
|
## Plugin Context
|
||||||
|
|
||||||
When rendering certain content in the user interface, the rendering functions are passed a `context` object which contains information about the current page being rendered. The type of the `context` object is defined in the `PluginContext` file:
|
When rendering certain content in the user interface, the rendering functions are passed a `context` object which contains information about the current page being rendered. The type of the `context` object is defined in the `PluginContext` file:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ FeatureType = Literal[
|
|||||||
'template_preview', # Custom template preview
|
'template_preview', # Custom template preview
|
||||||
'navigation', # Custom navigation items
|
'navigation', # Custom navigation items
|
||||||
'primary_action', # Custom primary action buttons
|
'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_editor': self.get_ui_template_editors,
|
||||||
'template_preview': self.get_ui_template_previews,
|
'template_preview': self.get_ui_template_previews,
|
||||||
'primary_action': self.get_ui_primary_actions,
|
'primary_action': self.get_ui_primary_actions,
|
||||||
|
'route': self.get_ui_routes,
|
||||||
}
|
}
|
||||||
|
|
||||||
if feature_type in feature_map:
|
if feature_type in feature_map:
|
||||||
@@ -231,3 +233,7 @@ class UserInterfaceMixin:
|
|||||||
|
|
||||||
# Default implementation returns an empty list
|
# Default implementation returns an empty list
|
||||||
return []
|
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]['plugin_name'], 'sampleui')
|
||||||
self.assertEqual(response.data[0]['key'], 'sample-primary-action')
|
self.assertEqual(response.data[0]['key'], 'sample-primary-action')
|
||||||
self.assertEqual(response.data[0]['title'], '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',
|
'key': 'sample-nav-item',
|
||||||
'title': 'Sample Nav Item',
|
'title': 'Sample Nav Item',
|
||||||
'icon': 'ti:menu',
|
'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:
|
def get_admin_context(self) -> dict:
|
||||||
"""Return custom context data which can be rendered in the admin panel."""
|
"""Return custom context data which can be rendered in the admin panel."""
|
||||||
return {'apple': 'banana', 'foo': 'bar', 'hello': 'world'}
|
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}.`)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,12 @@ import { useDisclosure, useDocumentVisibility } from '@mantine/hooks';
|
|||||||
import { IconBell, IconSearch, IconUserBolt } from '@tabler/icons-react';
|
import { IconBell, IconSearch, IconUserBolt } from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
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 { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
@@ -252,6 +257,8 @@ function NavTabs() {
|
|||||||
const tabValue = match?.params.tabName;
|
const tabValue = match?.params.tabName;
|
||||||
const navTabs = getNavTabs(user);
|
const navTabs = getNavTabs(user);
|
||||||
const userSettings = useUserSettingsState();
|
const userSettings = useUserSettingsState();
|
||||||
|
// Get the current URL
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const withIcons: boolean = useMemo(
|
const withIcons: boolean = useMemo(
|
||||||
() => userSettings.isSet('ICONS_IN_NAVBAR', false),
|
() => userSettings.isSet('ICONS_IN_NAVBAR', false),
|
||||||
@@ -263,6 +270,21 @@ function NavTabs() {
|
|||||||
context: {}
|
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[] = useMemo(() => {
|
||||||
const _tabs: ReactNode[] = [];
|
const _tabs: ReactNode[] = [];
|
||||||
|
|
||||||
@@ -301,7 +323,7 @@ function NavTabs() {
|
|||||||
extraNavs.forEach((nav) => {
|
extraNavs.forEach((nav) => {
|
||||||
_tabs.push(
|
_tabs.push(
|
||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
value={nav.options.title}
|
value={nav.options.key}
|
||||||
key={nav.options.key}
|
key={nav.options.key}
|
||||||
onClick={(event: any) =>
|
onClick={(event: any) =>
|
||||||
navigateToLink(nav.options.options.url, navigate, event)
|
navigateToLink(nav.options.options.url, navigate, event)
|
||||||
@@ -323,7 +345,8 @@ function NavTabs() {
|
|||||||
list: classes.tabsList,
|
list: classes.tabsList,
|
||||||
tab: classes.tab
|
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.List>{tabs.map((tab) => tab)}</Tabs.List>
|
||||||
</Tabs>
|
</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_editor = 'template_editor',
|
||||||
template_preview = 'template_preview',
|
template_preview = 'template_preview',
|
||||||
navigation = 'navigation',
|
navigation = 'navigation',
|
||||||
primary_action = 'primary_action'
|
primary_action = 'primary_action',
|
||||||
|
route = 'route'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,3 +92,13 @@ export type PrimaryActionUIFeature = {
|
|||||||
featureContext: {};
|
featureContext: {};
|
||||||
featureReturnType: undefined;
|
featureReturnType: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RouteUIFeature = BaseUIFeature & {
|
||||||
|
featureType: 'route';
|
||||||
|
requestContext: {};
|
||||||
|
responseOptions: {
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
featureContext: {};
|
||||||
|
featureReturnType: any;
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
|||||||
import { EagerLoadable, Loadable } from './functions/loading';
|
import { EagerLoadable, Loadable } from './functions/loading';
|
||||||
import { onLocaleReady } from './functions/localeReady';
|
import { onLocaleReady } from './functions/localeReady';
|
||||||
|
|
||||||
|
import { PluginRoutes } from './components/plugins/PluginRoutes';
|
||||||
|
|
||||||
// Lazy loaded pages
|
// Lazy loaded pages
|
||||||
// These two are mutually exclusive and one of them is always needed
|
// These two are mutually exclusive and one of them is always needed
|
||||||
// immediately on initial load, so they're loaded eagerly rather than via
|
// 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='user/:id/*' element={<UserDetail />} />
|
||||||
<Route path='group/:id/*' element={<GroupDetail />} />
|
<Route path='group/:id/*' element={<GroupDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path='plugin/*' element={<PluginRoutes />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path='/'
|
path='/'
|
||||||
|
|||||||
Reference in New Issue
Block a user