frontend-plugin-api: intial page and plugin header actions

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-11 23:02:37 +01:00
parent c8960d0ec3
commit 4738047ab7
14 changed files with 437 additions and 17 deletions
@@ -0,0 +1,58 @@
/*
* Copyright 2025 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 { 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.
*
* @public
*/
export type HeaderActionsApi = {
/**
* Returns the header actions for a given plugin.
*/
getHeaderActions(pluginId: string): HeaderAction[];
};
/**
* The `ApiRef` of {@link HeaderActionsApi}.
*
* @public
*/
export const headerActionsApiRef = createApiRef<HeaderActionsApi>({
id: 'core.header-actions',
});
@@ -49,3 +49,4 @@ export * from './RouteResolutionApi';
export * from './StorageApi';
export * from './AnalyticsApi';
export * from './TranslationApi';
export * from './HeaderActionsApi';
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ReactNode } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { IconElement } from '../icons/types';
import { RouteRef } from '../routing';
@@ -23,6 +24,38 @@ import {
createExtensionInput,
} from '../wiring';
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 {
const headerActionsApi = useApi(headerActionsApiRef);
const appTreeApi = useApi(appTreeApiRef);
const pluginActions = headerActionsApi.getHeaderActions(pluginId);
const allActions = [...pageActions, ...pluginActions];
if (allActions.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)}</>;
}
/**
* Creates extensions that are routable React page components.
@@ -39,6 +72,7 @@ export const PageBlueprint = createExtensionBlueprint({
coreExtensionData.reactElement,
coreExtensionData.title.optional(),
]),
headerActions: createExtensionInput([coreExtensionData.reactElement]),
},
output: [
coreExtensionData.routePath,
@@ -73,15 +107,24 @@ export const PageBlueprint = createExtensionBlueprint({
node.spec.plugin.title ??
node.spec.plugin.pluginId;
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 = () => (
<PageLayout title={title} icon={icon}>
{ExtensionBoundary.lazy(node, loader)}
</PageLayout>
);
const PageContent = () => {
const headerActions = useHeaderActions(pageActions, pluginId);
return (
<PageLayout title={title} icon={icon} headerActions={headerActions}>
{ExtensionBoundary.lazy(node, loader)}
</PageLayout>
);
};
yield coreExtensionData.reactElement(<PageContent />);
} else if (inputs.pages.length > 0) {
// Parent page with sub-pages - render header with tabs
@@ -98,9 +141,15 @@ export const PageBlueprint = createExtensionBlueprint({
const PageContent = () => {
const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath);
const headerActions = useHeaderActions(pageActions, pluginId);
return (
<PageLayout title={title} icon={icon} tabs={tabs}>
<PageLayout
title={title}
icon={icon}
tabs={tabs}
headerActions={headerActions}
>
<Routes>
{firstPagePath && (
<Route
@@ -122,9 +171,17 @@ export const PageBlueprint = createExtensionBlueprint({
yield coreExtensionData.reactElement(<PageContent />);
} else {
yield coreExtensionData.reactElement(
<PageLayout title={title} icon={icon} />,
);
const PageContent = () => {
const headerActions = useHeaderActions(pageActions, pluginId);
return (
<PageLayout
title={title}
icon={icon}
headerActions={headerActions}
/>
);
};
yield coreExtensionData.reactElement(<PageContent />);
}
if (params.routeRef) {
yield coreExtensionData.routeRef(params.routeRef);
@@ -18,13 +18,13 @@ import { coreExtensionData, createExtensionBlueprint } from '../wiring';
import { ExtensionBoundary } from '../components';
/**
* Createx extensions that are routable React page components.
* Creates extensions that render header actions in a page layout.
*
* @public
*/
export const HeaderActionBlueprint = createExtensionBlueprint({
kind: 'header-action',
attachTo: { id: 'app/routes', input: 'headerActions' },
export const PageHeaderActionBlueprint = createExtensionBlueprint({
kind: 'page-header-action',
attachTo: { relative: { kind: 'page' }, input: 'headerActions' },
output: [coreExtensionData.reactElement],
*factory(params: { loader: () => Promise<JSX.Element> }, { node }) {
@@ -0,0 +1,50 @@
/*
* Copyright 2025 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 {
createExtensionBlueprint,
createExtensionBlueprintParams,
createExtensionDataRef,
} from '../wiring';
const actionDataRef = createExtensionDataRef<
() => Promise<JSX.Element>
>().with({ id: 'core.plugin-header-action.loader' });
/**
* Creates extensions that provide plugin-scoped header actions.
*
* @remarks
*
* These actions are automatically scoped to the plugin that provides them
* and will appear in the header of all pages belonging to that plugin.
*
* @public
*/
export const PluginHeaderActionBlueprint = createExtensionBlueprint({
kind: 'plugin-header-action',
attachTo: { id: 'api:app/header-actions', input: 'actions' },
output: [actionDataRef],
dataRefs: {
action: actionDataRef,
},
defineParams(params: { loader: () => Promise<JSX.Element> }) {
return createExtensionBlueprintParams(params);
},
*factory(params) {
yield actionDataRef(params.loader);
},
});
@@ -23,4 +23,5 @@ export { AppRootElementBlueprint } from './AppRootElementBlueprint';
export { NavItemBlueprint } from './NavItemBlueprint';
export { PageBlueprint } from './PageBlueprint';
export { SubPageBlueprint } from './SubPageBlueprint';
export { HeaderActionBlueprint } from './HeaderActionBlueprint';
export { PageHeaderActionBlueprint } from './PageHeaderActionBlueprint';
export { PluginHeaderActionBlueprint } from './PluginHeaderActionBlueprint';
@@ -36,6 +36,7 @@ export interface PageTab {
export interface PageLayoutProps {
title?: string;
icon?: IconElement;
headerActions?: ReactNode;
tabs?: PageTab[];
children?: ReactNode;
}
@@ -44,7 +45,7 @@ export interface PageLayoutProps {
* Default implementation of PageLayout using plain HTML elements
*/
function DefaultPageLayout(props: PageLayoutProps): JSX.Element {
const { title, icon, tabs, children } = props;
const { title, icon, headerActions, tabs, children } = props;
return (
<div
@@ -77,6 +78,9 @@ function DefaultPageLayout(props: PageLayoutProps): JSX.Element {
>
{icon}
{title}
{headerActions && (
<div style={{ marginLeft: 'auto' }}>{headerActions}</div>
)}
</div>
)}
{tabs && tabs.length > 0 && (