frontend-plugin-api: initial support for subpages

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-09 14:09:00 +01:00
parent 9e71e1dec8
commit 4b996d05d3
22 changed files with 1570 additions and 31 deletions
@@ -0,0 +1,35 @@
/*
* Copyright 2024 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 { coreExtensionData, createExtensionBlueprint } from '../wiring';
import { ExtensionBoundary } from '../components';
/**
* Createx extensions that are routable React page components.
*
* @public
*/
export const HeaderActionBlueprint = createExtensionBlueprint({
kind: 'header-action',
attachTo: { id: 'app/routes', input: 'headerActions' },
output: [coreExtensionData.reactElement],
*factory(params: { loader: () => Promise<JSX.Element> }, { node }) {
yield coreExtensionData.reactElement(
ExtensionBoundary.lazy(node, params.loader),
);
},
});
@@ -14,9 +14,14 @@
* limitations under the License.
*/
import { Routes, Route, Navigate } from 'react-router-dom';
import { RouteRef } from '../routing';
import { coreExtensionData, createExtensionBlueprint } from '../wiring';
import { ExtensionBoundary } from '../components';
import {
coreExtensionData,
createExtensionBlueprint,
createExtensionInput,
} from '../wiring';
import { ExtensionBoundary, PageLayout, PageTab } from '../components';
/**
* Createx extensions that are routable React page components.
@@ -26,34 +31,100 @@ import { ExtensionBoundary } from '../components';
export const PageBlueprint = createExtensionBlueprint({
kind: 'page',
attachTo: { id: 'app/routes', input: 'routes' },
inputs: {
pages: createExtensionInput([
coreExtensionData.routePath,
coreExtensionData.routeRef.optional(),
coreExtensionData.reactElement,
coreExtensionData.title.optional(),
]),
},
output: [
coreExtensionData.routePath,
coreExtensionData.reactElement,
coreExtensionData.routeRef.optional(),
coreExtensionData.title.optional(),
],
config: {
schema: {
path: z => z.string().optional(),
title: z => z.string().optional(),
},
},
*factory(
params: {
/**
* @deprecated Use the `path` param instead.
* @deprecated Use the `path' param instead.
*/
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
loader?: () => Promise<JSX.Element>;
routeRef?: RouteRef;
},
{ config, node },
{ config, node, inputs },
) {
const title = config.title ?? params.title ?? node.spec.plugin.pluginId;
yield coreExtensionData.routePath(config.path ?? params.path);
yield coreExtensionData.reactElement(
ExtensionBoundary.lazy(node, params.loader),
);
if (params.loader) {
// Simple page with loader - render header + content
const loader = params.loader; // Capture for closure
const PageContent = () => (
<PageLayout title={title}>
{ExtensionBoundary.lazy(node, loader)}
</PageLayout>
);
yield coreExtensionData.reactElement(<PageContent />);
} else if (inputs.pages.length > 0) {
// Parent page with sub-pages - render Header with tabs and Routes for sub-pages
const tabs: PageTab[] = inputs.pages.map(page => {
const path = page.get(coreExtensionData.routePath);
const tabTitle = page.get(coreExtensionData.title);
return {
id: path,
label: tabTitle || path,
href: path,
matchStrategy: 'prefix' as const,
};
});
const PageContent = () => {
// Get first sub-page path for default navigation
const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath);
return (
<PageLayout title={title} tabs={tabs}>
<Routes>
{/* Index route redirects to first sub-page */}
{firstPagePath && (
<Route
index
element={<Navigate to={firstPagePath} replace />}
/>
)}
{inputs.pages.map((page, index) => {
const path = page.get(coreExtensionData.routePath);
const element = page.get(coreExtensionData.reactElement);
return (
<Route key={index} path={`${path}/*`} element={element} />
);
})}
</Routes>
</PageLayout>
);
};
yield coreExtensionData.reactElement(<PageContent />);
} else {
// Parent page without loader or sub-pages - render just header
yield coreExtensionData.reactElement(<PageLayout title={title} />);
}
if (params.routeRef) {
yield coreExtensionData.routeRef(params.routeRef);
}
if (title) {
yield coreExtensionData.title(title);
}
},
});
@@ -0,0 +1,90 @@
/*
* Copyright 2024 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 { RouteRef } from '../routing';
import { coreExtensionData, createExtensionBlueprint } from '../wiring';
import { ExtensionBoundary } from '../components';
/**
* Creates extensions that are sub-page React components attached to a parent page.
* Sub-pages are rendered as tabs within the parent page's header.
*
* @public
* @example
* ```tsx
* const overviewRouteRef = createRouteRef();
*
* const mySubPage = SubPageBlueprint.make({
* attachTo: { id: 'page:my-plugin', input: 'pages' },
* name: 'overview',
* params: {
* path: '/overview',
* title: 'Overview',
* routeRef: overviewRouteRef,
* loader: () => import('./components/Overview').then(m => <m.Overview />),
* },
* });
* ```
*/
export const SubPageBlueprint = createExtensionBlueprint({
kind: 'sub-page',
attachTo: { relative: { kind: 'page' }, input: 'pages' },
output: [
coreExtensionData.routePath,
coreExtensionData.reactElement,
coreExtensionData.title,
coreExtensionData.routeRef.optional(),
],
config: {
schema: {
path: z => z.string().optional(),
title: z => z.string().optional(),
},
},
*factory(
params: {
/**
* The path for this sub-page, relative to the parent page.
* Should start with '/'.
* @example '/overview', '/settings', '/details'
*/
path: string;
/**
* The title displayed in the tab for this sub-page.
*/
title: string;
/**
* A function that returns a promise resolving to the React element to render.
* This enables lazy loading of the sub-page content.
*/
loader: () => Promise<JSX.Element>;
/**
* Optional route reference for this sub-page.
*/
routeRef?: RouteRef;
},
{ config, node },
) {
yield coreExtensionData.routePath(config.path ?? params.path);
yield coreExtensionData.title(config.title ?? params.title);
yield coreExtensionData.reactElement(
ExtensionBoundary.lazy(node, params.loader),
);
if (params.routeRef) {
yield coreExtensionData.routeRef(params.routeRef);
}
},
});
@@ -22,3 +22,5 @@ export { ApiBlueprint } from './ApiBlueprint';
export { AppRootElementBlueprint } from './AppRootElementBlueprint';
export { NavItemBlueprint } from './NavItemBlueprint';
export { PageBlueprint } from './PageBlueprint';
export { SubPageBlueprint } from './SubPageBlueprint';
export { HeaderActionBlueprint } from './HeaderActionBlueprint';
@@ -0,0 +1,126 @@
/*
* Copyright 2024 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 { createSwappableComponent } from './createSwappableComponent';
/**
* Tab configuration for page navigation
* @public
*/
export interface PageTab {
id: string;
label: string;
href: string;
matchStrategy?: 'prefix' | 'exact';
}
/**
* Props for the PageLayout component
* @public
*/
export interface PageLayoutProps {
title?: string;
tabs?: PageTab[];
children?: ReactNode;
}
/**
* Default implementation of PageLayout using plain HTML elements
*/
function DefaultPageLayout(props: PageLayoutProps): JSX.Element {
const { title, tabs, children } = props;
return (
<div
data-component="page-layout"
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
}}
>
{(title || tabs) && (
<header
style={{
borderBottom: '1px solid #ddd',
backgroundColor: '#fff',
flexShrink: 0,
}}
>
{title && (
<div
style={{
padding: '12px 24px 8px',
fontSize: '18px',
fontWeight: 500,
}}
>
{title}
</div>
)}
{tabs && tabs.length > 0 && (
<nav
style={{
display: 'flex',
gap: '4px',
padding: '0 24px',
}}
>
{tabs.map(tab => (
<a
key={tab.id}
href={tab.href}
style={{
padding: '8px 12px',
textDecoration: 'none',
color: '#333',
borderBottom: '2px solid transparent',
}}
>
{tab.label}
</a>
))}
</nav>
)}
</header>
)}
<div
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
}}
>
{children}
</div>
</div>
);
}
/**
* Swappable component for laying out page content with header and navigation.
* The default implementation uses plain HTML elements.
* Apps can override this with a custom implementation (e.g., using @backstage/ui).
*
* @public
*/
export const PageLayout = createSwappableComponent<PageLayoutProps>({
id: 'core.page-layout',
loader: () => DefaultPageLayout,
});
@@ -25,3 +25,8 @@ export {
} from './createSwappableComponent';
export { useAppNode } from './AppNodeProvider';
export * from './DefaultSwappableComponents';
export {
PageLayout,
type PageLayoutProps,
type PageTab,
} from './PageLayout';