diff --git a/.changeset/app-visualizer-subpages.md b/.changeset/app-visualizer-subpages.md index a2a85f9ac7..164dcaa126 100644 --- a/.changeset/app-visualizer-subpages.md +++ b/.changeset/app-visualizer-subpages.md @@ -2,4 +2,4 @@ '@backstage/plugin-app-visualizer': minor --- -Migrated to use `SubPageBlueprint` for tabbed navigation and added a copy-tree-as-JSON header action using `HeaderActionBlueprint`. The plugin now specifies a `title` and `icon`. +Migrated to use `SubPageBlueprint` for tabbed navigation and added a copy-tree-as-JSON plugin header action using `PluginHeaderActionBlueprint`. The plugin now specifies a `title` and `icon`. diff --git a/.changeset/page-layout-and-header-actions.md b/.changeset/page-layout-and-header-actions.md index 3bcb1e072b..19cee39a3e 100644 --- a/.changeset/page-layout-and-header-actions.md +++ b/.changeset/page-layout-and-header-actions.md @@ -3,4 +3,4 @@ '@backstage/plugin-app': minor --- -Added `SubPageBlueprint` for creating sub-page tabs, `HeaderActionBlueprint` and `HeaderActionsApi` for plugin-scoped header actions, and `PageLayout` as a swappable component. The `PageBlueprint` now supports sub-pages with tabbed navigation, page title, icon, and header actions. Plugins can now specify a `title` and `icon` in `createFrontendPlugin`. +Added `SubPageBlueprint` for creating sub-page tabs, `PluginHeaderActionBlueprint` and `PluginHeaderActionsApi` for plugin-scoped header actions, and `PageLayout` as a swappable component. The `PageBlueprint` now supports sub-pages with tabbed navigation, page title, icon, and header actions. Plugins can now specify a `title` and `icon` in `createFrontendPlugin`. diff --git a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md index 9071d0910a..5a3fb530a4 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -29,9 +29,9 @@ To enable sub-pages on a page, you can either omit the `loader` param to use the Sub-page extensions create tabbed content within a parent page. They are attached to a page extension's `pages` input and rendered as tabs in the page header. Each sub-page has a `path` (relative to the parent page), a `title` for the tab, and an optional `icon`. Content is lazy-loaded via a `loader` function. -### HeaderAction - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.HeaderActionBlueprint.html) +### PluginHeaderAction - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.PluginHeaderActionBlueprint.html) -Header action extensions provide plugin-scoped actions that appear in the page header. They are automatically scoped to the plugin that provides them and will appear in the header of all pages belonging to that plugin. Actions are lazy-loaded via a `loader` function that returns a React element. +Plugin header action extensions provide plugin-scoped actions that appear in the page header. They are automatically scoped to the plugin that provides them and will appear in the header of all pages belonging to that plugin. Actions are lazy-loaded via a `loader` function that returns a React element. ## Extension blueprints in `@backstage/frontend-plugin-api/alpha` diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 6770e5f559..21412e54f2 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -394,7 +394,7 @@ describe('createApp', () => { - + ] diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index a014b4a80a..50ebf01a31 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1424,39 +1424,6 @@ export const googleAuthApiRef: ApiRef< SessionApi >; -// @public -export const HeaderActionBlueprint: ExtensionBlueprint_2<{ - kind: 'header-action'; - params: (params: { - loader: () => Promise; - }) => ExtensionBlueprintParams_2<{ - loader: () => Promise; - }>; - output: ExtensionDataRef_2< - () => Promise, - 'core.header-action.loader', - {} - >; - inputs: {}; - config: {}; - configInput: {}; - dataRefs: { - action: ConfigurableExtensionDataRef_2< - () => Promise, - 'core.header-action.loader', - {} - >; - }; -}>; - -// @public -export type HeaderActionsApi = { - getHeaderActions(pluginId: string): ReactNode[]; -}; - -// @public -export const headerActionsApiRef: ApiRef_2; - // @public @deprecated export type IconComponent = ComponentType<{ fontSize?: 'medium' | 'large' | 'small' | 'inherit'; @@ -1869,6 +1836,29 @@ export type PendingOAuthRequest = { trigger(): Promise; }; +// @public +export const PluginHeaderActionBlueprint: ExtensionBlueprint_2<{ + kind: 'plugin-header-action'; + params: (params: { + loader: () => Promise; + }) => ExtensionBlueprintParams_2<{ + loader: () => Promise; + }>; + output: ExtensionDataRef_2; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: never; +}>; + +// @public +export type PluginHeaderActionsApi = { + getPluginHeaderActions(pluginId: string): ReactNode[]; +}; + +// @public +export const pluginHeaderActionsApiRef: ApiRef_2; + // @public (undocumented) export interface PluginOptions< TId extends string, diff --git a/packages/frontend-plugin-api/src/apis/definitions/HeaderActionsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts similarity index 74% rename from packages/frontend-plugin-api/src/apis/definitions/HeaderActionsApi.ts rename to packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts index 04580a8be8..6f235148c6 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/HeaderActionsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts @@ -23,23 +23,23 @@ import { createApiRef } from '../system'; * @remarks * * Header actions are provided via - * {@link @backstage/frontend-plugin-api#HeaderActionBlueprint} + * {@link @backstage/frontend-plugin-api#PluginHeaderActionBlueprint} * and automatically scoped to the providing plugin. * * @public */ -export type HeaderActionsApi = { +export type PluginHeaderActionsApi = { /** * Returns the header actions for a given plugin. */ - getHeaderActions(pluginId: string): ReactNode[]; + getPluginHeaderActions(pluginId: string): ReactNode[]; }; /** - * The `ApiRef` of {@link HeaderActionsApi}. + * The `ApiRef` of {@link PluginHeaderActionsApi}. * * @public */ -export const headerActionsApiRef = createApiRef({ - id: 'core.header-actions', +export const pluginHeaderActionsApiRef = createApiRef({ + id: 'core.plugin-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 83ec4120aa..06d96a50a3 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -49,4 +49,4 @@ export * from './RouteResolutionApi'; export * from './StorageApi'; export * from './AnalyticsApi'; export * from './TranslationApi'; -export * from './HeaderActionsApi'; +export * from './PluginHeaderActionsApi'; diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx index 747d98ddc6..465a475b12 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx @@ -25,11 +25,11 @@ import { } from '../wiring'; import { ExtensionBoundary, PageLayout, PageTab } from '../components'; import { useApi } from '../apis/system'; -import { headerActionsApiRef } from '../apis/definitions/HeaderActionsApi'; +import { pluginHeaderActionsApiRef } from '../apis/definitions/PluginHeaderActionsApi'; -function useHeaderActions(pluginId: string): ReactNode { - const headerActionsApi = useApi(headerActionsApiRef); - const actions = headerActionsApi.getHeaderActions(pluginId); +function usePluginHeaderActions(pluginId: string): ReactNode { + const pluginHeaderActionsApi = useApi(pluginHeaderActionsApiRef); + const actions = pluginHeaderActionsApi.getPluginHeaderActions(pluginId); if (actions.length === 0) { return undefined; } @@ -97,7 +97,7 @@ export const PageBlueprint = createExtensionBlueprint({ if (params.loader) { const loader = params.loader; const PageContent = () => { - const headerActions = useHeaderActions(pluginId); + const headerActions = usePluginHeaderActions(pluginId); return ( { const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath); - const headerActions = useHeaderActions(pluginId); + const headerActions = usePluginHeaderActions(pluginId); return ( ); } else { const PageContent = () => { - const headerActions = useHeaderActions(pluginId); + const headerActions = usePluginHeaderActions(pluginId); return ( ); diff --git a/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginHeaderActionBlueprint.tsx similarity index 89% rename from packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx rename to packages/frontend-plugin-api/src/blueprints/PluginHeaderActionBlueprint.tsx index c2bf6052b7..84ed9da7ff 100644 --- a/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PluginHeaderActionBlueprint.tsx @@ -32,9 +32,9 @@ import { * * @public */ -export const HeaderActionBlueprint = createExtensionBlueprint({ - kind: 'header-action', - attachTo: { id: 'api:app/header-actions', input: 'actions' }, +export const PluginHeaderActionBlueprint = createExtensionBlueprint({ + kind: 'plugin-header-action', + attachTo: { id: 'api:app/plugin-header-actions', input: 'actions' }, output: [coreExtensionData.reactElement], defineParams(params: { loader: () => Promise }) { return createExtensionBlueprintParams(params); diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index fd55dbb7a5..d413776419 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -23,4 +23,4 @@ export { AppRootElementBlueprint } from './AppRootElementBlueprint'; export { NavItemBlueprint } from './NavItemBlueprint'; export { PageBlueprint } from './PageBlueprint'; export { SubPageBlueprint } from './SubPageBlueprint'; -export { HeaderActionBlueprint } from './HeaderActionBlueprint'; +export { PluginHeaderActionBlueprint } from './PluginHeaderActionBlueprint'; diff --git a/plugins/app-visualizer/report.api.md b/plugins/app-visualizer/report.api.md index 92bda66829..4d6b16d729 100644 --- a/plugins/app-visualizer/report.api.md +++ b/plugins/app-visualizer/report.api.md @@ -20,23 +20,6 @@ const visualizerPlugin: OverridableFrontendPlugin< {}, {}, { - 'header-action:app-visualizer': OverridableExtensionDefinition<{ - kind: 'header-action'; - name: undefined; - config: {}; - configInput: {}; - output: ExtensionDataRef< - () => Promise, - 'core.header-action.loader', - {} - >; - inputs: {}; - params: (params: { - loader: () => Promise; - }) => ExtensionBlueprintParams<{ - loader: () => Promise; - }>; - }>; 'nav-item:app-visualizer': OverridableExtensionDefinition<{ kind: 'nav-item'; name: undefined; @@ -130,11 +113,24 @@ const visualizerPlugin: OverridableFrontendPlugin< path: string; title?: string; icon?: IconElement; - loader?: () => Promise; + loader?: () => Promise; routeRef?: RouteRef; noHeader?: boolean; }; }>; + 'plugin-header-action:app-visualizer': OverridableExtensionDefinition<{ + kind: 'plugin-header-action'; + name: undefined; + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: {}; + params: (params: { + loader: () => Promise; + }) => ExtensionBlueprintParams<{ + loader: () => Promise; + }>; + }>; 'sub-page:app-visualizer/details': OverridableExtensionDefinition<{ kind: 'sub-page'; name: 'details'; diff --git a/plugins/app-visualizer/src/plugin.tsx b/plugins/app-visualizer/src/plugin.tsx index e47564c3b0..9fa7b2cd72 100644 --- a/plugins/app-visualizer/src/plugin.tsx +++ b/plugins/app-visualizer/src/plugin.tsx @@ -19,7 +19,7 @@ import { createRouteRef, NavItemBlueprint, PageBlueprint, - HeaderActionBlueprint, + PluginHeaderActionBlueprint, SubPageBlueprint, } from '@backstage/frontend-plugin-api'; import { RiEyeLine } from '@remixicon/react'; @@ -75,7 +75,7 @@ const appVisualizerTextPage = SubPageBlueprint.make({ }, }); -const copyTreeAsJson = HeaderActionBlueprint.make({ +const copyTreeAsJson = PluginHeaderActionBlueprint.make({ params: defineParams => defineParams({ loader: () => diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index bc83dc3476..b1d851dab9 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -469,34 +469,6 @@ const appPlugin: OverridableFrontendPlugin< params: ApiFactory, ) => ExtensionBlueprintParams; }>; - 'api:app/header-actions': OverridableExtensionDefinition<{ - config: {}; - configInput: {}; - output: ExtensionDataRef; - inputs: { - actions: ExtensionInput< - ConfigurableExtensionDataRef< - () => Promise, - 'core.header-action.loader', - {} - >, - { - singleton: false; - optional: false; - internal: false; - } - >; - }; - kind: 'api'; - name: 'header-actions'; - params: < - TApi, - TImpl extends TApi, - TDeps extends { [name in string]: unknown }, - >( - params: ApiFactory, - ) => ExtensionBlueprintParams; - }>; 'api:app/icons': OverridableExtensionDefinition<{ config: {}; configInput: {}; @@ -617,6 +589,30 @@ const appPlugin: OverridableFrontendPlugin< params: ApiFactory, ) => ExtensionBlueprintParams; }>; + 'api:app/plugin-header-actions': OverridableExtensionDefinition<{ + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: { + actions: ExtensionInput< + ConfigurableExtensionDataRef, + { + singleton: false; + optional: false; + internal: false; + } + >; + }; + kind: 'api'; + name: 'plugin-header-actions'; + params: < + TApi, + TImpl extends TApi, + TDeps extends { [name in string]: unknown }, + >( + params: ApiFactory, + ) => ExtensionBlueprintParams; + }>; 'api:app/plugin-wrapper': OverridableExtensionDefinition<{ config: {}; configInput: {}; diff --git a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.test.tsx similarity index 72% rename from plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx rename to plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.test.tsx index 4713896f40..9023c86ce8 100644 --- a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx +++ b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.test.tsx @@ -15,11 +15,11 @@ */ import { render, screen } from '@testing-library/react'; -import { DefaultHeaderActionsApi } from './DefaultHeaderActionsApi'; +import { DefaultPluginHeaderActionsApi } from './DefaultPluginHeaderActionsApi'; -describe('DefaultHeaderActionsApi', () => { +describe('DefaultPluginHeaderActionsApi', () => { it('should return actions for a specific plugin', () => { - const api = DefaultHeaderActionsApi.fromActions([ + const api = DefaultPluginHeaderActionsApi.fromActions([ { element: , pluginId: 'plugin-a', @@ -30,28 +30,28 @@ describe('DefaultHeaderActionsApi', () => { }, ]); - expect(api.getHeaderActions('plugin-a')).toHaveLength(1); - expect(api.getHeaderActions('plugin-b')).toHaveLength(1); + expect(api.getPluginHeaderActions('plugin-a')).toHaveLength(1); + expect(api.getPluginHeaderActions('plugin-b')).toHaveLength(1); - render(<>{api.getHeaderActions('plugin-a')}); + render(<>{api.getPluginHeaderActions('plugin-a')}); expect( screen.getByRole('button', { name: 'Action A' }), ).toBeInTheDocument(); }); it('should return an empty array for unknown plugins', () => { - const api = DefaultHeaderActionsApi.fromActions([ + const api = DefaultPluginHeaderActionsApi.fromActions([ { element: Action, pluginId: 'plugin-a', }, ]); - expect(api.getHeaderActions('unknown-plugin')).toEqual([]); + expect(api.getPluginHeaderActions('unknown-plugin')).toEqual([]); }); it('should group multiple actions by plugin', () => { - const api = DefaultHeaderActionsApi.fromActions([ + const api = DefaultPluginHeaderActionsApi.fromActions([ { element: , pluginId: 'plugin-a', @@ -62,7 +62,7 @@ describe('DefaultHeaderActionsApi', () => { }, ]); - const actions = api.getHeaderActions('plugin-a'); + const actions = api.getPluginHeaderActions('plugin-a'); expect(actions).toHaveLength(2); render(<>{actions}); diff --git a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx similarity index 74% rename from plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx rename to plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx index d165410a4d..fc823fe315 100644 --- a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx +++ b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx @@ -15,7 +15,7 @@ */ import { ReactNode } from 'react'; -import { type HeaderActionsApi } from '@backstage/frontend-plugin-api'; +import { type PluginHeaderActionsApi } from '@backstage/frontend-plugin-api'; type ActionInput = { element: JSX.Element; @@ -23,18 +23,20 @@ type ActionInput = { }; /** - * Default implementation of HeaderActionsApi. + * Default implementation of PluginHeaderActionsApi. * * @internal */ -export class DefaultHeaderActionsApi implements HeaderActionsApi { +export class DefaultPluginHeaderActionsApi implements PluginHeaderActionsApi { constructor(private readonly actionsByPlugin: Map) {} - getHeaderActions(pluginId: string): ReactNode[] { + getPluginHeaderActions(pluginId: string): ReactNode[] { return this.actionsByPlugin.get(pluginId) ?? []; } - static fromActions(actions: Array): DefaultHeaderActionsApi { + static fromActions( + actions: Array, + ): DefaultPluginHeaderActionsApi { const actionsByPlugin = new Map(); for (const action of actions) { @@ -47,6 +49,6 @@ export class DefaultHeaderActionsApi implements HeaderActionsApi { pluginActions.push(action.element); } - return new DefaultHeaderActionsApi(actionsByPlugin); + return new DefaultPluginHeaderActionsApi(actionsByPlugin); } } diff --git a/plugins/app/src/apis/HeaderActionsApi/index.ts b/plugins/app/src/apis/PluginHeaderActionsApi/index.ts similarity index 88% rename from plugins/app/src/apis/HeaderActionsApi/index.ts rename to plugins/app/src/apis/PluginHeaderActionsApi/index.ts index 15dc55382a..be7906a2a7 100644 --- a/plugins/app/src/apis/HeaderActionsApi/index.ts +++ b/plugins/app/src/apis/PluginHeaderActionsApi/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { DefaultHeaderActionsApi } from './DefaultHeaderActionsApi'; +export { DefaultPluginHeaderActionsApi } from './DefaultPluginHeaderActionsApi'; diff --git a/plugins/app/src/extensions/HeaderActionsApi.ts b/plugins/app/src/extensions/PluginHeaderActionsApi.ts similarity index 80% rename from plugins/app/src/extensions/HeaderActionsApi.ts rename to plugins/app/src/extensions/PluginHeaderActionsApi.ts index b1779b09a3..58c67e239a 100644 --- a/plugins/app/src/extensions/HeaderActionsApi.ts +++ b/plugins/app/src/extensions/PluginHeaderActionsApi.ts @@ -16,27 +16,27 @@ import { coreExtensionData, - headerActionsApiRef, + pluginHeaderActionsApiRef, createExtensionInput, ApiBlueprint, } from '@backstage/frontend-plugin-api'; -import { DefaultHeaderActionsApi } from '../apis/HeaderActionsApi'; +import { DefaultPluginHeaderActionsApi } from '../apis/PluginHeaderActionsApi'; /** * Contains the plugin-scoped header actions installed into the app. */ -export const HeaderActionsApi = ApiBlueprint.makeWithOverrides({ - name: 'header-actions', +export const PluginHeaderActionsApi = ApiBlueprint.makeWithOverrides({ + name: 'plugin-header-actions', inputs: { actions: createExtensionInput([coreExtensionData.reactElement]), }, factory: (originalFactory, { inputs }) => { return originalFactory(defineParams => defineParams({ - api: headerActionsApiRef, + api: pluginHeaderActionsApiRef, deps: {}, factory: () => { - return DefaultHeaderActionsApi.fromActions( + return DefaultPluginHeaderActionsApi.fromActions( inputs.actions.map(actionInput => ({ element: actionInput.get(coreExtensionData.reactElement), pluginId: actionInput.node.spec.plugin.pluginId, diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index 96a74b055c..d9e2b666f1 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -38,4 +38,4 @@ export { PageLayout, } from './components'; export { PluginWrapperApi } from './PluginWrapperApi'; -export { HeaderActionsApi } from './HeaderActionsApi'; +export { PluginHeaderActionsApi } from './PluginHeaderActionsApi'; diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index 71f200f01f..8d2fd56720 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -29,7 +29,7 @@ import { IconsApi, FeatureFlagsApi, PluginWrapperApi, - HeaderActionsApi, + PluginHeaderActionsApi, TranslationsApi, oauthRequestDialogAppRootElement, alertDisplayAppRootElement, @@ -62,7 +62,7 @@ export const appPlugin = createFrontendPlugin({ IconsApi, FeatureFlagsApi, PluginWrapperApi, - HeaderActionsApi, + PluginHeaderActionsApi, TranslationsApi, DefaultSignInPage, oauthRequestDialogAppRootElement,