frontend-plugin-api: initial support for subpages
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user