mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-22 01:06:50 +00:00
feat(frontend): Add option for plugins to add header actions (#9570)
* [FR] PUI - Add option for plugins to add header actions Fixes #8593 * fix parsing * fix merge * reduce diff * fix sample implementation * add support for icons and colors in primary actions * add changelog entry * add docs * add more detailed sample text * pass location into context * fix test --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog.
|
- [#11778](https://github.com/inventree/InvenTree/pull/11778) adds inline supplier part creation to po line item addition dialog.
|
||||||
- [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes
|
- [#11772](https://github.com/inventree/InvenTree/pull/11772) the UI now warns if you navigate away from a note panel with unsaved changes
|
||||||
- [#11788](https://github.com/inventree/InvenTree/pull/11788) adds support for custom permissions checks on database models defined in plugins. If a model defines a `check_user_permission` classmethod, this will be called to determine if a user has permission to view the model. This is required for plugin models which do not have the required ruleset definitions for the standard permission system.
|
- [#11788](https://github.com/inventree/InvenTree/pull/11788) adds support for custom permissions checks on database models defined in plugins. If a model defines a `check_user_permission` classmethod, this will be called to determine if a user has permission to view the model. This is required for plugin models which do not have the required ruleset definitions for the standard permission system.
|
||||||
|
- [#9570](https://github.com/inventree/InvenTree/pull/9570) adds support for defining primary actions via plugins
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,20 @@ The `get_ui_template_previews` feature type can be used to provide custom templa
|
|||||||
summary: False
|
summary: False
|
||||||
members: []
|
members: []
|
||||||
|
|
||||||
|
### Primary Actions
|
||||||
|
|
||||||
|
The `get_ui_primary_actions` method can be used to provide custom primary action, which are rendered in the header of the page, next to the title/name and any status indicators. These primary actions are typically used to provide quick access to common actions related to the current page.
|
||||||
|
|
||||||
|
::: plugin.base.ui.mixins.UserInterfaceMixin.get_ui_primary_actions
|
||||||
|
options:
|
||||||
|
show_bases: False
|
||||||
|
show_root_heading: False
|
||||||
|
show_root_toc_entry: False
|
||||||
|
extra:
|
||||||
|
show_source: True
|
||||||
|
summary: False
|
||||||
|
members: []
|
||||||
|
|
||||||
## 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:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ FeatureType = Literal[
|
|||||||
'template_editor', # Custom template editor
|
'template_editor', # Custom template editor
|
||||||
'template_preview', # Custom template preview
|
'template_preview', # Custom template preview
|
||||||
'navigation', # Custom navigation items
|
'navigation', # Custom navigation items
|
||||||
|
'primary_action', # Custom primary action buttons
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ class UserInterfaceMixin:
|
|||||||
'panel': self.get_ui_panels,
|
'panel': self.get_ui_panels,
|
||||||
'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,
|
||||||
}
|
}
|
||||||
|
|
||||||
if feature_type in feature_map:
|
if feature_type in feature_map:
|
||||||
@@ -203,3 +205,29 @@ class UserInterfaceMixin:
|
|||||||
"""
|
"""
|
||||||
# Default implementation returns an empty list
|
# Default implementation returns an empty list
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_ui_primary_actions(
|
||||||
|
self, request: Request, context: dict, **kwargs
|
||||||
|
) -> list[UIFeature]:
|
||||||
|
"""Return a list of custom primary action buttons to be injected into the UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTPRequest object (including user information)
|
||||||
|
context: Additional context data provided by the UI (query parameters)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of custom primary action buttons to be injected into the UI
|
||||||
|
"""
|
||||||
|
# Sample code to render conditional primary action button based on context
|
||||||
|
# items = []
|
||||||
|
# if self.my_assert_function(context):
|
||||||
|
# items.append({
|
||||||
|
# 'key': 'sample-primary-action',
|
||||||
|
# 'title': 'Sample Primary Action',
|
||||||
|
# 'icon': 'ti:plus:outline',
|
||||||
|
# 'options': {'url': '/core/sample-primary-action/', 'color': 'orange'},
|
||||||
|
# })
|
||||||
|
# return items
|
||||||
|
|
||||||
|
# Default implementation returns an empty list
|
||||||
|
return []
|
||||||
|
|||||||
@@ -233,3 +233,13 @@ 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-nav-item')
|
self.assertEqual(response.data[0]['key'], 'sample-nav-item')
|
||||||
self.assertEqual(response.data[0]['title'], 'Sample Nav Item')
|
self.assertEqual(response.data[0]['title'], 'Sample Nav Item')
|
||||||
|
|
||||||
|
def test_ui_primary_actions(self):
|
||||||
|
"""Test that the sample UI plugin provides custom primary actions."""
|
||||||
|
response = self.get(
|
||||||
|
reverse('api-plugin-ui-feature-list', kwargs={'feature': 'primary_action'})
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(response.data))
|
||||||
|
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')
|
||||||
|
|||||||
@@ -226,6 +226,17 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_ui_primary_actions(self, request, context, **kwargs):
|
||||||
|
"""Return a list of custom primary action buttons."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'key': 'sample-primary-action',
|
||||||
|
'title': 'Sample Primary Action',
|
||||||
|
'icon': 'ti:plus:outline',
|
||||||
|
'options': {'url': '/core/sample-primary-action/', 'color': 'orange'},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
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'}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export default function PrimaryActionButton({
|
|||||||
icon,
|
icon,
|
||||||
color,
|
color,
|
||||||
hidden,
|
hidden,
|
||||||
onClick
|
onClick,
|
||||||
|
leftSection
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
title: string;
|
title: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
@@ -20,6 +21,7 @@ export default function PrimaryActionButton({
|
|||||||
color?: string;
|
color?: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
leftSection?: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return null;
|
return null;
|
||||||
@@ -28,7 +30,7 @@ export default function PrimaryActionButton({
|
|||||||
return (
|
return (
|
||||||
<Tooltip label={tooltip ?? title} position='bottom' hidden={!tooltip}>
|
<Tooltip label={tooltip ?? title} position='bottom' hidden={!tooltip}>
|
||||||
<Button
|
<Button
|
||||||
leftSection={icon && <InvenTreeIcon icon={icon} />}
|
leftSection={leftSection ?? (icon && <InvenTreeIcon icon={icon} />)}
|
||||||
color={color}
|
color={color}
|
||||||
radius='sm'
|
radius='sm'
|
||||||
p='xs'
|
p='xs'
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import { useHotkeys } from '@mantine/hooks';
|
|||||||
import { StylishText } from '@lib/components/StylishText';
|
import { StylishText } from '@lib/components/StylishText';
|
||||||
import { shortenString } from '@lib/functions/String';
|
import { shortenString } from '@lib/functions/String';
|
||||||
import { Fragment, type ReactNode, useMemo } from 'react';
|
import { Fragment, type ReactNode, useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature';
|
||||||
import { useUserSettingsState } from '../../states/SettingsStates';
|
import { useUserSettingsState } from '../../states/SettingsStates';
|
||||||
|
import PrimaryActionButton from '../buttons/PrimaryActionButton';
|
||||||
import { ApiImage } from '../images/ApiImage';
|
import { ApiImage } from '../images/ApiImage';
|
||||||
|
import { ApiIcon } from '../items/ApiIcon';
|
||||||
|
import type { PrimaryActionUIFeature } from '../plugins/PluginUIFeatureTypes';
|
||||||
import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList';
|
import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList';
|
||||||
import PageTitle from './PageTitle';
|
import PageTitle from './PageTitle';
|
||||||
|
|
||||||
@@ -45,6 +50,8 @@ export function PageDetail({
|
|||||||
editEnabled
|
editEnabled
|
||||||
}: Readonly<PageDetailInterface>) {
|
}: Readonly<PageDetailInterface>) {
|
||||||
const userSettings = useUserSettingsState();
|
const userSettings = useUserSettingsState();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
useHotkeys([
|
useHotkeys([
|
||||||
[
|
[
|
||||||
'mod+E',
|
'mod+E',
|
||||||
@@ -86,6 +93,39 @@ export function PageDetail({
|
|||||||
}
|
}
|
||||||
}, [breadcrumbs, last_crumb, userSettings]);
|
}, [breadcrumbs, last_crumb, userSettings]);
|
||||||
|
|
||||||
|
const extraActions = usePluginUIFeature<PrimaryActionUIFeature>({
|
||||||
|
featureType: 'primary_action',
|
||||||
|
context: { location: location.pathname }
|
||||||
|
});
|
||||||
|
|
||||||
|
// action caching
|
||||||
|
const computedActions = useMemo(() => {
|
||||||
|
const extraActionArray: ReactNode[] = extraActions.map((action) => {
|
||||||
|
const { options: opts, func } = action;
|
||||||
|
const { title, icon, context, options } = opts;
|
||||||
|
|
||||||
|
const click = () => {
|
||||||
|
const url = options?.url;
|
||||||
|
if (url) {
|
||||||
|
navigate(url);
|
||||||
|
} else if (func) {
|
||||||
|
func(context);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimaryActionButton
|
||||||
|
title={title}
|
||||||
|
leftSection={<ApiIcon name={icon as string} />}
|
||||||
|
color={options?.color}
|
||||||
|
onClick={click}
|
||||||
|
key={title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return [...(extraActionArray ?? []), ...(actions ?? [])];
|
||||||
|
}, [extraActions, actions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={pageTitleString} />
|
<PageTitle title={pageTitleString} />
|
||||||
@@ -140,9 +180,9 @@ export function PageDetail({
|
|||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{actions && (
|
{computedActions && (
|
||||||
<Group gap={5} justify='right' wrap='nowrap' align='flex-start'>
|
<Group gap={5} justify='right' wrap='nowrap' align='flex-start'>
|
||||||
{actions.map((action, idx) => (
|
{computedActions.map((action, idx) => (
|
||||||
<Fragment key={idx}>{action}</Fragment>
|
<Fragment key={idx}>{action}</Fragment>
|
||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export enum PluginUIFeatureType {
|
|||||||
panel = 'panel',
|
panel = 'panel',
|
||||||
template_editor = 'template_editor',
|
template_editor = 'template_editor',
|
||||||
template_preview = 'template_preview',
|
template_preview = 'template_preview',
|
||||||
navigation = 'navigation'
|
navigation = 'navigation',
|
||||||
|
primary_action = 'primary_action'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -84,3 +84,11 @@ export type NavigationUIFeature = {
|
|||||||
featureContext: {};
|
featureContext: {};
|
||||||
featureReturnType: undefined;
|
featureReturnType: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PrimaryActionUIFeature = {
|
||||||
|
featureType: 'primary_action';
|
||||||
|
requestContext: {};
|
||||||
|
responseOptions: PluginUIFeature;
|
||||||
|
featureContext: {};
|
||||||
|
featureReturnType: undefined;
|
||||||
|
};
|
||||||
|
|||||||
@@ -100,11 +100,8 @@ test('Stock - Location Delete', async ({ browser }) => {
|
|||||||
|
|
||||||
// Delete this location, and all child locations
|
// Delete this location, and all child locations
|
||||||
await page
|
await page
|
||||||
.locator('div')
|
.getByRole('button', { name: 'action-menu-location-actions' })
|
||||||
.filter({
|
.first()
|
||||||
hasText: new RegExp(`^Stock>PCB Assembler>${loc_1}Stock Location$`)
|
|
||||||
})
|
|
||||||
.getByLabel('action-menu-location-actions')
|
|
||||||
.click();
|
.click();
|
||||||
await page
|
await page
|
||||||
.getByRole('menuitem', { name: 'action-menu-location-actions-delete' })
|
.getByRole('menuitem', { name: 'action-menu-location-actions-delete' })
|
||||||
|
|||||||
Reference in New Issue
Block a user