frontend-plugin-api: switch to just PageHeaderAction

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-11 23:18:14 +01:00
parent 4738047ab7
commit d6de6069aa
8 changed files with 36 additions and 147 deletions
@@ -17,27 +17,14 @@
import { ReactNode } from 'react';
import { createApiRef } from '../system';
/**
* A single header action with its node ID for ordering.
*
* @public
*/
export interface HeaderAction {
/** The extension node ID, used for ordering across sources */
nodeId: string;
/** The rendered header action element */
element: ReactNode;
}
/**
* API for retrieving plugin-scoped header actions.
*
* @remarks
*
* Plugin-level header actions are provided via {@link @backstage/frontend-plugin-api#PluginHeaderActionBlueprint}
* and automatically scoped to the providing plugin. The `nodeId` field on each
* action is used together with `AppTreeApi` to determine the display order
* across both page-level and plugin-level actions.
* Header actions are provided via
* {@link @backstage/frontend-plugin-api#HeaderActionBlueprint}
* and automatically scoped to the providing plugin.
*
* @public
*/
@@ -45,7 +32,7 @@ export type HeaderActionsApi = {
/**
* Returns the header actions for a given plugin.
*/
getHeaderActions(pluginId: string): HeaderAction[];
getHeaderActions(pluginId: string): ReactNode[];
};
/**
@@ -20,9 +20,9 @@ import {
createExtensionDataRef,
} from '../wiring';
const actionDataRef = createExtensionDataRef<
() => Promise<JSX.Element>
>().with({ id: 'core.plugin-header-action.loader' });
const actionDataRef = createExtensionDataRef<() => Promise<JSX.Element>>().with(
{ id: 'core.header-action.loader' },
);
/**
* Creates extensions that provide plugin-scoped header actions.
@@ -34,8 +34,8 @@ const actionDataRef = createExtensionDataRef<
*
* @public
*/
export const PluginHeaderActionBlueprint = createExtensionBlueprint({
kind: 'plugin-header-action',
export const HeaderActionBlueprint = createExtensionBlueprint({
kind: 'header-action',
attachTo: { id: 'api:app/header-actions', input: 'actions' },
output: [actionDataRef],
dataRefs: {
@@ -26,35 +26,14 @@ import {
import { ExtensionBoundary, PageLayout, PageTab } from '../components';
import { useApi } from '../apis/system';
import { headerActionsApiRef } from '../apis/definitions/HeaderActionsApi';
import { appTreeApiRef } from '../apis/definitions/AppTreeApi';
import { HeaderAction } from '../apis/definitions/HeaderActionsApi';
/**
* Hook that collects page-level and plugin-level header actions, then
* sorts them by their position in the global app tree node list.
*/
function useHeaderActions(
pageActions: HeaderAction[],
pluginId: string,
): ReactNode {
function useHeaderActions(pluginId: string): ReactNode {
const headerActionsApi = useApi(headerActionsApiRef);
const appTreeApi = useApi(appTreeApiRef);
const pluginActions = headerActionsApi.getHeaderActions(pluginId);
const allActions = [...pageActions, ...pluginActions];
if (allActions.length === 0) {
const actions = headerActionsApi.getHeaderActions(pluginId);
if (actions.length === 0) {
return undefined;
}
// Build a position index from the global node ordering
const nodeKeys = [...appTreeApi.getTree().tree.nodes.keys()];
const orderIndex = new Map(nodeKeys.map((id, i) => [id, i]));
allActions.sort(
(a, b) => (orderIndex.get(a.nodeId) ?? 0) - (orderIndex.get(b.nodeId) ?? 0),
);
return <>{allActions.map(a => a.element)}</>;
return <>{actions}</>;
}
/**
@@ -72,7 +51,6 @@ export const PageBlueprint = createExtensionBlueprint({
coreExtensionData.reactElement,
coreExtensionData.title.optional(),
]),
headerActions: createExtensionInput([coreExtensionData.reactElement]),
},
output: [
coreExtensionData.routePath,
@@ -109,16 +87,11 @@ export const PageBlueprint = createExtensionBlueprint({
const icon = params.icon ?? node.spec.plugin.icon;
const pluginId = node.spec.plugin.pluginId;
const pageActions: HeaderAction[] = inputs.headerActions.map(action => ({
nodeId: action.node.spec.id,
element: action.get(coreExtensionData.reactElement),
}));
yield coreExtensionData.routePath(config.path ?? params.path);
if (params.loader) {
const loader = params.loader;
const PageContent = () => {
const headerActions = useHeaderActions(pageActions, pluginId);
const headerActions = useHeaderActions(pluginId);
return (
<PageLayout title={title} icon={icon} headerActions={headerActions}>
{ExtensionBoundary.lazy(node, loader)}
@@ -141,7 +114,7 @@ export const PageBlueprint = createExtensionBlueprint({
const PageContent = () => {
const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath);
const headerActions = useHeaderActions(pageActions, pluginId);
const headerActions = useHeaderActions(pluginId);
return (
<PageLayout
@@ -172,13 +145,9 @@ export const PageBlueprint = createExtensionBlueprint({
yield coreExtensionData.reactElement(<PageContent />);
} else {
const PageContent = () => {
const headerActions = useHeaderActions(pageActions, pluginId);
const headerActions = useHeaderActions(pluginId);
return (
<PageLayout
title={title}
icon={icon}
headerActions={headerActions}
/>
<PageLayout title={title} icon={icon} headerActions={headerActions} />
);
};
yield coreExtensionData.reactElement(<PageContent />);
@@ -1,35 +0,0 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { coreExtensionData, createExtensionBlueprint } from '../wiring';
import { ExtensionBoundary } from '../components';
/**
* Creates extensions that render header actions in a page layout.
*
* @public
*/
export const PageHeaderActionBlueprint = createExtensionBlueprint({
kind: 'page-header-action',
attachTo: { relative: { kind: 'page' }, input: 'headerActions' },
output: [coreExtensionData.reactElement],
*factory(params: { loader: () => Promise<JSX.Element> }, { node }) {
yield coreExtensionData.reactElement(
ExtensionBoundary.lazy(node, params.loader),
);
},
});
@@ -23,5 +23,4 @@ export { AppRootElementBlueprint } from './AppRootElementBlueprint';
export { NavItemBlueprint } from './NavItemBlueprint';
export { PageBlueprint } from './PageBlueprint';
export { SubPageBlueprint } from './SubPageBlueprint';
export { PageHeaderActionBlueprint } from './PageHeaderActionBlueprint';
export { PluginHeaderActionBlueprint } from './PluginHeaderActionBlueprint';
export { HeaderActionBlueprint } from './HeaderActionBlueprint';
@@ -32,19 +32,10 @@ describe('DefaultHeaderActionsApi', () => {
},
]);
const actionsA = api.getHeaderActions('plugin-a');
const actionsB = api.getHeaderActions('plugin-b');
expect(api.getHeaderActions('plugin-a')).toHaveLength(1);
expect(api.getHeaderActions('plugin-b')).toHaveLength(1);
expect(actionsA).toHaveLength(1);
expect(actionsB).toHaveLength(1);
expect(actionsA[0].nodeId).toBe(
'plugin-header-action:plugin-a/action-a',
);
expect(actionsB[0].nodeId).toBe(
'plugin-header-action:plugin-b/action-b',
);
render(<>{actionsA[0].element}</>);
render(<>{api.getHeaderActions('plugin-a')}</>);
await expect(
screen.findByRole('button', { name: 'Action A' }),
).resolves.toBeInTheDocument();
@@ -78,16 +69,8 @@ describe('DefaultHeaderActionsApi', () => {
const actions = api.getHeaderActions('plugin-a');
expect(actions).toHaveLength(2);
expect(actions[0].nodeId).toBe('plugin-header-action:plugin-a/first');
expect(actions[1].nodeId).toBe('plugin-header-action:plugin-a/second');
render(
<>
{actions.map(a => (
<span key={a.nodeId}>{a.element}</span>
))}
</>,
);
render(<>{actions}</>);
await expect(
screen.findByRole('button', { name: 'First' }),
).resolves.toBeInTheDocument();
@@ -14,11 +14,8 @@
* limitations under the License.
*/
import { Suspense, lazy } from 'react';
import {
type HeaderActionsApi,
type HeaderAction,
} from '@backstage/frontend-plugin-api';
import { Suspense, ReactNode, lazy } from 'react';
import { type HeaderActionsApi } from '@backstage/frontend-plugin-api';
type ActionInput = {
loader: () => Promise<JSX.Element>;
@@ -32,18 +29,14 @@ type ActionInput = {
* @internal
*/
export class DefaultHeaderActionsApi implements HeaderActionsApi {
constructor(
private readonly actionsByPlugin: Map<string, HeaderAction[]>,
) {}
constructor(private readonly actionsByPlugin: Map<string, ReactNode[]>) {}
getHeaderActions(pluginId: string): HeaderAction[] {
getHeaderActions(pluginId: string): ReactNode[] {
return this.actionsByPlugin.get(pluginId) ?? [];
}
static fromActions(
actions: Array<ActionInput>,
): DefaultHeaderActionsApi {
const actionsByPlugin = new Map<string, HeaderAction[]>();
static fromActions(actions: Array<ActionInput>): DefaultHeaderActionsApi {
const actionsByPlugin = new Map<string, ReactNode[]>();
for (const action of actions) {
let pluginActions = actionsByPlugin.get(action.pluginId);
@@ -57,14 +50,11 @@ export class DefaultHeaderActionsApi implements HeaderActionsApi {
return { default: () => element };
});
pluginActions.push({
nodeId: action.nodeId,
element: (
<Suspense key={action.nodeId} fallback={null}>
<LazyAction />
</Suspense>
),
});
pluginActions.push(
<Suspense key={action.nodeId} fallback={null}>
<LazyAction />
</Suspense>,
);
}
return new DefaultHeaderActionsApi(actionsByPlugin);
@@ -15,7 +15,7 @@
*/
import {
PluginHeaderActionBlueprint,
HeaderActionBlueprint,
headerActionsApiRef,
createExtensionInput,
ApiBlueprint,
@@ -28,9 +28,7 @@ import { DefaultHeaderActionsApi } from '../apis/HeaderActionsApi';
export const HeaderActionsApi = ApiBlueprint.makeWithOverrides({
name: 'header-actions',
inputs: {
actions: createExtensionInput([
PluginHeaderActionBlueprint.dataRefs.action,
]),
actions: createExtensionInput([HeaderActionBlueprint.dataRefs.action]),
},
factory: (originalFactory, { inputs }) => {
return originalFactory(defineParams =>
@@ -40,9 +38,7 @@ export const HeaderActionsApi = ApiBlueprint.makeWithOverrides({
factory: () => {
return DefaultHeaderActionsApi.fromActions(
inputs.actions.map(actionInput => ({
loader: actionInput.get(
PluginHeaderActionBlueprint.dataRefs.action,
),
loader: actionInput.get(HeaderActionBlueprint.dataRefs.action),
pluginId: actionInput.node.spec.plugin.pluginId,
nodeId: actionInput.node.spec.id,
})),