2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-06-12 03:28:37 +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:
Matthias Mair
2026-05-22 00:20:07 +02:00
committed by GitHub
parent 65d15a5945
commit f27b9b5443
10 changed files with 122 additions and 10 deletions
@@ -12,7 +12,8 @@ export default function PrimaryActionButton({
icon,
color,
hidden,
onClick
onClick,
leftSection
}: Readonly<{
title: string;
tooltip?: string;
@@ -20,6 +21,7 @@ export default function PrimaryActionButton({
color?: string;
hidden?: boolean;
onClick: () => void;
leftSection?: React.ReactNode;
}>) {
if (hidden) {
return null;
@@ -28,7 +30,7 @@ export default function PrimaryActionButton({
return (
<Tooltip label={tooltip ?? title} position='bottom' hidden={!tooltip}>
<Button
leftSection={icon && <InvenTreeIcon icon={icon} />}
leftSection={leftSection ?? (icon && <InvenTreeIcon icon={icon} />)}
color={color}
radius='sm'
p='xs'
+42 -2
View File
@@ -4,8 +4,13 @@ import { useHotkeys } from '@mantine/hooks';
import { StylishText } from '@lib/components/StylishText';
import { shortenString } from '@lib/functions/String';
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 PrimaryActionButton from '../buttons/PrimaryActionButton';
import { ApiImage } from '../images/ApiImage';
import { ApiIcon } from '../items/ApiIcon';
import type { PrimaryActionUIFeature } from '../plugins/PluginUIFeatureTypes';
import { type Breadcrumb, BreadcrumbList } from './BreadcrumbList';
import PageTitle from './PageTitle';
@@ -45,6 +50,8 @@ export function PageDetail({
editEnabled
}: Readonly<PageDetailInterface>) {
const userSettings = useUserSettingsState();
const navigate = useNavigate();
const location = useLocation();
useHotkeys([
[
'mod+E',
@@ -86,6 +93,39 @@ export function PageDetail({
}
}, [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 (
<>
<PageTitle title={pageTitleString} />
@@ -140,9 +180,9 @@ export function PageDetail({
</Group>
)}
</Group>
{actions && (
{computedActions && (
<Group gap={5} justify='right' wrap='nowrap' align='flex-start'>
{actions.map((action, idx) => (
{computedActions.map((action, idx) => (
<Fragment key={idx}>{action}</Fragment>
))}
</Group>
@@ -30,7 +30,8 @@ export enum PluginUIFeatureType {
panel = 'panel',
template_editor = 'template_editor',
template_preview = 'template_preview',
navigation = 'navigation'
navigation = 'navigation',
primary_action = 'primary_action'
}
/**
@@ -84,3 +84,11 @@ export type NavigationUIFeature = {
featureContext: {};
featureReturnType: undefined;
};
export type PrimaryActionUIFeature = {
featureType: 'primary_action';
requestContext: {};
responseOptions: PluginUIFeature;
featureContext: {};
featureReturnType: undefined;
};
+2 -5
View File
@@ -100,11 +100,8 @@ test('Stock - Location Delete', async ({ browser }) => {
// Delete this location, and all child locations
await page
.locator('div')
.filter({
hasText: new RegExp(`^Stock>PCB Assembler>${loc_1}Stock Location$`)
})
.getByLabel('action-menu-location-actions')
.getByRole('button', { name: 'action-menu-location-actions' })
.first()
.click();
await page
.getByRole('menuitem', { name: 'action-menu-location-actions-delete' })