diff --git a/packages/frontend-plugin-api/src/apis/definitions/HeaderActionsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/HeaderActionsApi.ts new file mode 100644 index 0000000000..77681ef959 --- /dev/null +++ b/packages/frontend-plugin-api/src/apis/definitions/HeaderActionsApi.ts @@ -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({ + id: 'core.header-actions', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 5e7f713fcf..83ec4120aa 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -49,3 +49,4 @@ export * from './RouteResolutionApi'; export * from './StorageApi'; export * from './AnalyticsApi'; export * from './TranslationApi'; +export * from './HeaderActionsApi'; diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx index 73548f7916..5406db28fe 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx @@ -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 = () => ( - - {ExtensionBoundary.lazy(node, loader)} - - ); + const PageContent = () => { + const headerActions = useHeaderActions(pageActions, pluginId); + return ( + + {ExtensionBoundary.lazy(node, loader)} + + ); + }; yield coreExtensionData.reactElement(); } 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 ( - + {firstPagePath && ( ); } else { - yield coreExtensionData.reactElement( - , - ); + const PageContent = () => { + const headerActions = useHeaderActions(pageActions, pluginId); + return ( + + ); + }; + yield coreExtensionData.reactElement(); } if (params.routeRef) { yield coreExtensionData.routeRef(params.routeRef); diff --git a/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PageHeaderActionBlueprint.tsx similarity index 80% rename from packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx rename to packages/frontend-plugin-api/src/blueprints/PageHeaderActionBlueprint.tsx index 469407a404..d6e1cb339a 100644 --- a/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageHeaderActionBlueprint.tsx @@ -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 }, { node }) { diff --git a/packages/frontend-plugin-api/src/blueprints/PluginHeaderActionBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginHeaderActionBlueprint.tsx new file mode 100644 index 0000000000..b36eae3bf6 --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/PluginHeaderActionBlueprint.tsx @@ -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 +>().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 }) { + return createExtensionBlueprintParams(params); + }, + *factory(params) { + yield actionDataRef(params.loader); + }, +}); diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index fd55dbb7a5..6640b14e7d 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -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'; diff --git a/packages/frontend-plugin-api/src/components/PageLayout.tsx b/packages/frontend-plugin-api/src/components/PageLayout.tsx index fa7ae80a11..2e59bac80c 100644 --- a/packages/frontend-plugin-api/src/components/PageLayout.tsx +++ b/packages/frontend-plugin-api/src/components/PageLayout.tsx @@ -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 (
{icon} {title} + {headerActions && ( +
{headerActions}
+ )}
)} {tabs && tabs.length > 0 && ( diff --git a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx b/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx new file mode 100644 index 0000000000..9cc17f88f9 --- /dev/null +++ b/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx @@ -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 () => , + pluginId: 'plugin-a', + nodeId: 'plugin-header-action:plugin-a/action-a', + }, + { + loader: async () => , + 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 () => Action, + 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 () => , + pluginId: 'plugin-a', + nodeId: 'plugin-header-action:plugin-a/first', + }, + { + loader: async () => , + 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 => ( + {a.element} + ))} + , + ); + await expect( + screen.findByRole('button', { name: 'First' }), + ).resolves.toBeInTheDocument(); + await expect( + screen.findByRole('button', { name: 'Second' }), + ).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx b/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx new file mode 100644 index 0000000000..2fa35c97de --- /dev/null +++ b/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx @@ -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; + pluginId: string; + nodeId: string; +}; + +/** + * Default implementation of HeaderActionsApi. + * + * @internal + */ +export class DefaultHeaderActionsApi implements HeaderActionsApi { + constructor( + private readonly actionsByPlugin: Map, + ) {} + + getHeaderActions(pluginId: string): HeaderAction[] { + return this.actionsByPlugin.get(pluginId) ?? []; + } + + static fromActions( + actions: Array, + ): DefaultHeaderActionsApi { + const actionsByPlugin = new Map(); + + 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: ( + + + + ), + }); + } + + return new DefaultHeaderActionsApi(actionsByPlugin); + } +} diff --git a/plugins/app/src/apis/HeaderActionsApi/index.ts b/plugins/app/src/apis/HeaderActionsApi/index.ts new file mode 100644 index 0000000000..15dc55382a --- /dev/null +++ b/plugins/app/src/apis/HeaderActionsApi/index.ts @@ -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'; diff --git a/plugins/app/src/extensions/HeaderActionsApi.ts b/plugins/app/src/extensions/HeaderActionsApi.ts new file mode 100644 index 0000000000..a64faf3ed8 --- /dev/null +++ b/plugins/app/src/extensions/HeaderActionsApi.ts @@ -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, + })), + ); + }, + }), + ); + }, +}); diff --git a/plugins/app/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx index 68df086935..30f6fe3f8d 100644 --- a/plugins/app/src/extensions/components.tsx +++ b/plugins/app/src/extensions/components.tsx @@ -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 ( -
+
{children} diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index e97a739e44..96a74b055c 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -38,3 +38,4 @@ export { PageLayout, } from './components'; export { PluginWrapperApi } from './PluginWrapperApi'; +export { HeaderActionsApi } from './HeaderActionsApi'; diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index bc4d8d278e..71f200f01f 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -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,