diff --git a/.changeset/hot-yaks-crash.md b/.changeset/hot-yaks-crash.md new file mode 100644 index 0000000000..eb56752374 --- /dev/null +++ b/.changeset/hot-yaks-crash.md @@ -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. diff --git a/.changeset/lucky-things-write.md b/.changeset/lucky-things-write.md new file mode 100644 index 0000000000..36df2d7308 --- /dev/null +++ b/.changeset/lucky-things-write.md @@ -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. diff --git a/packages/app/e2e-tests/HomePage.test.ts b/packages/app/e2e-tests/HomePage.test.ts index 4261cc435b..1e359f66e7 100644 --- a/packages/app/e2e-tests/HomePage.test.ts +++ b/packages/app/e2e-tests/HomePage.test.ts @@ -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 diff --git a/packages/app/e2e-tests/SearchPage.test.ts b/packages/app/e2e-tests/SearchPage.test.ts index d8989275f0..927592a421 100644 --- a/packages/app/e2e-tests/SearchPage.test.ts +++ b/packages/app/e2e-tests/SearchPage.test.ts @@ -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 => { diff --git a/packages/app/e2e-tests/app.test.ts b/packages/app/e2e-tests/app.test.ts index 2efe991f7a..6d16d134c7 100644 --- a/packages/app/e2e-tests/app.test.ts +++ b/packages/app/e2e-tests/app.test.ts @@ -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(); }); diff --git a/packages/create-app/templates/next-app/packages/app/e2e-tests/app.test.ts b/packages/create-app/templates/next-app/packages/app/e2e-tests/app.test.ts index 3347034856..187e124bfa 100644 --- a/packages/create-app/templates/next-app/packages/app/e2e-tests/app.test.ts +++ b/packages/create-app/templates/next-app/packages/app/e2e-tests/app.test.ts @@ -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(); }); diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index c3936ba0da..66ddc8f1b2 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1924,6 +1924,8 @@ export interface PageLayoutProps { tabs?: PageLayoutTab[]; // (undocumented) title?: string; + // (undocumented) + titleLink?: string; } // @public diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx index bf2e6d8135..4738597ac8 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx @@ -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} > @@ -147,12 +172,15 @@ export const PageBlueprint = createExtensionBlueprint({ yield coreExtensionData.reactElement(); } else { const PageContent = () => { + const routeResolutionApi = useApi(routeResolutionApiRef); + const titleLink = resolveTitleLink(routeResolutionApi, titleRouteRef); const headerActionsApi = useApi(pluginHeaderActionsApiRef); const headerActions = headerActionsApi.getPluginHeaderActions(pluginId); return ( ); diff --git a/packages/frontend-plugin-api/src/components/PageLayout.tsx b/packages/frontend-plugin-api/src/components/PageLayout.tsx index d97d746665..00060e3c6e 100644 --- a/packages/frontend-plugin-api/src/components/PageLayout.tsx +++ b/packages/frontend-plugin-api/src/components/PageLayout.tsx @@ -43,6 +43,7 @@ export interface PageLayoutProps { title?: string; icon?: IconElement; noHeader?: boolean; + titleLink?: string; headerActions?: Array; tabs?: PageLayoutTab[]; children?: ReactNode; diff --git a/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx index ae114e4e24..e566b84ecc 100644 --- a/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx +++ b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx @@ -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); diff --git a/plugins/app/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx index 1f31e58f9c..95ca4e0cbe 100644 --- a/plugins/app/src/extensions/components.tsx +++ b/plugins/app/src/extensions/components.tsx @@ -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({