plugin icons and titles

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-09 23:02:34 +01:00
parent 4b996d05d3
commit e1f22f2d15
31 changed files with 1362 additions and 89 deletions
+126 -3
View File
@@ -13,6 +13,7 @@ import { ExpandRecursive } from '@backstage/types';
import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
import { ExtensionInput as ExtensionInput_2 } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { JSX as JSX_2 } from 'react/jsx-runtime';
@@ -388,6 +389,7 @@ export interface ConfigurableExtensionDataRef<
// @public (undocumented)
export const coreExtensionData: {
title: ConfigurableExtensionDataRef_2<string, 'core.title', {}>;
icon: ConfigurableExtensionDataRef_2<IconComponent, 'core.icon', {}>;
reactElement: ConfigurableExtensionDataRef_2<
JSX_3.Element,
'core.reactElement',
@@ -1367,12 +1369,14 @@ export interface FrontendPlugin<
readonly $$type: '@backstage/FrontendPlugin';
// (undocumented)
readonly externalRoutes: TExternalRoutes;
readonly icon?: IconComponent;
// @deprecated
readonly id: string;
info(): Promise<FrontendPluginInfo>;
readonly pluginId: string;
// (undocumented)
readonly routes: TRoutes;
readonly title?: string;
}
// @public
@@ -1420,6 +1424,19 @@ export const googleAuthApiRef: ApiRef<
SessionApi
>;
// @public
export const HeaderActionBlueprint: ExtensionBlueprint_2<{
kind: 'header-action';
params: {
loader: () => Promise<JSX.Element>;
};
output: ExtensionDataRef_2<JSX_3, 'core.reactElement', {}>;
inputs: {};
config: {};
configInput: {};
dataRefs: never;
}>;
// @public
export type IconComponent = ComponentType<{
fontSize?: 'medium' | 'large' | 'small' | 'inherit';
@@ -1710,29 +1727,101 @@ export const PageBlueprint: ExtensionBlueprint_2<{
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconComponent;
loader?: () => Promise<JSX.Element>;
routeRef?: RouteRef;
};
output:
| ExtensionDataRef_2<string, 'core.routing.path', {}>
| ExtensionDataRef_2<JSX_3, 'core.reactElement', {}>
| ExtensionDataRef_2<
RouteRef<AnyRouteRefParams_2>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef_2<JSX_3, 'core.reactElement', {}>
| ExtensionDataRef_2<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef_2<
IconComponent,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput_2<
| ConfigurableExtensionDataRef_2<JSX_3, 'core.reactElement', {}>
| ConfigurableExtensionDataRef_2<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef_2<
RouteRef<AnyRouteRefParams_2>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef_2<
string,
'core.title',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
dataRefs: never;
}>;
// @public
export const PageLayout: {
(props: PageLayoutProps): JSX.Element | null;
ref: SwappableComponentRef_2<PageLayoutProps, PageLayoutProps>;
};
// @public
export interface PageLayoutProps {
// (undocumented)
children?: ReactNode;
// (undocumented)
icon?: IconComponent;
// (undocumented)
tabs?: PageTab[];
// (undocumented)
title?: string;
}
// @public
export interface PageTab {
// (undocumented)
href: string;
// (undocumented)
id: string;
// (undocumented)
label: string;
// (undocumented)
matchStrategy?: 'prefix' | 'exact';
}
// @public
export type PendingOAuthRequest = {
provider: AuthProviderInfo;
@@ -1757,12 +1846,14 @@ export interface PluginOptions<
externalRoutes?: TExternalRoutes;
// (undocumented)
featureFlags?: FeatureFlagConfig[];
icon?: IconComponent;
// (undocumented)
info?: FrontendPluginInfoOptions;
// (undocumented)
pluginId: TId;
// (undocumented)
routes?: TRoutes;
title?: string;
}
// @public (undocumented)
@@ -1898,6 +1989,38 @@ export type StorageValueSnapshot<TValue extends JsonValue> =
value: TValue;
};
// @public
export const SubPageBlueprint: ExtensionBlueprint_2<{
kind: 'sub-page';
params: {
path: string;
title: string;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef;
};
output:
| ExtensionDataRef_2<string, 'core.routing.path', {}>
| ExtensionDataRef_2<
RouteRef<AnyRouteRefParams_2>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef_2<JSX_3, 'core.reactElement', {}>
| ExtensionDataRef_2<string, 'core.title', {}>;
inputs: {};
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
dataRefs: never;
}>;
// @public
export interface SubRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
@@ -15,6 +15,7 @@
*/
import { Routes, Route, Navigate } from 'react-router-dom';
import { IconComponent } from '../icons/types';
import { RouteRef } from '../routing';
import {
coreExtensionData,
@@ -24,7 +25,7 @@ import {
import { ExtensionBoundary, PageLayout, PageTab } from '../components';
/**
* Createx extensions that are routable React page components.
* Creates extensions that are routable React page components.
*
* @public
*/
@@ -44,6 +45,7 @@ export const PageBlueprint = createExtensionBlueprint({
coreExtensionData.reactElement,
coreExtensionData.routeRef.optional(),
coreExtensionData.title.optional(),
coreExtensionData.icon.optional(),
],
config: {
schema: {
@@ -54,30 +56,35 @@ export const PageBlueprint = createExtensionBlueprint({
*factory(
params: {
/**
* @deprecated Use the `path' param instead.
* @deprecated Use the `path` param instead.
*/
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
title?: string;
icon?: IconComponent;
loader?: () => Promise<JSX.Element>;
routeRef?: RouteRef;
},
{ config, node, inputs },
) {
const title = config.title ?? params.title ?? node.spec.plugin.pluginId;
const title =
config.title ??
params.title ??
node.spec.plugin.title ??
node.spec.plugin.pluginId;
const icon = params.icon ?? node.spec.plugin.icon;
yield coreExtensionData.routePath(config.path ?? params.path);
if (params.loader) {
// Simple page with loader - render header + content
const loader = params.loader; // Capture for closure
const loader = params.loader;
const PageContent = () => (
<PageLayout title={title}>
<PageLayout title={title} icon={icon}>
{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
// Parent page with sub-pages - render header with tabs
const tabs: PageTab[] = inputs.pages.map(page => {
const path = page.get(coreExtensionData.routePath);
const tabTitle = page.get(coreExtensionData.title);
@@ -90,13 +97,11 @@ export const PageBlueprint = createExtensionBlueprint({
});
const PageContent = () => {
// Get first sub-page path for default navigation
const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath);
return (
<PageLayout title={title} tabs={tabs}>
<PageLayout title={title} icon={icon} tabs={tabs}>
<Routes>
{/* Index route redirects to first sub-page */}
{firstPagePath && (
<Route
index
@@ -117,8 +122,9 @@ export const PageBlueprint = createExtensionBlueprint({
yield coreExtensionData.reactElement(<PageContent />);
} else {
// Parent page without loader or sub-pages - render just header
yield coreExtensionData.reactElement(<PageLayout title={title} />);
yield coreExtensionData.reactElement(
<PageLayout title={title} icon={icon} />,
);
}
if (params.routeRef) {
yield coreExtensionData.routeRef(params.routeRef);
@@ -126,5 +132,8 @@ export const PageBlueprint = createExtensionBlueprint({
if (title) {
yield coreExtensionData.title(title);
}
if (icon) {
yield coreExtensionData.icon(icon);
}
},
});
@@ -15,6 +15,7 @@
*/
import { ReactNode } from 'react';
import { IconComponent } from '../icons/types';
import { createSwappableComponent } from './createSwappableComponent';
/**
@@ -34,6 +35,7 @@ export interface PageTab {
*/
export interface PageLayoutProps {
title?: string;
icon?: IconComponent;
tabs?: PageTab[];
children?: ReactNode;
}
@@ -42,7 +44,7 @@ export interface PageLayoutProps {
* Default implementation of PageLayout using plain HTML elements
*/
function DefaultPageLayout(props: PageLayoutProps): JSX.Element {
const { title, tabs, children } = props;
const { title, icon: Icon, tabs, children } = props;
return (
<div
@@ -65,11 +67,15 @@ function DefaultPageLayout(props: PageLayoutProps): JSX.Element {
{title && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 24px 8px',
fontSize: '18px',
fontWeight: 500,
}}
>
{Icon && <Icon fontSize="small" />}
{title}
</div>
)}
@@ -116,7 +122,7 @@ function DefaultPageLayout(props: PageLayoutProps): JSX.Element {
/**
* 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).
* Apps can override this with a custom implementation (e.g., using \@backstage/ui).
*
* @public
*/
@@ -15,12 +15,14 @@
*/
import { JSX } from 'react';
import { IconComponent } from '../icons/types';
import { RouteRef } from '../routing/RouteRef';
import { createExtensionDataRef } from './createExtensionDataRef';
/** @public */
export const coreExtensionData = {
title: createExtensionDataRef<string>().with({ id: 'core.title' }),
icon: createExtensionDataRef<IconComponent>().with({ id: 'core.icon' }),
reactElement: createExtensionDataRef<JSX.Element>().with({
id: 'core.reactElement',
}),
@@ -29,6 +29,7 @@ import {
import { FeatureFlagConfig } from './types';
import { MakeSortedExtensionsMap } from './MakeSortedExtensionsMap';
import { JsonObject } from '@backstage/types';
import { IconComponent } from '../icons/types';
import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing';
import { ID_PATTERN } from './constants';
@@ -141,6 +142,15 @@ export interface FrontendPlugin<
* @deprecated Use `pluginId` instead.
*/
readonly id: string;
/**
* The display title of the plugin, used in page headers and navigation.
* Falls back to the plugin ID if not provided.
*/
readonly title?: string;
/**
* The display icon of the plugin, used in page headers and navigation.
*/
readonly icon?: IconComponent;
readonly routes: TRoutes;
readonly externalRoutes: TExternalRoutes;
@@ -158,6 +168,15 @@ export interface PluginOptions<
TExtensions extends readonly ExtensionDefinition[],
> {
pluginId: TId;
/**
* The display title of the plugin, used in page headers and navigation.
* Falls back to the plugin ID if not provided.
*/
title?: string;
/**
* The display icon of the plugin, used in page headers and navigation.
*/
icon?: IconComponent;
routes?: TRoutes;
externalRoutes?: TExternalRoutes;
extensions?: TExtensions;
@@ -250,6 +269,8 @@ export function createFrontendPlugin<
return OpaqueFrontendPlugin.createInstance('v1', {
pluginId,
id: pluginId,
title: options.title,
icon: options.icon,
routes: options.routes ?? ({} as TRoutes),
externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes),
featureFlags: options.featureFlags ?? [],