feat(frontend-plugin-api): add titleRouteRef to PageBlueprint

Add `titleRouteRef` to `PageLayoutProps` so the plugin header title
links back to the plugin root. `PageBlueprint` resolves it from
`plugin.routes.root` with fallback to `params.routeRef`.

- PageLayout swap resolves the title link via a conditional child
  component that calls `useRouteRef` only when a route ref exists
- Header actions get stable React keys via `cloneElement`

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-16 17:27:46 +01:00
parent 364d4fe187
commit d66a3ec9ab
11 changed files with 71 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app': patch
---
Updated the `PageLayout` swap to pass a clickable `titleLink` on the `PluginHeader`, resolved from the plugin's root route ref.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added `titleLink` prop to `PageLayoutProps` so the plugin header title can link back to the plugin root.
+3 -1
View File
@@ -43,7 +43,9 @@ test('Should render the home page', async ({ page }) => {
await enterButton.click();
// Wait for sign-in to complete
await expect(page.getByRole('link', { name: 'Catalog' })).toBeVisible();
await expect(
page.getByRole('navigation').getByRole('link', { name: 'Catalog' }),
).toBeVisible();
await page.goto('/home');
// The home page should render with the custom homepage grid
+3 -1
View File
@@ -24,7 +24,9 @@ test('the results are rendered as expected', async ({ page }) => {
await enterButton.click();
// Wait for sign-in to complete before navigating
await expect(page.getByRole('link', { name: 'Catalog' })).toBeVisible();
await expect(
page.getByRole('navigation').getByRole('link', { name: 'Catalog' }),
).toBeVisible();
// Set up route interception BEFORE navigating to the search page
await page.route(`**/api/search/query?term=*`, async route => {
+3 -1
View File
@@ -24,6 +24,8 @@ test('App should render the welcome page', async ({ page }) => {
await enterButton.click();
// Verify the sidebar navigation is visible after sign-in
await expect(page.getByRole('link', { name: 'Catalog' })).toBeVisible();
await expect(
page.getByRole('navigation').getByRole('link', { name: 'Catalog' }),
).toBeVisible();
await expect(page.getByRole('link', { name: 'APIs' })).toBeVisible();
});
@@ -23,6 +23,7 @@ test('App should render the welcome page', async ({ page }) => {
await expect(enterButton).toBeVisible();
await enterButton.click();
await expect(page.getByRole('link', { name: 'Catalog' })).toBeVisible();
const nav = page.getByRole('navigation');
await expect(nav.getByRole('link', { name: 'Catalog' })).toBeVisible();
await expect(page.getByRole('link', { name: 'APIs' })).toBeVisible();
});
@@ -1924,6 +1924,8 @@ export interface PageLayoutProps {
tabs?: PageLayoutTab[];
// (undocumented)
title?: string;
// (undocumented)
titleLink?: string;
}
// @public
@@ -25,7 +25,24 @@ import {
} from '../wiring';
import { ExtensionBoundary, PageLayout, PageLayoutTab } from '../components';
import { useApi } from '../apis/system';
import { routeResolutionApiRef } from '../apis/definitions/RouteResolutionApi';
import { pluginHeaderActionsApiRef } from '../apis/definitions/PluginHeaderActionsApi';
import { RouteResolutionApi } from '../apis/definitions/RouteResolutionApi';
function resolveTitleLink(
routeResolutionApi: RouteResolutionApi,
routeRef: RouteRef | undefined,
): string | undefined {
if (!routeRef) {
return undefined;
}
try {
return routeResolutionApi.resolve(routeRef)?.();
} catch {
// Route ref may require params not available in the current context
return undefined;
}
}
/**
* Creates extensions that are routable React page components.
@@ -78,11 +95,15 @@ export const PageBlueprint = createExtensionBlueprint({
const resolvedTitle =
title ?? node.spec.plugin.title ?? node.spec.plugin.pluginId;
const resolvedIcon = icon ?? node.spec.plugin.icon;
const titleRouteRef =
(node.spec.plugin.routes as { root?: RouteRef }).root ?? params.routeRef;
yield coreExtensionData.routePath(config.path ?? params.path);
if (params.loader) {
const loader = params.loader;
const PageContent = () => {
const routeResolutionApi = useApi(routeResolutionApiRef);
const titleLink = resolveTitleLink(routeResolutionApi, titleRouteRef);
const headerActionsApi = useApi(pluginHeaderActionsApiRef);
const headerActions = headerActionsApi.getPluginHeaderActions(pluginId);
@@ -91,6 +112,7 @@ export const PageBlueprint = createExtensionBlueprint({
title={resolvedTitle}
icon={resolvedIcon}
noHeader={noHeader}
titleLink={titleLink}
headerActions={headerActions}
>
{ExtensionBoundary.lazy(node, loader)}
@@ -114,6 +136,8 @@ export const PageBlueprint = createExtensionBlueprint({
const PageContent = () => {
const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath);
const routeResolutionApi = useApi(routeResolutionApiRef);
const titleLink = resolveTitleLink(routeResolutionApi, titleRouteRef);
const headerActionsApi = useApi(pluginHeaderActionsApiRef);
const headerActions = headerActionsApi.getPluginHeaderActions(pluginId);
@@ -123,6 +147,7 @@ export const PageBlueprint = createExtensionBlueprint({
title={resolvedTitle}
icon={resolvedIcon}
tabs={tabs}
titleLink={titleLink}
headerActions={headerActions}
>
<Routes>
@@ -147,12 +172,15 @@ export const PageBlueprint = createExtensionBlueprint({
yield coreExtensionData.reactElement(<PageContent />);
} else {
const PageContent = () => {
const routeResolutionApi = useApi(routeResolutionApiRef);
const titleLink = resolveTitleLink(routeResolutionApi, titleRouteRef);
const headerActionsApi = useApi(pluginHeaderActionsApiRef);
const headerActions = headerActionsApi.getPluginHeaderActions(pluginId);
return (
<PageLayout
title={resolvedTitle}
icon={resolvedIcon}
titleLink={titleLink}
headerActions={headerActions}
/>
);
@@ -43,6 +43,7 @@ export interface PageLayoutProps {
title?: string;
icon?: IconElement;
noHeader?: boolean;
titleLink?: string;
headerActions?: Array<JSX.Element | null>;
tabs?: PageLayoutTab[];
children?: ReactNode;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { JSX } from 'react';
import { cloneElement, JSX } from 'react';
import { type PluginHeaderActionsApi } from '@backstage/frontend-plugin-api';
// Stable reference
@@ -51,7 +51,14 @@ export class DefaultPluginHeaderActionsApi implements PluginHeaderActionsApi {
actionsByPlugin.set(action.pluginId, pluginActions);
}
pluginActions.push(action.element);
const index = pluginActions.length;
pluginActions.push(
cloneElement(action.element, {
key:
action.element.key ??
`plugin-header-action-${action.pluginId}-${index}`,
}),
);
}
return new DefaultPluginHeaderActionsApi(actionsByPlugin);
+10 -1
View File
@@ -75,7 +75,15 @@ export const PageLayout = SwappableComponentBlueprint.make({
define({
component: SwappablePageLayout,
loader: () => (props: PageLayoutProps) => {
const { title, icon, noHeader, headerActions, tabs, children } = props;
const {
title,
icon,
noHeader,
titleLink,
headerActions,
tabs,
children,
} = props;
// TODO(Rugvip): Different solution to this path handling would be good
const parentPath = useResolvedPath('.').pathname.replace(/\/$/, '');
const resolvedTabs = useMemo(
@@ -99,6 +107,7 @@ export const PageLayout = SwappableComponentBlueprint.make({
<PluginHeader
title={title}
icon={icon}
titleLink={titleLink}
tabs={resolvedTabs}
customActions={headerActions}
/>