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,98 @@
/*
* 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 { render, screen } from '@testing-library/react';
import { DefaultHeaderActionsApi } from './DefaultHeaderActionsApi';
describe('DefaultHeaderActionsApi', () => {
it('should return actions for a specific plugin', async () => {
const api = DefaultHeaderActionsApi.fromActions([
{
loader: async () => <button>Action A</button>,
pluginId: 'plugin-a',
nodeId: 'plugin-header-action:plugin-a/action-a',
},
{
loader: async () => <button>Action B</button>,
pluginId: 'plugin-b',
nodeId: 'plugin-header-action:plugin-b/action-b',
},
]);
const actionsA = api.getHeaderActions('plugin-a');
const actionsB = api.getHeaderActions('plugin-b');
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}</>);
await expect(
screen.findByRole('button', { name: 'Action A' }),
).resolves.toBeInTheDocument();
});
it('should return an empty array for unknown plugins', () => {
const api = DefaultHeaderActionsApi.fromActions([
{
loader: async () => <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 () => {
const api = DefaultHeaderActionsApi.fromActions([
{
loader: async () => <button>First</button>,
pluginId: 'plugin-a',
nodeId: 'plugin-header-action:plugin-a/first',
},
{
loader: async () => <button>Second</button>,
pluginId: 'plugin-a',
nodeId: 'plugin-header-action:plugin-a/second',
},
]);
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>
))}
</>,
);
await expect(
screen.findByRole('button', { name: 'First' }),
).resolves.toBeInTheDocument();
await expect(
screen.findByRole('button', { name: 'Second' }),
).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
/*
* 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 { Suspense, lazy } from 'react';
import {
type HeaderActionsApi,
type HeaderAction,
} from '@backstage/frontend-plugin-api';
type ActionInput = {
loader: () => Promise<JSX.Element>;
pluginId: string;
nodeId: string;
};
/**
* Default implementation of HeaderActionsApi.
*
* @internal
*/
export class DefaultHeaderActionsApi implements HeaderActionsApi {
constructor(
private readonly actionsByPlugin: Map<string, HeaderAction[]>,
) {}
getHeaderActions(pluginId: string): HeaderAction[] {
return this.actionsByPlugin.get(pluginId) ?? [];
}
static fromActions(
actions: Array<ActionInput>,
): DefaultHeaderActionsApi {
const actionsByPlugin = new Map<string, HeaderAction[]>();
for (const action of actions) {
let pluginActions = actionsByPlugin.get(action.pluginId);
if (!pluginActions) {
pluginActions = [];
actionsByPlugin.set(action.pluginId, pluginActions);
}
const LazyAction = lazy(async () => {
const element = await action.loader();
return { default: () => element };
});
pluginActions.push({
nodeId: action.nodeId,
element: (
<Suspense key={action.nodeId} fallback={null}>
<LazyAction />
</Suspense>
),
});
}
return new DefaultHeaderActionsApi(actionsByPlugin);
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { DefaultHeaderActionsApi } from './DefaultHeaderActionsApi';
@@ -0,0 +1,54 @@
/*
* 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 {
PluginHeaderActionBlueprint,
headerActionsApiRef,
createExtensionInput,
ApiBlueprint,
} from '@backstage/frontend-plugin-api';
import { DefaultHeaderActionsApi } from '../apis/HeaderActionsApi';
/**
* Contains the plugin-scoped header actions installed into the app.
*/
export const HeaderActionsApi = ApiBlueprint.makeWithOverrides({
name: 'header-actions',
inputs: {
actions: createExtensionInput([
PluginHeaderActionBlueprint.dataRefs.action,
]),
},
factory: (originalFactory, { inputs }) => {
return originalFactory(defineParams =>
defineParams({
api: headerActionsApiRef,
deps: {},
factory: () => {
return DefaultHeaderActionsApi.fromActions(
inputs.actions.map(actionInput => ({
loader: actionInput.get(
PluginHeaderActionBlueprint.dataRefs.action,
),
pluginId: actionInput.node.spec.plugin.pluginId,
nodeId: actionInput.node.spec.id,
})),
);
},
}),
);
},
});
+7 -2
View File
@@ -73,13 +73,18 @@ export const PageLayout = SwappableComponentBlueprint.make({
define({
component: SwappablePageLayout,
loader: () => (props: PageLayoutProps) => {
const { title, icon, tabs, children } = props;
const { title, icon, headerActions, tabs, children } = props;
return (
<Flex
direction="column"
style={{ flexGrow: 1, minHeight: 0, gap: 0 }}
>
<Header title={title} icon={icon} tabs={tabs} />
<Header
title={title}
icon={icon}
tabs={tabs}
customActions={headerActions}
/>
<Flex direction="column" style={{ flexGrow: 1, minHeight: 0 }}>
{children}
</Flex>
+1
View File
@@ -38,3 +38,4 @@ export {
PageLayout,
} from './components';
export { PluginWrapperApi } from './PluginWrapperApi';
export { HeaderActionsApi } from './HeaderActionsApi';
+2
View File
@@ -29,6 +29,7 @@ import {
IconsApi,
FeatureFlagsApi,
PluginWrapperApi,
HeaderActionsApi,
TranslationsApi,
oauthRequestDialogAppRootElement,
alertDisplayAppRootElement,
@@ -61,6 +62,7 @@ export const appPlugin = createFrontendPlugin({
IconsApi,
FeatureFlagsApi,
PluginWrapperApi,
HeaderActionsApi,
TranslationsApi,
DefaultSignInPage,
oauthRequestDialogAppRootElement,