frontend-plugin-api: use react element for header action

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-16 13:08:08 +01:00
parent 273655886f
commit 85261a3b3c
4 changed files with 31 additions and 49 deletions
@@ -14,16 +14,14 @@
* limitations under the License.
*/
import { lazy as reactLazy } from 'react';
import { ExtensionBoundary } from '../components';
import {
coreExtensionData,
createExtensionBlueprint,
createExtensionBlueprintParams,
createExtensionDataRef,
} from '../wiring';
const actionDataRef = createExtensionDataRef<() => Promise<JSX.Element>>().with(
{ id: 'core.header-action.loader' },
);
/**
* Creates extensions that provide plugin-scoped header actions.
*
@@ -37,14 +35,18 @@ const actionDataRef = createExtensionDataRef<() => Promise<JSX.Element>>().with(
export const HeaderActionBlueprint = createExtensionBlueprint({
kind: 'header-action',
attachTo: { id: 'api:app/header-actions', input: 'actions' },
output: [actionDataRef],
dataRefs: {
action: actionDataRef,
},
output: [coreExtensionData.reactElement],
defineParams(params: { loader: () => Promise<JSX.Element> }) {
return createExtensionBlueprintParams(params);
},
*factory(params) {
yield actionDataRef(params.loader);
*factory(params, { node }) {
const LazyAction = reactLazy(() =>
params.loader().then(element => ({ default: () => element })),
);
yield coreExtensionData.reactElement(
<ExtensionBoundary node={node} errorPresentation="error-api">
<LazyAction />
</ExtensionBoundary>,
);
},
});
@@ -18,17 +18,15 @@ import { render, screen } from '@testing-library/react';
import { DefaultHeaderActionsApi } from './DefaultHeaderActionsApi';
describe('DefaultHeaderActionsApi', () => {
it('should return actions for a specific plugin', async () => {
it('should return actions for a specific plugin', () => {
const api = DefaultHeaderActionsApi.fromActions([
{
loader: async () => <button>Action A</button>,
element: <button>Action A</button>,
pluginId: 'plugin-a',
nodeId: 'plugin-header-action:plugin-a/action-a',
},
{
loader: async () => <button>Action B</button>,
element: <button>Action B</button>,
pluginId: 'plugin-b',
nodeId: 'plugin-header-action:plugin-b/action-b',
},
]);
@@ -36,34 +34,31 @@ describe('DefaultHeaderActionsApi', () => {
expect(api.getHeaderActions('plugin-b')).toHaveLength(1);
render(<>{api.getHeaderActions('plugin-a')}</>);
await expect(
screen.findByRole('button', { name: 'Action A' }),
).resolves.toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Action A' }),
).toBeInTheDocument();
});
it('should return an empty array for unknown plugins', () => {
const api = DefaultHeaderActionsApi.fromActions([
{
loader: async () => <span>Action</span>,
element: <span>Action</span>,
pluginId: 'plugin-a',
nodeId: 'plugin-header-action:plugin-a/action',
},
]);
expect(api.getHeaderActions('unknown-plugin')).toEqual([]);
});
it('should group multiple actions by plugin', async () => {
it('should group multiple actions by plugin', () => {
const api = DefaultHeaderActionsApi.fromActions([
{
loader: async () => <button>First</button>,
element: <button>First</button>,
pluginId: 'plugin-a',
nodeId: 'plugin-header-action:plugin-a/first',
},
{
loader: async () => <button>Second</button>,
element: <button>Second</button>,
pluginId: 'plugin-a',
nodeId: 'plugin-header-action:plugin-a/second',
},
]);
@@ -71,11 +66,7 @@ describe('DefaultHeaderActionsApi', () => {
expect(actions).toHaveLength(2);
render(<>{actions}</>);
await expect(
screen.findByRole('button', { name: 'First' }),
).resolves.toBeInTheDocument();
await expect(
screen.findByRole('button', { name: 'Second' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'First' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Second' })).toBeInTheDocument();
});
});
@@ -14,13 +14,12 @@
* limitations under the License.
*/
import { Suspense, ReactNode, lazy } from 'react';
import { ReactNode } from 'react';
import { type HeaderActionsApi } from '@backstage/frontend-plugin-api';
type ActionInput = {
loader: () => Promise<JSX.Element>;
element: JSX.Element;
pluginId: string;
nodeId: string;
};
/**
@@ -45,16 +44,7 @@ export class DefaultHeaderActionsApi implements HeaderActionsApi {
actionsByPlugin.set(action.pluginId, pluginActions);
}
const LazyAction = lazy(async () => {
const element = await action.loader();
return { default: () => element };
});
pluginActions.push(
<Suspense key={action.nodeId} fallback={null}>
<LazyAction />
</Suspense>,
);
pluginActions.push(action.element);
}
return new DefaultHeaderActionsApi(actionsByPlugin);
@@ -15,7 +15,7 @@
*/
import {
HeaderActionBlueprint,
coreExtensionData,
headerActionsApiRef,
createExtensionInput,
ApiBlueprint,
@@ -28,7 +28,7 @@ import { DefaultHeaderActionsApi } from '../apis/HeaderActionsApi';
export const HeaderActionsApi = ApiBlueprint.makeWithOverrides({
name: 'header-actions',
inputs: {
actions: createExtensionInput([HeaderActionBlueprint.dataRefs.action]),
actions: createExtensionInput([coreExtensionData.reactElement]),
},
factory: (originalFactory, { inputs }) => {
return originalFactory(defineParams =>
@@ -38,9 +38,8 @@ export const HeaderActionsApi = ApiBlueprint.makeWithOverrides({
factory: () => {
return DefaultHeaderActionsApi.fromActions(
inputs.actions.map(actionInput => ({
loader: actionInput.get(HeaderActionBlueprint.dataRefs.action),
element: actionInput.get(coreExtensionData.reactElement),
pluginId: actionInput.node.spec.plugin.pluginId,
nodeId: actionInput.node.spec.id,
})),
);
},