diff --git a/CHANGELOG.md b/CHANGELOG.md index 29242dd482..74f4eea94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - [#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. +- [#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 - [#12914](https://github.com/inventree/InvenTree/pull/12914) adds the AllocateMixin, allowing plugins to customize automatic stock allocation for build orders and sales orders. diff --git a/docs/docs/plugins/mixins/ui.md b/docs/docs/plugins/mixins/ui.md index dc3785d778..416de0007e 100644 --- a/docs/docs/plugins/mixins/ui.md +++ b/docs/docs/plugins/mixins/ui.md @@ -197,6 +197,40 @@ The `get_ui_primary_actions` method can be used to provide custom primary action summary: False 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//`. For example, the route above would be available at: `/web/plugin/my-plugin/page/` + +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 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: diff --git a/src/backend/InvenTree/plugin/base/ui/mixins.py b/src/backend/InvenTree/plugin/base/ui/mixins.py index 9aa4a67edd..635bceb934 100644 --- a/src/backend/InvenTree/plugin/base/ui/mixins.py +++ b/src/backend/InvenTree/plugin/base/ui/mixins.py @@ -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 [] diff --git a/src/backend/InvenTree/plugin/base/ui/tests.py b/src/backend/InvenTree/plugin/base/ui/tests.py index f05eac0046..3a5315f8d8 100644 --- a/src/backend/InvenTree/plugin/base/ui/tests.py +++ b/src/backend/InvenTree/plugin/base/ui/tests.py @@ -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', + }, + ) 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 09bdbb7f37..becbba1116 100644 --- a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py @@ -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'} diff --git a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_route.js b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_route.js new file mode 100644 index 0000000000..0ed8a61631 --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_route.js @@ -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}.`) + ); +} diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx index 5872988554..262103bcd4 100644 --- a/src/frontend/src/components/nav/Header.tsx +++ b/src/frontend/src/components/nav/Header.tsx @@ -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( 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.map((tab) => tab)} diff --git a/src/frontend/src/components/plugins/PluginRoutes.tsx b/src/frontend/src/components/plugins/PluginRoutes.tsx new file mode 100644 index 0000000000..49878735f6 --- /dev/null +++ b/src/frontend/src/components/plugins/PluginRoutes.tsx @@ -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({ + featureType: 'route', + context: {} + }); + + const pluginContext = useInvenTreeContext(); + + return ( + + {routes.map((route) => ( + + } + /> + ))} + + } /> + + ); +} diff --git a/src/frontend/src/components/plugins/PluginUIFeature.tsx b/src/frontend/src/components/plugins/PluginUIFeature.tsx index 85425d4894..482cbe7cef 100644 --- a/src/frontend/src/components/plugins/PluginUIFeature.tsx +++ b/src/frontend/src/components/plugins/PluginUIFeature.tsx @@ -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' } /** diff --git a/src/frontend/src/components/plugins/PluginUIFeatureTypes.ts b/src/frontend/src/components/plugins/PluginUIFeatureTypes.ts index 5da52b90ac..9138cc627c 100644 --- a/src/frontend/src/components/plugins/PluginUIFeatureTypes.ts +++ b/src/frontend/src/components/plugins/PluginUIFeatureTypes.ts @@ -92,3 +92,13 @@ export type PrimaryActionUIFeature = { featureContext: {}; featureReturnType: undefined; }; + +export type RouteUIFeature = BaseUIFeature & { + featureType: 'route'; + requestContext: {}; + responseOptions: { + path: string; + }; + featureContext: {}; + featureReturnType: any; +}; diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index ccb344ea03..ad17df8a19 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -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 = ( } /> } /> + } />