frontend-plugin-api: intial page and plugin header actions
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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,
|
||||
})),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -38,3 +38,4 @@ export {
|
||||
PageLayout,
|
||||
} from './components';
|
||||
export { PluginWrapperApi } from './PluginWrapperApi';
|
||||
export { HeaderActionsApi } from './HeaderActionsApi';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user