Merge pull request #32812 from backstage/mob/header

frontend-plugin-api: new system for page extensions
This commit is contained in:
Patrik Oldsberg
2026-02-17 14:51:15 +01:00
committed by GitHub
86 changed files with 3335 additions and 293 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-visualizer': minor
---
Migrated to use `SubPageBlueprint` for tabbed navigation and added a copy-tree-as-JSON plugin header action using `PluginHeaderActionBlueprint`. The plugin now specifies a `title` and `icon`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Updated the app template sidebar to use the new `NavContentBlueprint` API for page-based navigation.
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/frontend-plugin-api': minor
'@backstage/frontend-app-api': patch
'@backstage/core-compat-api': patch
'@backstage/plugin-app-react': patch
---
Added `IconElement` type as a replacement for the deprecated `IconComponent`. The `IconsApi` now has a new `icon()` method that returns `IconElement`, while the existing `getIcon()` method is deprecated. The `IconBundleBlueprint` now accepts both `IconComponent` and `IconElement` values.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-app-react': minor
'@backstage/plugin-app': patch
---
Added new `NavContentNavItem`, `NavContentNavItems`, and `navItems` prop to `NavContentComponentProps` for auto-discovering navigation items from page extensions. The new `navItems` collection supports `take(id)` and `rest()` methods for placing specific items in custom sidebar positions, as well as `withComponent(Component)` which returns a `NavContentNavItemsWithComponent` for rendering items directly as elements. The existing `items` prop is now deprecated in favor of `navItems`.
@@ -0,0 +1,6 @@
---
'@backstage/frontend-plugin-api': minor
'@backstage/plugin-app': minor
---
Added `SubPageBlueprint` for creating sub-page tabs, `PluginHeaderActionBlueprint` and `PluginHeaderActionsApi` for plugin-scoped header actions, and `PageLayout` as a swappable component. The `PageBlueprint` now supports sub-pages with tabbed navigation, page title, icon, and header actions. Plugins can now specify a `title` and `icon` in `createFrontendPlugin`.
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/plugin-api-docs': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-unprocessed-entities': patch
'@backstage/plugin-devtools': patch
'@backstage/plugin-home': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-search': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-user-settings': patch
---
Added `title` and `icon` to the plugin definition for the new frontend system.
@@ -422,6 +422,7 @@ SCM
SCMs
scrollable
scrollbar
scrollbars
sdks
seb
semlas
@@ -26,6 +26,8 @@ const myPage = PageBlueprint.make({
export default createFrontendPlugin({
pluginId: 'my-plugin',
title: 'My Plugin',
icon: MyPluginIcon,
extensions: [myPage],
});
```
@@ -36,6 +38,30 @@ Each plugin needs an ID, which is used to uniquely identify the plugin within an
The plugin ID should generally be part of the of the package name and use kebab-case. See both the [frontend naming patterns section](./50-naming-patterns.md), as well as the [package metadata section](../../tooling/package-metadata.md#name) for more information.
### `title` option
The display title of the plugin, used in page headers and navigation. Falls back to the plugin ID if not provided.
```tsx
export default createFrontendPlugin({
pluginId: 'my-plugin',
title: 'My Plugin',
extensions: [...],
});
```
### `icon` option
The display icon of the plugin, used in page headers and navigation. The type is `IconElement` (`JSX.Element | null`) from `@backstage/frontend-plugin-api`. Icons should be exactly 24x24 pixels in size.
```tsx
export default createFrontendPlugin({
pluginId: 'my-plugin',
icon: <MyPluginIcon />,
extensions: [...],
});
```
### `extensions` option
These are the [extensions](./20-extensions.md) that the plugin provides to the app. Note that you should not export any of these extensions separately from the plugin package, as they can already by accessed via the `getExtension` method of the plugin instance using the extension ID.
@@ -686,7 +686,7 @@ createApp({
#### App Root Sidebar
New apps feature a built-in sidebar extension which is created by using the `NavContentBlueprint` in `src/modules/nav/Sidebar.tsx`. The default implementation of the sidebar in this blueprint will render some items explicitly in different groups, and then render the rest of the items which are the other `NavItem` extensions provided by the system.
New apps feature a built-in sidebar extension which is created by using the `NavContentBlueprint` in `src/modules/nav/Sidebar.tsx`. The default implementation of the sidebar in this blueprint will render some items explicitly in different groups, and then render the rest of the items. Nav items are auto-discovered from page extensions registered under `app/routes` (no explicit `NavItemBlueprint` required), with metadata from page config, nav item extensions, or plugin defaults.
In order to migrate your existing sidebar, you will want to create an override for the `app/nav` extension. You can do this by copying the standard of having a `src/modules/nav/` folder, which can contain an extension which you can install into the `app` in the form of a `module`.
@@ -702,38 +702,45 @@ export const navModule = createFrontendModule({
Then in the actual implementation for the `SidebarContent` extension, you can provide something like the following, where you implement the entire `Sidebar` component.
The component receives a `navItems` prop with `take(id)` and `rest()` methods for placing specific items in custom positions. The recommended approach is to use `navItems.withComponent(...)` to define a component for rendering each nav item, and then use the returned `take(id)` and `rest()` methods to get pre-rendered elements directly. Items taken from the renderer are also taken from the main list. Keys are automatically assigned when rendering via `rest()`.
```tsx title="in packages/app/src/modules/nav/Sidebar.tsx"
import { NavContentBlueprint } from '@backstage/plugin-app-react';
export const SidebarContent = NavContentBlueprint.make({
params: {
component: ({ items }) => (
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
...
</SidebarGroup>
<SidebarGroup label="Plugins">
<SidebarScrollWrapper>
{/* Items in this group will be scrollable if they run out of space */}
{items.map((item, index) => (
<SidebarItem {...item} key={index} />
))}
</SidebarScrollWrapper>
</SidebarGroup>
</Sidebar>
),
component: ({ navItems }) => {
const nav = navItems.withComponent(item => (
<SidebarItem icon={() => item.icon} to={item.href} text={item.title} />
));
return (
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{nav.take('page:catalog')}
{nav.take('page:scaffolder')}
<SidebarDivider />
<SidebarScrollWrapper>
{nav.rest({ sortBy: 'title' })}
</SidebarScrollWrapper>
</SidebarGroup>
</Sidebar>
);
},
},
});
```
The `items` property is a list of all extensions provided by the `NavItemBlueprint` that are currently installed in the App. If you don't want to auto populate this list you can simply remove the rendering of that `SidebarGroup`, but otherwise you can see from the above example how a `SidebarItem` element is rendered for each of the items in the list.
The deprecated `items` prop (a flat list compatible with `<SidebarItem {...item} />`) remains supported for backward compatibility. If you don't want to auto-populate the list, simply remove the rendering of that `SidebarGroup`.
You might also notice that when you're rendering additional fixed icons for plugins that these might become duplicated as the plugin provides a `NavItem` extension and you're also rendering one in the `Sidebar` manually. In order to remove the item from the list of `items` which is passed through, we recommend that you disable that extension using config:
You might also notice that when you're rendering additional fixed icons for plugins (e.g. Search in a dedicated group) these might become duplicated, since that page is also included in `nav.rest()`. To exclude an item from the remaining list, call `nav.take('page:search')` before calling `nav.rest()` — you can discard the return value. Items that have been taken will not appear in `rest()`.
You can also use the old `NavItemBlueprint`-based nav item extensions to disable items from the nav bar, these can be disabled in config without affecting the page itself:
```yaml title="in app-config.yaml"
app:
@@ -742,15 +749,6 @@ app:
- nav-item:catalog: false
```
You can also determine the order of the provided auto installed `NavItems` that you get from the system in config. The below example ensures that the `catalog` navigation item will proceed the `search` navigation item when being passed through as the `item` prop.
```yaml title="in app-config.yaml"
app:
extensions:
- nav-item:catalog
- nav-item:search
```
#### App Root Routes
Your top-level routes are the routes directly under the `AppRouter` component with the `<FlatRoutes>` element. In a small app they might look something like this:
@@ -15,13 +15,23 @@ These are the [extension blueprints](../architecture/23-extension-blueprints.md)
An API extension is used to add or override [Utility API factories](../utility-apis/01-index.md) in the app. They are commonly used by plugins for both internal and shared APIs. There are also many built-in Api extensions provided by the framework that you are able to override.
### NavItem - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.NavItemBlueprint.html)
### NavItem (deprecated) - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.NavItemBlueprint.html)
Navigation item extensions are used to provide menu items that link to different parts of the app. By default nav items are attached to the app nav extension, which by default is rendered as the left sidebar in the app.
The `NavItemBlueprint` is deprecated. The app now auto-discovers navigation items from page extensions, so explicit nav item extensions are no longer needed. To migrate, ensure your plugin and/or page extensions have a `title` and `icon` set — these are used to populate the sidebar automatically.
### Page - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.PageBlueprint.html)
Page extensions provide content for a particular route in the app. By default pages are attached to the app routes extensions, which renders the root routes.
Page extensions provide content for a particular route in the app. By default pages are attached to the app routes extensions, which renders the root routes. Pages automatically inherit the plugin's `title` and `icon` as defaults, which can be overridden per-page via `PageBlueprint` params.
To enable sub-pages on a page, you can either omit the `loader` param to use the built-in default implementation that renders sub-pages as tabs, or provide a custom `loader` that explicitly handles the sub-page inputs.
### SubPage - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.SubPageBlueprint.html)
Sub-page extensions create tabbed content within a parent page. They are attached to a page extension's `pages` input and rendered as tabs in the page header. Each sub-page has a `path` (relative to the parent page), a `title` for the tab, and an optional `icon`. Content is lazy-loaded via a `loader` function.
### PluginHeaderAction - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.PluginHeaderActionBlueprint.html)
Plugin header action extensions provide plugin-scoped actions that appear in the page header. They are automatically scoped to the plugin that provides them and will appear in the header of all pages belonging to that plugin. Actions are lazy-loaded via a `loader` function that returns a React element.
## Extension blueprints in `@backstage/frontend-plugin-api/alpha`
@@ -51,10 +61,12 @@ Icon bundle extensions provide the ability to replace or provide new icons to th
Translation extension provide custom translation messages for the app. They can be used both to override the default english messages to custom ones, as well as provide translations for additional languages.
### NavContent - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.NavContentBlueprint.html)
### NavContent - [Reference](https://backstage.io/api/stable/variables/_backstage_plugin-app-react.NavContentBlueprint.html)
Nav content extensions allow you to replace the entire navbar with your own component. They are always attached to the app nav extension.
Your custom component receives a `navItems` prop—a collection with `take(id)` and `rest()` methods for placing specific items in custom positions. Nav items are auto-discovered from page extensions, and metadata (title, icon) comes from page config, nav item extensions, or plugin defaults. Use `navItems.take('page:home')` to take a specific item by extension ID, and `navItems.rest()` to get all remaining items. The deprecated `items` prop (a flat list) remains supported for backward compatibility.
### Router - [Reference](https://backstage.io/api/stable/variables/_backstage_frontend-plugin-api.RouterBlueprint.html)
Router extensions allow you to replace the router component used by the app. They are always attached to the app root extension.
@@ -42,6 +42,14 @@ const examplePage = createExtension({
The `title` data reference can be used for defining the extension input/output of string titles.
### `icon`
| id | type |
| :---------: | :-----------: |
| `core.icon` | `IconElement` |
The `icon` data reference can be used for defining the extension input/output of icon elements. The type is `IconElement` (`JSX.Element | null`) from `@backstage/frontend-plugin-api`. Icons should be exactly 24x24 pixels in size.
### `routePath`
| id | type |
+56 -3
View File
@@ -4,7 +4,10 @@
```ts
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { JSX as JSX_3 } from 'react/jsx-runtime';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
@@ -21,26 +24,76 @@ const examplePlugin: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef;
noHeader?: boolean;
};
}>;
}
+39 -28
View File
@@ -103,34 +103,45 @@ export const appModuleNav = createFrontendModule({
extensions: [
NavContentBlueprint.make({
params: {
component: ({ items }) => (
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
<SidebarScrollWrapper>
{items.map((item, index) => (
<SidebarItem {...item} key={index} />
))}
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarDivider />
<SidebarSpace />
<SidebarDivider />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
>
<NotificationsSidebarItem />
<SidebarItem icon={BuildIcon} to="devtools" text="DevTools" />
<Settings />
</SidebarGroup>
</Sidebar>
),
component: ({ navItems }) => {
const nav = navItems.withComponent(item => (
<SidebarItem
icon={() => item.icon}
to={item.href}
text={item.title}
/>
));
nav.take('page:home'); // Skip home
return (
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{nav.take('page:catalog')}
{nav.take('page:scaffolder')}
<SidebarDivider />
<SidebarScrollWrapper>
{nav.rest({ sortBy: 'title' })}
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarDivider />
<SidebarSpace />
<SidebarDivider />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
>
<NotificationsSidebarItem />
<SidebarItem icon={BuildIcon} to="devtools" text="DevTools" />
<Settings />
</SidebarGroup>
</Sidebar>
);
},
},
}),
],
@@ -29,6 +29,7 @@ import {
ProgressProps,
ExternalRouteRef,
IconComponent,
IconElement,
IconsApi,
RouteFunc,
RouteRef,
@@ -41,7 +42,7 @@ import {
NotFoundErrorPage,
ErrorDisplay,
} from '@backstage/frontend-plugin-api';
import { ComponentType, useMemo } from 'react';
import { ComponentType, createElement, useMemo } from 'react';
import { ReactNode } from 'react';
import { toLegacyPlugin } from './BackwardsCompatProvider';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
@@ -99,6 +100,11 @@ class CompatIconsApi implements IconsApi {
this.#app = app;
}
icon(key: string): IconElement | undefined {
const Icon = this.#app.getSystemIcon(key);
return Icon ? createElement(Icon) : undefined;
}
getIcon(key: string): IconComponent | undefined {
return this.#app.getSystemIcon(key);
}
@@ -40,11 +40,13 @@ describe('convertLegacyPlugin', () => {
"externalRoutes": {},
"featureFlags": [],
"getExtension": [Function],
"icon": undefined,
"id": "test",
"info": [Function],
"infoOptions": undefined,
"pluginId": "test",
"routes": {},
"title": undefined,
"toString": [Function],
"version": "v1",
"withOverrides": [Function],
@@ -50,6 +50,9 @@ describe('useApp', () => {
describe('new system', () => {
const mockIcon = () => null;
const mockIconsApi: IconsApi = {
icon: jest.fn((key: string) =>
key === 'test-icon' ? mockIcon() : undefined,
),
getIcon: jest.fn((key: string) =>
key === 'test-icon' ? mockIcon : undefined,
),
@@ -1,4 +1,5 @@
import {
Sidebar,
SidebarDivider,
SidebarGroup,
SidebarItem,
@@ -6,11 +7,8 @@ import {
SidebarSpace,
} from '@backstage/core-components';
import { compatWrapper } from '@backstage/core-compat-api';
import { Sidebar } from '@backstage/core-components';
import { NavContentBlueprint } from '@backstage/plugin-app-react';
import { SidebarLogo } from './SidebarLogo';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import HomeIcon from '@material-ui/icons/Home';
import MenuIcon from '@material-ui/icons/Menu';
import SearchIcon from '@material-ui/icons/Search';
import { SidebarSearchModal } from '@backstage/plugin-search';
@@ -19,8 +17,15 @@ import { NotificationsSidebarItem } from '@backstage/plugin-notifications';
export const SidebarContent = NavContentBlueprint.make({
params: {
component: ({ items }) =>
compatWrapper(
component: ({ navItems }) => {
const nav = navItems.withComponent(item => (
<SidebarItem
icon={() => item.icon}
to={item.href}
text={item.title}
/>
));
return compatWrapper(
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
@@ -28,20 +33,11 @@ export const SidebarContent = NavContentBlueprint.make({
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem
icon={CreateComponentIcon}
to="create"
text="Create..."
/>
{/* End global nav */}
{nav.take('page:catalog')}
{nav.take('page:scaffolder')}
<SidebarDivider />
<SidebarScrollWrapper>
{/* Items in this group will be scrollable if they run out of space */}
{items.map((item, index) => (
<SidebarItem {...item} key={index} />
))}
{nav.rest({ sortBy: 'title' })}
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarSpace />
@@ -56,6 +52,7 @@ export const SidebarContent = NavContentBlueprint.make({
<SidebarSettings />
</SidebarGroup>
</Sidebar>,
),
);
},
},
});
@@ -0,0 +1,137 @@
/*
* Copyright 2026 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 { createElement, memo, forwardRef } from 'react';
import { DefaultIconsApi } from './DefaultIconsApi';
describe('DefaultIconsApi', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should return undefined for unknown keys', () => {
const api = new DefaultIconsApi({});
expect(api.icon('missing')).toBeUndefined();
expect(api.getIcon('missing')).toBeUndefined();
});
it('should list all registered icon keys', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
const api = new DefaultIconsApi({
a: createElement('span'),
b: () => createElement('span'),
c: null,
});
expect(api.listIconKeys()).toEqual(['a', 'b', 'c']);
});
it('should return IconElement values directly via icon()', () => {
const element = createElement('span', null, 'test-icon');
const api = new DefaultIconsApi({ myIcon: element });
expect(api.icon('myIcon')).toBe(element);
});
it('should return null IconElement values via icon()', () => {
const api = new DefaultIconsApi({ empty: null });
expect(api.icon('empty')).toBeNull();
});
it('should convert IconComponent values to elements for icon()', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
const MyIcon = () => createElement('span', null, 'rendered');
const api = new DefaultIconsApi({ myIcon: MyIcon });
const result = api.icon('myIcon');
expect(result).toBeTruthy();
// @ts-expect-error accessing internal React element structure
expect(result.type).toBe(MyIcon);
});
it('should wrap IconElement values in a component for getIcon()', () => {
const element = createElement('span', null, 'test-icon');
const api = new DefaultIconsApi({ myIcon: element });
const icon = api.getIcon('myIcon');
expect(icon).toBeDefined();
expect(typeof icon).toBe('function');
// @ts-expect-error testing runtime behavior
expect(icon({})).toBe(element);
expect(api.getIcon('myIcon')).toBe(icon);
});
it('should wrap null IconElement in a component for getIcon()', () => {
const api = new DefaultIconsApi({ empty: null });
const icon = api.getIcon('empty');
expect(icon).toBeDefined();
expect(typeof icon).toBe('function');
// @ts-expect-error testing runtime behavior
expect(icon({})).toBeNull();
});
it('should log a single warning listing all IconComponent keys', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
void new DefaultIconsApi({
a: () => createElement('span'),
elem: createElement('span'),
b: () => createElement('span'),
empty: null,
});
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/a, b$/));
});
it('should not warn when only IconElement values are provided', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
void new DefaultIconsApi({
element: createElement('span'),
empty: null,
});
expect(warnSpy).not.toHaveBeenCalled();
});
it('should treat React.memo components as IconComponent', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const MemoIcon = memo(() => createElement('svg'));
const api = new DefaultIconsApi({ myIcon: MemoIcon });
const el = api.icon('myIcon');
expect(el).toBeTruthy();
// @ts-expect-error accessing internal React element structure
expect(el.type).toBe(MemoIcon);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('myIcon'));
});
it('should treat React.forwardRef components as IconComponent', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const RefIcon = forwardRef(() => createElement('svg'));
// @ts-expect-error forwardRef is not strictly IconComponent but should be handled
const api = new DefaultIconsApi({ myIcon: RefIcon });
const el = api.icon('myIcon');
expect(el).toBeTruthy();
// @ts-expect-error accessing internal React element structure
expect(el.type).toBe(RefIcon);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('myIcon'));
});
});
@@ -14,7 +14,12 @@
* limitations under the License.
*/
import { IconComponent, IconsApi } from '@backstage/frontend-plugin-api';
import {
IconComponent,
IconElement,
IconsApi,
} from '@backstage/frontend-plugin-api';
import { createElement, isValidElement } from 'react';
/**
* Implementation for the {@link IconsApi}
@@ -22,14 +27,47 @@ import { IconComponent, IconsApi } from '@backstage/frontend-plugin-api';
* @internal
*/
export class DefaultIconsApi implements IconsApi {
#icons: Map<string, IconComponent>;
#icons: Map<string, IconElement>;
#components = new Map<string, IconComponent>();
constructor(icons: { [key in string]: IconComponent }) {
this.#icons = new Map(Object.entries(icons));
constructor(icons: { [key in string]: IconComponent | IconElement }) {
const deprecatedKeys: string[] = [];
this.#icons = new Map(
Object.entries(icons).map(([key, value]) => {
if (value === null || isValidElement(value)) {
return [key, value];
}
deprecatedKeys.push(key);
return [key, createElement(value as IconComponent)];
}),
);
if (deprecatedKeys.length > 0) {
const keys = deprecatedKeys.join(', ');
// eslint-disable-next-line no-console
console.warn(
`The following icons were registered as IconComponent, which is deprecated. Use IconElement instead by passing <MyIcon /> rather than MyIcon: ${keys}`,
);
}
}
icon(key: string): IconElement | undefined {
return this.#icons.get(key);
}
getIcon(key: string): IconComponent | undefined {
return this.#icons.get(key);
let component = this.#components.get(key);
if (component) {
return component;
}
const el = this.#icons.get(key);
if (el === undefined) {
return undefined;
}
component = () => el;
this.#components.set(key, component);
return component;
}
listIconKeys(): string[] {
@@ -388,11 +388,13 @@ describe('createApp', () => {
<component:app/core-progress out=[core.swappableComponent] />
<component:app/core-not-found-error-page out=[core.swappableComponent] />
<component:app/core-error-display out=[core.swappableComponent] />
<component:app/core-page-layout out=[core.swappableComponent] />
]
</api:app/swappable-components>
<api:app/icons out=[core.api.factory] />
<api:app/feature-flags out=[core.api.factory] />
<api:app/plugin-wrapper out=[core.api.factory] />
<api:app/plugin-header-actions out=[core.api.factory] />
<api:app/translations out=[core.api.factory] />
<api:app/components out=[core.api.factory] />
]
@@ -17,6 +17,7 @@
import {
Extension,
FeatureFlagConfig,
IconElement,
OverridableFrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
@@ -26,6 +27,8 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{
public: OverridableFrontendPlugin;
versions: {
readonly version: 'v1';
readonly title?: string;
readonly icon?: IconElement;
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
readonly infoOptions?: {
+174 -15
View File
@@ -13,10 +13,11 @@ 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';
import { JSX as JSX_3 } from 'react';
import { JSX as JSX_2 } from 'react';
import { JSX as JSX_3 } from 'react/jsx-runtime';
import { Observable } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { ReactNode } from 'react';
@@ -51,7 +52,7 @@ export const analyticsApiRef: ApiRef<AnalyticsApi>;
export const AnalyticsContext: (options: {
attributes: Partial<AnalyticsContextValue>;
children: ReactNode;
}) => JSX_2.Element;
}) => JSX_3.Element;
// @public
export interface AnalyticsContextValue {
@@ -262,7 +263,7 @@ export const AppRootElementBlueprint: ExtensionBlueprint_2<{
params: {
element: JSX.Element;
};
output: ExtensionDataRef_2<JSX_3, 'core.reactElement', {}>;
output: ExtensionDataRef_2<JSX_2, 'core.reactElement', {}>;
inputs: {};
config: {};
configInput: {};
@@ -388,8 +389,9 @@ export interface ConfigurableExtensionDataRef<
// @public (undocumented)
export const coreExtensionData: {
title: ConfigurableExtensionDataRef_2<string, 'core.title', {}>;
icon: ConfigurableExtensionDataRef_2<IconElement, 'core.icon', {}>;
reactElement: ConfigurableExtensionDataRef_2<
JSX_3.Element,
JSX_2.Element,
'core.reactElement',
{}
>;
@@ -1101,7 +1103,7 @@ export type ExtensionBlueprintParams<T extends object = object> = {
};
// @public (undocumented)
export function ExtensionBoundary(props: ExtensionBoundaryProps): JSX_2.Element;
export function ExtensionBoundary(props: ExtensionBoundaryProps): JSX_3.Element;
// @public (undocumented)
export namespace ExtensionBoundary {
@@ -1367,12 +1369,14 @@ export interface FrontendPlugin<
readonly $$type: '@backstage/FrontendPlugin';
// (undocumented)
readonly externalRoutes: TExternalRoutes;
readonly icon?: IconElement;
// @deprecated
readonly id: string;
info(): Promise<FrontendPluginInfo>;
readonly pluginId: string;
// (undocumented)
readonly routes: TRoutes;
readonly title?: string;
}
// @public
@@ -1420,15 +1424,19 @@ export const googleAuthApiRef: ApiRef<
SessionApi
>;
// @public
// @public @deprecated
export type IconComponent = ComponentType<{
fontSize?: 'medium' | 'large' | 'small' | 'inherit';
}>;
// @public
export type IconElement = JSX_2.Element | null;
// @public
export interface IconsApi {
// (undocumented)
// @deprecated (undocumented)
getIcon(key: string): IconComponent | undefined;
icon(key: string): IconElement | undefined;
// (undocumented)
listIconKeys(): string[];
}
@@ -1699,7 +1707,9 @@ export interface OverridableFrontendPlugin<
): OverridableExtensionDefinition<TExtensionMap[TId]['T']>;
// (undocumented)
withOverrides(options: {
extensions: Array<ExtensionDefinition>;
extensions?: Array<ExtensionDefinition>;
title?: string;
icon?: IconElement;
info?: FrontendPluginInfoOptions;
}): OverridableFrontendPlugin<TRoutes, TExternalRoutes, TExtensionMap>;
}
@@ -1710,29 +1720,113 @@ export const PageBlueprint: ExtensionBlueprint_2<{
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef;
noHeader?: boolean;
};
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_2.Element, 'core.reactElement', {}>
| ExtensionDataRef_2<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef_2<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput_2<
| ConfigurableExtensionDataRef_2<JSX_2.Element, '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;
}
>
| ConfigurableExtensionDataRef_2<
IconElement,
'core.icon',
{
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)
headerActions?: Array<JSX.Element | null>;
// (undocumented)
icon?: IconElement;
// (undocumented)
noHeader?: boolean;
// (undocumented)
tabs?: PageTab[];
// (undocumented)
title?: string;
}
// @public
export interface PageTab {
// (undocumented)
href: string;
// (undocumented)
icon?: IconElement;
// (undocumented)
id: string;
// (undocumented)
label: string;
}
// @public
export type PendingOAuthRequest = {
provider: AuthProviderInfo;
@@ -1740,6 +1834,29 @@ export type PendingOAuthRequest = {
trigger(): Promise<void>;
};
// @public
export const PluginHeaderActionBlueprint: ExtensionBlueprint_2<{
kind: 'plugin-header-action';
params: (params: {
loader: () => Promise<JSX.Element>;
}) => ExtensionBlueprintParams_2<{
loader: () => Promise<JSX.Element>;
}>;
output: ExtensionDataRef_2<JSX_2, 'core.reactElement', {}>;
inputs: {};
config: {};
configInput: {};
dataRefs: never;
}>;
// @public
export type PluginHeaderActionsApi = {
getPluginHeaderActions(pluginId: string): Array<JSX_2.Element | null>;
};
// @public
export const pluginHeaderActionsApiRef: ApiRef_2<PluginHeaderActionsApi>;
// @public (undocumented)
export interface PluginOptions<
TId extends string,
@@ -1757,12 +1874,14 @@ export interface PluginOptions<
externalRoutes?: TExternalRoutes;
// (undocumented)
featureFlags?: FeatureFlagConfig[];
icon?: IconElement;
// (undocumented)
info?: FrontendPluginInfoOptions;
// (undocumented)
pluginId: TId;
// (undocumented)
routes?: TRoutes;
title?: string;
}
// @public (undocumented)
@@ -1898,6 +2017,46 @@ export type StorageValueSnapshot<TValue extends JsonValue> =
value: TValue;
};
// @public
export const SubPageBlueprint: ExtensionBlueprint_2<{
kind: 'sub-page';
params: {
path: string;
title: string;
icon?: IconElement;
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_2, 'core.reactElement', {}>
| ExtensionDataRef_2<string, 'core.title', {}>
| ExtensionDataRef_2<
IconElement,
'core.icon',
{
optional: true;
}
>;
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,
@@ -1980,9 +2139,9 @@ export type TranslationFunction<
NestedMessageKeys<TKey, IMessages>,
PluralKeys<TMessages>,
IMessages,
string | JSX_3.Element
string | JSX_2.Element
>
): JSX_3.Element;
): JSX_2.Element;
}
: never;
@@ -2158,7 +2317,7 @@ export function withApis<T extends {}>(
): <TProps extends T>(
WrappedComponent: ComponentType<TProps>,
) => {
(props: PropsWithChildren<Omit<TProps, keyof T>>): JSX_2.Element;
(props: PropsWithChildren<Omit<TProps, keyof T>>): JSX_3.Element;
displayName: string;
};
```
@@ -15,7 +15,7 @@
*/
import { createApiRef } from '../system';
import { IconComponent } from '../../icons';
import { IconComponent, IconElement } from '../../icons';
/**
* API for accessing app icons.
@@ -23,6 +23,14 @@ import { IconComponent } from '../../icons';
* @public
*/
export interface IconsApi {
/**
* Look up an icon element by key.
*/
icon(key: string): IconElement | undefined;
/**
* @deprecated Use {@link IconsApi.icon} instead.
*/
getIcon(key: string): IconComponent | undefined;
listIconKeys(): string[];
@@ -0,0 +1,45 @@
/*
* Copyright 2026 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 { JSX } from 'react';
import { createApiRef } from '../system';
/**
* API for retrieving plugin-scoped header actions.
*
* @remarks
*
* Header actions are provided via
* {@link @backstage/frontend-plugin-api#PluginHeaderActionBlueprint}
* and automatically scoped to the providing plugin.
*
* @public
*/
export type PluginHeaderActionsApi = {
/**
* Returns the header actions for a given plugin.
*/
getPluginHeaderActions(pluginId: string): Array<JSX.Element | null>;
};
/**
* The `ApiRef` of {@link PluginHeaderActionsApi}.
*
* @public
*/
export const pluginHeaderActionsApiRef = createApiRef<PluginHeaderActionsApi>({
id: 'core.plugin-header-actions',
});
@@ -49,3 +49,4 @@ export * from './RouteResolutionApi';
export * from './StorageApi';
export * from './AnalyticsApi';
export * from './TranslationApi';
export * from './PluginHeaderActionsApi';
@@ -56,13 +56,63 @@ describe('PageBlueprint', () => {
"path": {
"type": "string",
},
"title": {
"type": "string",
},
},
"type": "object",
},
},
"disabled": false,
"factory": [Function],
"inputs": {},
"inputs": {
"pages": {
"$$type": "@backstage/ExtensionInput",
"config": {
"internal": false,
"optional": false,
"singleton": false,
},
"context": {
"input": "pages",
"kind": "page",
"name": "test-page",
},
"extensionData": [
[Function],
{
"$$type": "@backstage/ExtensionDataRef",
"config": {
"optional": true,
},
"id": "core.routing.ref",
"optional": [Function],
"toString": [Function],
},
[Function],
{
"$$type": "@backstage/ExtensionDataRef",
"config": {
"optional": true,
},
"id": "core.title",
"optional": [Function],
"toString": [Function],
},
{
"$$type": "@backstage/ExtensionDataRef",
"config": {
"optional": true,
},
"id": "core.icon",
"optional": [Function],
"toString": [Function],
},
],
"replaces": undefined,
"withContext": [Function],
},
},
"kind": "page",
"name": "test-page",
"output": [
@@ -77,6 +127,24 @@ describe('PageBlueprint', () => {
"optional": [Function],
"toString": [Function],
},
{
"$$type": "@backstage/ExtensionDataRef",
"config": {
"optional": true,
},
"id": "core.title",
"optional": [Function],
"toString": [Function],
},
{
"$$type": "@backstage/ExtensionDataRef",
"config": {
"optional": true,
},
"id": "core.icon",
"optional": [Function],
"toString": [Function],
},
],
"override": [Function],
"toString": [Function],
@@ -14,26 +14,47 @@
* limitations under the License.
*/
import { JSX } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { IconElement } from '../icons/types';
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';
import { useApi } from '../apis/system';
import { pluginHeaderActionsApiRef } from '../apis/definitions/PluginHeaderActionsApi';
/**
* Createx extensions that are routable React page components.
* Creates extensions that are routable React page components.
*
* @public
*/
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(),
coreExtensionData.icon.optional(),
]),
},
output: [
coreExtensionData.routePath,
coreExtensionData.reactElement,
coreExtensionData.routeRef.optional(),
coreExtensionData.title.optional(),
coreExtensionData.icon.optional(),
],
config: {
schema: {
path: z => z.string().optional(),
title: z => z.string().optional(),
},
},
*factory(
@@ -43,17 +64,106 @@ export const PageBlueprint = createExtensionBlueprint({
*/
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX.Element>;
routeRef?: RouteRef;
/**
* Hide the default plugin page header, making the page fill up all available space.
*/
noHeader?: boolean;
},
{ config, node },
{ config, node, inputs },
) {
const title = config.title ?? params.title;
const icon = params.icon;
const pluginId = node.spec.plugin.pluginId;
const noHeader = params.noHeader ?? false;
yield coreExtensionData.routePath(config.path ?? params.path);
yield coreExtensionData.reactElement(
ExtensionBoundary.lazy(node, params.loader),
);
if (params.loader) {
const loader = params.loader;
const PageContent = () => {
const headerActionsApi = useApi(pluginHeaderActionsApiRef);
const headerActions = headerActionsApi.getPluginHeaderActions(pluginId);
return (
<PageLayout
title={title ?? node.spec.plugin.title ?? node.spec.plugin.pluginId}
icon={icon ?? node.spec.plugin.icon}
noHeader={noHeader}
headerActions={headerActions}
>
{ExtensionBoundary.lazy(node, loader)}
</PageLayout>
);
};
yield coreExtensionData.reactElement(<PageContent />);
} else if (inputs.pages.length > 0) {
// 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);
const tabIcon = page.get(coreExtensionData.icon);
return {
id: path,
label: tabTitle || path,
icon: tabIcon,
href: path,
};
});
const PageContent = () => {
const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath);
const headerActionsApi = useApi(pluginHeaderActionsApiRef);
const headerActions = headerActionsApi.getPluginHeaderActions(pluginId);
return (
<PageLayout
title={title}
icon={icon}
tabs={tabs}
headerActions={headerActions}
>
<Routes>
{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 {
const PageContent = () => {
const headerActionsApi = useApi(pluginHeaderActionsApiRef);
const headerActions = headerActionsApi.getPluginHeaderActions(pluginId);
return (
<PageLayout title={title} icon={icon} headerActions={headerActions} />
);
};
yield coreExtensionData.reactElement(<PageContent />);
}
if (params.routeRef) {
yield coreExtensionData.routeRef(params.routeRef);
}
if (title) {
yield coreExtensionData.title(title);
}
if (icon) {
yield coreExtensionData.icon(icon);
}
},
});
@@ -0,0 +1,52 @@
/*
* Copyright 2025 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 { lazy as reactLazy } from 'react';
import { ExtensionBoundary } from '../components';
import {
coreExtensionData,
createExtensionBlueprint,
createExtensionBlueprintParams,
} from '../wiring';
/**
* Creates extensions that provide plugin-scoped header actions.
*
* @remarks
*
* These actions are automatically scoped to the plugin that provides them
* and will appear in the header of all pages belonging to that plugin.
*
* @public
*/
export const PluginHeaderActionBlueprint = createExtensionBlueprint({
kind: 'plugin-header-action',
attachTo: { id: 'api:app/plugin-header-actions', input: 'actions' },
output: [coreExtensionData.reactElement],
defineParams(params: { loader: () => Promise<JSX.Element> }) {
return createExtensionBlueprintParams(params);
},
*factory(params, { node }) {
const LazyAction = reactLazy(() =>
params.loader().then(element => ({ default: () => element })),
);
yield coreExtensionData.reactElement(
<ExtensionBoundary node={node} errorPresentation="error-api">
<LazyAction />
</ExtensionBoundary>,
);
},
});
@@ -0,0 +1,99 @@
/*
* Copyright 2026 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 { IconElement } from '../icons/types';
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(),
coreExtensionData.icon.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. Must **not** start with '/'.
*
* @example 'overview', 'settings', 'details'
*/
path: string;
/**
* The title displayed in the tab for this sub-page.
*/
title: string;
/**
* Optional icon for this sub-page, displayed in the tab.
*/
icon?: IconElement;
/**
* 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);
}
if (params.icon) {
yield coreExtensionData.icon(params.icon);
}
},
});
@@ -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 { PluginHeaderActionBlueprint } from './PluginHeaderActionBlueprint';
@@ -0,0 +1,141 @@
/*
* 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 { IconElement } from '../icons/types';
import { createSwappableComponent } from './createSwappableComponent';
/**
* Tab configuration for page navigation
* @public
*/
export interface PageTab {
id: string;
label: string;
icon?: IconElement;
href: string;
}
/**
* Props for the PageLayout component
* @public
*/
export interface PageLayoutProps {
title?: string;
icon?: IconElement;
noHeader?: boolean;
headerActions?: Array<JSX.Element | null>;
tabs?: PageTab[];
children?: ReactNode;
}
/**
* Default implementation of PageLayout using plain HTML elements
*/
function DefaultPageLayout(props: PageLayoutProps): JSX.Element {
const { title, icon, headerActions, 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={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px 24px 8px',
fontSize: '18px',
fontWeight: 500,
}}
>
{icon}
{title}
{headerActions && (
<div style={{ marginLeft: 'auto' }}>{headerActions}</div>
)}
</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={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '8px 12px',
textDecoration: 'none',
color: '#333',
borderBottom: '2px solid transparent',
}}
>
{tab.icon}
{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,4 @@ export {
} from './createSwappableComponent';
export { useAppNode } from './AppNodeProvider';
export * from './DefaultSwappableComponents';
export { PageLayout, type PageLayoutProps, type PageTab } from './PageLayout';
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export type { IconComponent } from './types';
export type { IconComponent, IconElement } from './types';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ComponentType } from 'react';
import { ComponentType, JSX } from 'react';
/**
* IconComponent is the common icon type used throughout Backstage when
@@ -31,7 +31,19 @@ import { ComponentType } from 'react';
* also describe your use-case and reasoning of the addition.
*
* @public
* @deprecated Use {@link IconElement} instead, passing `<MyIcon />` rather than `MyIcon`.
*/
export type IconComponent = ComponentType<{
fontSize?: 'medium' | 'large' | 'small' | 'inherit';
}>;
/**
* The type used for icon elements throughout Backstage.
*
* @remarks
*
* Icons should be exactly 24x24 pixels in size.
*
* @public
*/
export type IconElement = JSX.Element | null;
@@ -15,12 +15,15 @@
*/
import { JSX } from 'react';
import { IconElement } from '../icons/types';
import { RouteRef } from '../routing/RouteRef';
import { createExtensionDataRef } from './createExtensionDataRef';
/** @public */
export const coreExtensionData = {
title: createExtensionDataRef<string>().with({ id: 'core.title' }),
/** An icon element for the extension. Should be exactly 24x24 pixels. */
icon: createExtensionDataRef<IconElement>().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 { IconElement } from '../icons/types';
import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing';
import { ID_PATTERN } from './constants';
@@ -112,7 +113,17 @@ export interface OverridableFrontendPlugin<
id: TId,
): OverridableExtensionDefinition<TExtensionMap[TId]['T']>;
withOverrides(options: {
extensions: Array<ExtensionDefinition>;
extensions?: Array<ExtensionDefinition>;
/**
* Overrides the display title of the plugin.
*/
title?: string;
/**
* Overrides the display icon of the plugin.
*/
icon?: IconElement;
/**
* Overrides the original info loaders of the plugin one by one.
@@ -141,6 +152,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?: IconElement;
readonly routes: TRoutes;
readonly externalRoutes: TExternalRoutes;
@@ -158,6 +178,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?: IconElement;
routes?: TRoutes;
externalRoutes?: TExternalRoutes;
extensions?: TExtensions;
@@ -250,6 +279,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 ?? [],
@@ -275,8 +306,9 @@ export function createFrontendPlugin<
return `Plugin{id=${pluginId}}`;
},
withOverrides(overrides) {
const overrideExtensions = overrides.extensions ?? [];
const overriddenExtensionIds = new Set(
overrides.extensions.map(
overrideExtensions.map(
e => resolveExtensionDefinition(e, { namespace: pluginId }).id,
),
);
@@ -289,7 +321,9 @@ export function createFrontendPlugin<
return createFrontendPlugin({
...options,
pluginId,
extensions: [...nonOverriddenExtensions, ...overrides.extensions],
title: overrides.title ?? options.title,
icon: overrides.icon ?? options.icon,
extensions: [...nonOverriddenExtensions, ...overrideExtensions],
info: {
...options.info,
...overrides.info,
+58 -5
View File
@@ -6,14 +6,17 @@
import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { defaultEntityContentGroups } from '@backstage/plugin-catalog-react/alpha';
import { Entity } from '@backstage/catalog-model';
import { EntityCardType } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { JSXElementConstructor } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
@@ -348,7 +351,6 @@ const _default: OverridableFrontendPlugin<
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
@@ -356,6 +358,7 @@ const _default: OverridableFrontendPlugin<
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
@@ -418,7 +421,6 @@ const _default: OverridableFrontendPlugin<
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
@@ -426,6 +428,7 @@ const _default: OverridableFrontendPlugin<
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
@@ -494,29 +497,79 @@ const _default: OverridableFrontendPlugin<
config: {
initiallySelectedFilter: 'all' | 'owned' | 'starred' | undefined;
path: string | undefined;
title: string | undefined;
};
configInput: {
initiallySelectedFilter?: 'all' | 'owned' | 'starred' | undefined;
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
kind: 'page';
name: undefined;
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
}
+2
View File
@@ -208,6 +208,8 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({
export default createFrontendPlugin({
pluginId: 'api-docs',
title: 'APIs',
icon: <AppIcon id="kind:api" />,
info: { packageJson: () => import('../package.json') },
routes: {
root: rootRoute,
+32 -3
View File
@@ -3,6 +3,7 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AppNode } from '@backstage/frontend-plugin-api';
import { AppTheme } from '@backstage/frontend-plugin-api';
import { ComponentType } from 'react';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
@@ -10,6 +11,7 @@ import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { IdentityApi } from '@backstage/frontend-plugin-api';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
@@ -45,11 +47,11 @@ export const AppRootWrapperBlueprint: ExtensionBlueprint<{
export const IconBundleBlueprint: ExtensionBlueprint<{
kind: 'icon-bundle';
params: {
icons: { [key in string]: IconComponent };
icons: { [key in string]: IconComponent | IconElement };
};
output: ExtensionDataRef<
{
[x: string]: IconComponent;
[x: string]: IconComponent | IconElement;
},
'core.icons',
{}
@@ -60,7 +62,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{
dataRefs: {
icons: ConfigurableExtensionDataRef<
{
[x: string]: IconComponent;
[x: string]: IconComponent | IconElement;
},
'core.icons',
{}
@@ -98,6 +100,7 @@ export type NavContentComponent = (
// @public
export interface NavContentComponentProps {
// @deprecated
items: Array<{
icon: IconComponent;
title: string;
@@ -105,6 +108,32 @@ export interface NavContentComponentProps {
to: string;
text: string;
}>;
navItems: NavContentNavItems;
}
// @public
export interface NavContentNavItem {
href: string;
icon: IconElement;
node: AppNode;
routeRef: RouteRef;
title: string;
}
// @public
export interface NavContentNavItems {
clone(): NavContentNavItems;
rest(): NavContentNavItem[];
take(id: string): NavContentNavItem | undefined;
withComponent(
Component: ComponentType<NavContentNavItem>,
): NavContentNavItemsWithComponent;
}
// @public
export interface NavContentNavItemsWithComponent {
rest(options?: { sortBy?: 'title' }): JSX.Element[];
take(id: string): JSX.Element | null;
}
// @public
@@ -14,14 +14,14 @@
* limitations under the License.
*/
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconComponent, IconElement } from '@backstage/frontend-plugin-api';
import {
createExtensionBlueprint,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
const iconsDataRef = createExtensionDataRef<{
[key in string]: IconComponent;
[key in string]: IconComponent | IconElement;
}>().with({ id: 'core.icons' });
/**
@@ -33,9 +33,9 @@ export const IconBundleBlueprint = createExtensionBlueprint({
kind: 'icon-bundle',
attachTo: { id: 'api:app/icons', input: 'icons' },
output: [iconsDataRef],
factory: (params: { icons: { [key in string]: IconComponent } }) => [
iconsDataRef(params.icons),
],
factory: (params: {
icons: { [key in string]: IconComponent | IconElement };
}) => [iconsDataRef(params.icons)],
dataRefs: {
icons: iconsDataRef,
},
@@ -14,12 +14,59 @@
* limitations under the License.
*/
import { createRouteRef } from '@backstage/frontend-plugin-api';
import { NavContentBlueprint } from './NavContentBlueprint';
import { AppNode, createRouteRef } from '@backstage/frontend-plugin-api';
import {
NavContentBlueprint,
NavContentNavItem,
NavContentNavItems,
} from './NavContentBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { render, screen } from '@testing-library/react';
const routeRef = createRouteRef();
function mockNode(id: string): AppNode {
return { spec: { id } } as AppNode;
}
function mockNavItems(items: NavContentNavItem[]): NavContentNavItems {
const taken = new Set<string>();
return {
take(id: string) {
const item = items.find(i => i.node.spec.id === id);
if (item) {
taken.add(id);
}
return item;
},
rest: () => items.filter(i => !taken.has(i.node.spec.id)),
clone() {
return mockNavItems(items);
},
withComponent(Component: (props: NavContentNavItem) => JSX.Element) {
return {
take: (id: string) => {
const item = items.find(i => i.node.spec.id === id);
if (item) {
taken.add(id);
return <Component {...item} />;
}
return null;
},
rest: (options?: { sortBy?: 'title' }) => {
const remaining = items.filter(i => !taken.has(i.node.spec.id));
if (options?.sortBy === 'title') {
remaining.sort((a, b) => a.title.localeCompare(b.title));
}
return remaining.map(item => (
<Component key={item.node.spec.id} {...item} />
));
},
};
},
};
}
describe('NavContentBlueprint', () => {
it('should create an extension with sensible defaults', () => {
const extension = NavContentBlueprint.make({
@@ -52,22 +99,7 @@ describe('NavContentBlueprint', () => {
`);
});
it('should return a valid component', () => {
const extension = NavContentBlueprint.make({
name: 'test',
params: {
component: () => <div>Nav content</div>,
},
});
const tester = createExtensionTester(extension);
expect(
tester.get(NavContentBlueprint.dataRefs.component)({ items: [] }),
).toEqual(<div>Nav content</div>);
});
it('should return a valid component with items', () => {
it('should return a valid component with legacy items', () => {
const extension = NavContentBlueprint.make({
name: 'test',
params: {
@@ -88,6 +120,7 @@ describe('NavContentBlueprint', () => {
expect(
tester.get(NavContentBlueprint.dataRefs.component)({
navItems: mockNavItems([]),
items: [
{
to: '/',
@@ -109,4 +142,128 @@ describe('NavContentBlueprint', () => {
</div>,
);
});
it('should return a valid component with navItems', () => {
const items: NavContentNavItem[] = [
{
node: mockNode('page:home'),
href: '/',
title: 'Home',
icon: <span>home</span>,
routeRef,
},
{
node: mockNode('page:catalog'),
href: '/catalog',
title: 'Catalog',
icon: <span>catalog</span>,
routeRef,
},
{
node: mockNode('page:docs'),
href: '/docs',
title: 'Docs',
icon: <span>docs</span>,
routeRef,
},
];
const extension = NavContentBlueprint.make({
name: 'test',
params: {
component: ({ navItems }) => (
<div>
{navItems.rest().map(item => (
<a key={item.node.spec.id} href={item.href}>
{item.title}
</a>
))}
</div>
),
},
});
const tester = createExtensionTester(extension);
expect(
tester.get(NavContentBlueprint.dataRefs.component)({
navItems: mockNavItems(items),
items: [],
}),
).toEqual(
<div>
{[
<a key="page:home" href="/">
Home
</a>,
<a key="page:catalog" href="/catalog">
Catalog
</a>,
<a key="page:docs" href="/docs">
Docs
</a>,
]}
</div>,
);
});
it('should support withComponent for take and rest', () => {
const items: NavContentNavItem[] = [
{
node: mockNode('page:home'),
href: '/',
title: 'Home',
icon: <span>home</span>,
routeRef,
},
{
node: mockNode('page:catalog'),
href: '/catalog',
title: 'Catalog',
icon: <span>catalog</span>,
routeRef,
},
{
node: mockNode('page:docs'),
href: '/docs',
title: 'Docs',
icon: <span>docs</span>,
routeRef,
},
];
const extension = NavContentBlueprint.make({
name: 'test',
params: {
component: ({ navItems }) => {
const nav = navItems.withComponent(item => (
<a href={item.href}>{item.title}</a>
));
return (
<div>
<header>{nav.take('page:home')}</header>
<nav>{nav.rest()}</nav>
</div>
);
},
},
});
const tester = createExtensionTester(extension);
const Component = tester.get(NavContentBlueprint.dataRefs.component);
render(<Component navItems={mockNavItems(items)} items={[]} />);
const homeLink = screen.getByText('Home');
expect(homeLink).toBeInTheDocument();
expect(homeLink.closest('header')).toBeTruthy();
const catalogLink = screen.getByText('Catalog');
expect(catalogLink).toBeInTheDocument();
expect(catalogLink.closest('nav')).toBeTruthy();
const docsLink = screen.getByText('Docs');
expect(docsLink).toBeInTheDocument();
expect(docsLink.closest('nav')).toBeTruthy();
});
});
@@ -14,12 +14,68 @@
* limitations under the License.
*/
import { IconComponent, RouteRef } from '@backstage/frontend-plugin-api';
import { ComponentType } from 'react';
import {
AppNode,
IconComponent,
IconElement,
RouteRef,
} from '@backstage/frontend-plugin-api';
import {
createExtensionBlueprint,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
/**
* A navigation item auto-discovered from a page extension in the app.
*
* @public
*/
export interface NavContentNavItem {
/** The app node of the page extension that this nav item points to */
node: AppNode;
/** The resolved route path */
href: string;
/** The display title */
title: string;
/** The display icon */
icon: IconElement;
/** The route ref of the source page */
routeRef: RouteRef;
}
/**
* A pre-bound renderer that wraps {@link NavContentNavItems} with a component,
* so that `take` and `rest` return rendered elements directly.
*
* @public
*/
export interface NavContentNavItemsWithComponent {
/** Render and take a specific item by extension ID. Returns null if not found. */
take(id: string): JSX.Element | null;
/** Render all remaining items not yet taken, optionally sorted. */
rest(options?: { sortBy?: 'title' }): JSX.Element[];
}
/**
* A collection of nav items that supports picking specific items by ID
* and retrieving whatever remains. Created fresh for each render.
*
* @public
*/
export interface NavContentNavItems {
/** Take an item by extension ID, removing it from the collection. */
take(id: string): NavContentNavItem | undefined;
/** All items not yet taken. */
rest(): NavContentNavItem[];
/** Create a copy of the collection preserving the current taken state. */
clone(): NavContentNavItems;
/** Create a renderer that wraps take/rest to return pre-rendered elements. */
withComponent(
Component: ComponentType<NavContentNavItem>,
): NavContentNavItemsWithComponent;
}
/**
* The props for the {@link NavContentComponent}.
*
@@ -27,20 +83,21 @@ import {
*/
export interface NavContentComponentProps {
/**
* The nav items available to the component. These are all the items created
* with the {@link @backstage/frontend-plugin-api#NavItemBlueprint} in the app.
* Nav items auto-discovered from page extensions, with take/rest semantics
* for placing specific items in specific positions.
*/
navItems: NavContentNavItems;
/**
* Flat list of nav items for simple rendering. Use `navItems` for more
* control over item placement.
*
* In addition to the original properties from the nav items, these also
* include a resolved route path as `to`, and duplicated `title` as `text` to
* simplify rendering.
* @deprecated Use `navItems` instead.
*/
items: Array<{
// Original props from nav items
icon: IconComponent;
title: string;
routeRef: RouteRef<undefined>;
// Additional props to simplify item rendering
to: string;
text: string;
}>;
@@ -20,6 +20,9 @@ export { NavContentBlueprint } from './NavContentBlueprint';
export type {
NavContentComponent,
NavContentComponentProps,
NavContentNavItem,
NavContentNavItemsWithComponent,
NavContentNavItems,
} from './NavContentBlueprint';
export { RouterBlueprint } from './RouterBlueprint';
export { SignInPageBlueprint } from './SignInPageBlueprint';
+183 -2
View File
@@ -4,8 +4,12 @@
```ts
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -42,24 +46,201 @@ const visualizerPlugin: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef;
noHeader?: boolean;
};
}>;
'plugin-header-action:app-visualizer': OverridableExtensionDefinition<{
kind: 'plugin-header-action';
name: undefined;
config: {};
configInput: {};
output: ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>;
inputs: {};
params: (params: {
loader: () => Promise<JSX.Element>;
}) => ExtensionBlueprintParams<{
loader: () => Promise<JSX.Element>;
}>;
}>;
'sub-page:app-visualizer/details': OverridableExtensionDefinition<{
kind: 'sub-page';
name: 'details';
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
params: {
path: string;
title: string;
icon?: IconElement;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef;
};
}>;
'sub-page:app-visualizer/text': OverridableExtensionDefinition<{
kind: 'sub-page';
name: 'text';
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
params: {
path: string;
title: string;
icon?: IconElement;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef;
};
}>;
'sub-page:app-visualizer/tree': OverridableExtensionDefinition<{
kind: 'sub-page';
name: 'tree';
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
params: {
path: string;
title: string;
icon?: IconElement;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef;
};
@@ -15,8 +15,6 @@
*/
import { Content, Header, HeaderTabs, Page } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { appTreeApiRef } from '@backstage/frontend-plugin-api';
import { Flex } from '@backstage/ui';
import { useCallback, useEffect, useMemo } from 'react';
import { DetailedVisualizer } from './DetailedVisualizer';
@@ -31,31 +29,28 @@ import {
} from 'react-router-dom';
export function AppVisualizerPage() {
const appTreeApi = useApi(appTreeApiRef);
const { tree } = appTreeApi.getTree();
const tabs = useMemo(
() => [
{
id: 'tree',
path: 'tree',
label: 'Tree',
element: <TreeVisualizer tree={tree} />,
element: <TreeVisualizer />,
},
{
id: 'detailed',
path: 'detailed',
label: 'Detailed',
element: <DetailedVisualizer tree={tree} />,
element: <DetailedVisualizer />,
},
{
id: 'text',
path: 'text',
label: 'Text',
element: <TextVisualizer tree={tree} />,
element: <TextVisualizer />,
},
],
[tree],
[],
);
const location = useLocation();
@@ -16,15 +16,23 @@
import {
AppNode,
AppTree,
ExtensionDataRef,
coreExtensionData,
ApiBlueprint,
NavItemBlueprint,
useApi,
routeResolutionApiRef,
appTreeApiRef,
} from '@backstage/frontend-plugin-api';
import { Box, Flex, Link, Text, Tooltip, TooltipTrigger } from '@backstage/ui';
import {
Box,
Flex,
FullPage,
Link,
Text,
Tooltip,
TooltipTrigger,
} from '@backstage/ui';
import {
RiInputField as InputIcon,
RiCloseCircleLine as DisabledIcon,
@@ -351,24 +359,29 @@ function Legend() {
);
}
export function DetailedVisualizer({ tree }: { tree: AppTree }) {
return (
<Flex direction="column" style={{ height: '100%', flex: '1 1 100%' }}>
<Box ml="4" mt="4" style={{ flex: '1 1 0', overflow: 'auto' }}>
<Extension node={tree.root} depth={0} />
</Box>
export function DetailedVisualizer() {
const appTreeApi = useApi(appTreeApiRef);
const { tree } = appTreeApi.getTree();
<Box
m="2"
style={{
flex: '0 0 auto',
background: 'var(--bui-bg-neutral-1)',
border: '1px solid var(--bui-border-2)',
borderRadius: 'var(--bui-radius-2)',
}}
>
<Legend />
</Box>
</Flex>
return (
<FullPage>
<Flex direction="column" style={{ height: '100%', flex: '1 1 100%' }}>
<Box ml="4" mt="4" style={{ flex: '1 1 0', overflow: 'auto' }}>
<Extension node={tree.root} depth={0} />
</Box>
<Box
m="2"
style={{
flex: '0 0 auto',
background: 'var(--bui-bg-neutral-1)',
border: '1px solid var(--bui-border-2)',
borderRadius: 'var(--bui-radius-2)',
}}
>
<Legend />
</Box>
</Flex>
</FullPage>
);
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { AppNode, AppTree } from '@backstage/frontend-plugin-api';
import { AppNode, useApi, appTreeApiRef } from '@backstage/frontend-plugin-api';
import { Box, Checkbox } from '@backstage/ui';
import { ReactNode, useState } from 'react';
@@ -77,7 +77,9 @@ function nodeToText(
]);
}
export function TextVisualizer({ tree }: { tree: AppTree }) {
export function TextVisualizer() {
const appTreeApi = useApi(appTreeApiRef);
const { tree } = appTreeApi.getTree();
const [showOutputs, setShowOutputs] = useState(false);
const [showDisabled, setShowDisabled] = useState(false);
@@ -18,8 +18,13 @@ import {
DependencyGraph,
DependencyGraphTypes,
} from '@backstage/core-components';
import { AppNode, AppTree } from '@backstage/frontend-plugin-api';
import { Flex } from '@backstage/ui';
import {
AppNode,
AppTree,
useApi,
appTreeApiRef,
} from '@backstage/frontend-plugin-api';
import { Flex, FullPage } from '@backstage/ui';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
type NodeType =
@@ -137,28 +142,25 @@ export function Node(props: { node: NodeType }) {
);
}
export function TreeVisualizer({ tree }: { tree: AppTree }) {
export function TreeVisualizer() {
const appTreeApi = useApi(appTreeApiRef);
const { tree } = appTreeApi.getTree();
const graphData = useMemo(() => resolveGraphData(tree), [tree]);
return (
<Flex
style={{
flex: '1 1 0',
overflow: 'hidden',
justifyContent: 'stretch',
alignItems: 'stretch',
}}
>
<DependencyGraph
fit="contain"
{...graphData}
nodeMargin={10}
rankMargin={50}
paddingX={50}
renderNode={Node}
ranker={DependencyGraphTypes.Ranker.TIGHT_TREE}
direction={DependencyGraphTypes.Direction.LEFT_RIGHT}
/>
</Flex>
<FullPage>
<Flex style={{ height: '100%' }}>
<DependencyGraph
fit="contain"
{...graphData}
nodeMargin={10}
rankMargin={50}
paddingX={50}
renderNode={Node}
ranker={DependencyGraphTypes.Ranker.TIGHT_TREE}
direction={DependencyGraphTypes.Direction.LEFT_RIGHT}
/>
</Flex>
</FullPage>
);
}
@@ -0,0 +1,63 @@
/*
* Copyright 2025 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 { useState } from 'react';
import {
useApi,
appTreeApiRef,
type AppNode,
} from '@backstage/frontend-plugin-api';
import { Button } from '@backstage/ui';
import { RiFileCopyLine, RiCheckLine } from '@remixicon/react';
function nodeToJson(node: AppNode): object {
const attachments: Record<string, object[]> = {};
for (const [input, children] of node.edges.attachments) {
attachments[input] = children.map(nodeToJson);
}
return {
id: node.spec.id,
plugin: node.spec.plugin.pluginId,
disabled: node.spec.disabled || undefined,
...(Object.keys(attachments).length > 0 ? { attachments } : {}),
};
}
export function CopyTreeButton() {
const appTreeApi = useApi(appTreeApiRef);
const [copied, setCopied] = useState(false);
const handlePress = () => {
const { tree } = appTreeApi.getTree();
const json = JSON.stringify(nodeToJson(tree.root), null, 2);
window.navigator.clipboard.writeText(json).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<Button
variant="secondary"
size="small"
iconStart={copied ? <RiCheckLine /> : <RiFileCopyLine />}
onPress={handlePress}
>
{copied ? 'Copied!' : 'Copy as JSON'}
</Button>
);
}
+62 -5
View File
@@ -19,8 +19,10 @@ import {
createRouteRef,
NavItemBlueprint,
PageBlueprint,
PluginHeaderActionBlueprint,
SubPageBlueprint,
} from '@backstage/frontend-plugin-api';
import { RiEyeLine as VisualizerIcon } from '@remixicon/react';
import { RiEyeLine } from '@remixicon/react';
const rootRouteRef = createRouteRef();
@@ -28,17 +30,63 @@ const appVisualizerPage = PageBlueprint.make({
params: {
path: '/visualizer',
routeRef: rootRouteRef,
title: 'Visualizer',
},
});
const treeRouteRef = createRouteRef();
const detailedRouteRef = createRouteRef();
const textRouteRef = createRouteRef();
const appVisualizerTreePage = SubPageBlueprint.make({
name: 'tree',
params: {
path: 'tree',
routeRef: treeRouteRef,
title: 'Tree',
loader: () =>
import('./components/AppVisualizerPage').then(m => (
<m.AppVisualizerPage />
import('./components/AppVisualizerPage/TreeVisualizer').then(m => (
<m.TreeVisualizer />
)),
},
});
const appVisualizerDetailedPage = SubPageBlueprint.make({
name: 'details',
params: {
path: 'details',
routeRef: detailedRouteRef,
title: 'Detailed',
loader: () =>
import('./components/AppVisualizerPage/DetailedVisualizer').then(m => (
<m.DetailedVisualizer />
)),
},
});
const appVisualizerTextPage = SubPageBlueprint.make({
name: 'text',
params: {
path: 'text',
routeRef: textRouteRef,
title: 'Text',
loader: () =>
import('./components/AppVisualizerPage/TextVisualizer').then(m => (
<m.TextVisualizer />
)),
},
});
const copyTreeAsJson = PluginHeaderActionBlueprint.make({
params: defineParams =>
defineParams({
loader: () =>
import('./components/CopyTreeButton').then(m => <m.CopyTreeButton />),
}),
});
export const appVisualizerNavItem = NavItemBlueprint.make({
params: {
title: 'Visualizer',
icon: () => <VisualizerIcon />,
icon: () => <RiEyeLine />,
routeRef: rootRouteRef,
},
});
@@ -46,6 +94,15 @@ export const appVisualizerNavItem = NavItemBlueprint.make({
/** @public */
export const visualizerPlugin = createFrontendPlugin({
pluginId: 'app-visualizer',
title: 'App Visualizer',
icon: <RiEyeLine />,
info: { packageJson: () => import('../package.json') },
extensions: [appVisualizerPage, appVisualizerNavItem],
extensions: [
appVisualizerPage,
appVisualizerTreePage,
appVisualizerDetailedPage,
appVisualizerTextPage,
appVisualizerNavItem,
copyTreeAsJson,
],
});
+1
View File
@@ -59,6 +59,7 @@
"@backstage/plugin-permission-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/ui": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
+82 -1
View File
@@ -14,6 +14,7 @@ import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { NavContentComponent } from '@backstage/plugin-app-react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
@@ -476,7 +477,7 @@ const appPlugin: OverridableFrontendPlugin<
icons: ExtensionInput<
ConfigurableExtensionDataRef<
{
[x: string]: IconComponent;
[x: string]: IconComponent | IconElement;
},
'core.icons',
{}
@@ -588,6 +589,30 @@ const appPlugin: OverridableFrontendPlugin<
params: ApiFactory<TApi, TImpl, TDeps>,
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
'api:app/plugin-header-actions': OverridableExtensionDefinition<{
config: {};
configInput: {};
output: ExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>;
inputs: {
actions: ExtensionInput<
ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
kind: 'api';
name: 'plugin-header-actions';
params: <
TApi,
TImpl extends TApi,
TDeps extends { [name in string]: unknown },
>(
params: ApiFactory<TApi, TImpl, TDeps>,
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
'api:app/plugin-wrapper': OverridableExtensionDefinition<{
config: {};
configInput: {};
@@ -907,6 +932,62 @@ const appPlugin: OverridableFrontendPlugin<
: never;
}>;
}>;
'component:app/core-page-layout': OverridableExtensionDefinition<{
kind: 'component';
name: 'core-page-layout';
config: {};
configInput: {};
output: ExtensionDataRef<
{
ref: SwappableComponentRef;
loader:
| (() => (props: {}) => JSX.Element | null)
| (() => Promise<(props: {}) => JSX.Element | null>);
},
'core.swappableComponent',
{}
>;
inputs: {};
params: <Ref extends SwappableComponentRef<any>>(params: {
component: Ref extends SwappableComponentRef<
any,
infer IExternalComponentProps
>
? {
ref: Ref;
} & ((props: IExternalComponentProps) => JSX.Element | null)
: never;
loader: Ref extends SwappableComponentRef<
infer IInnerComponentProps,
any
>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<
(props: IInnerComponentProps) => JSX.Element | null
>)
: never;
}) => ExtensionBlueprintParams<{
component: Ref extends SwappableComponentRef<
any,
infer IExternalComponentProps
>
? {
ref: Ref;
} & ((props: IExternalComponentProps) => JSX.Element | null)
: never;
loader: Ref extends SwappableComponentRef<
infer IInnerComponentProps,
any
>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<
(props: IInnerComponentProps) => JSX.Element | null
>)
: never;
}>;
}>;
'component:app/core-progress': OverridableExtensionDefinition<{
kind: 'component';
name: 'core-progress';
@@ -0,0 +1,72 @@
/*
* Copyright 2025 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 { render, screen } from '@testing-library/react';
import { DefaultPluginHeaderActionsApi } from './DefaultPluginHeaderActionsApi';
describe('DefaultPluginHeaderActionsApi', () => {
it('should return actions for a specific plugin', () => {
const api = DefaultPluginHeaderActionsApi.fromActions([
{
element: <button>Action A</button>,
pluginId: 'plugin-a',
},
{
element: <button>Action B</button>,
pluginId: 'plugin-b',
},
]);
expect(api.getPluginHeaderActions('plugin-a')).toHaveLength(1);
expect(api.getPluginHeaderActions('plugin-b')).toHaveLength(1);
render(<>{api.getPluginHeaderActions('plugin-a')}</>);
expect(
screen.getByRole('button', { name: 'Action A' }),
).toBeInTheDocument();
});
it('should return an empty array for unknown plugins', () => {
const api = DefaultPluginHeaderActionsApi.fromActions([
{
element: <span>Action</span>,
pluginId: 'plugin-a',
},
]);
expect(api.getPluginHeaderActions('unknown-plugin')).toEqual([]);
});
it('should group multiple actions by plugin', () => {
const api = DefaultPluginHeaderActionsApi.fromActions([
{
element: <button>First</button>,
pluginId: 'plugin-a',
},
{
element: <button>Second</button>,
pluginId: 'plugin-a',
},
]);
const actions = api.getPluginHeaderActions('plugin-a');
expect(actions).toHaveLength(2);
render(<>{actions}</>);
expect(screen.getByRole('button', { name: 'First' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Second' })).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
/*
* Copyright 2026 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 { JSX } from 'react';
import { type PluginHeaderActionsApi } from '@backstage/frontend-plugin-api';
// Stable reference
const EMPTY_ACTIONS = new Array<JSX.Element | null>();
type ActionInput = {
element: JSX.Element;
pluginId: string;
};
/**
* Default implementation of PluginHeaderActionsApi.
*
* @internal
*/
export class DefaultPluginHeaderActionsApi implements PluginHeaderActionsApi {
constructor(
private readonly actionsByPlugin: Map<string, Array<JSX.Element | null>>,
) {}
getPluginHeaderActions(pluginId: string): Array<JSX.Element | null> {
return this.actionsByPlugin.get(pluginId) ?? EMPTY_ACTIONS;
}
static fromActions(
actions: Array<ActionInput>,
): DefaultPluginHeaderActionsApi {
const actionsByPlugin = new Map<string, Array<JSX.Element | null>>();
for (const action of actions) {
let pluginActions = actionsByPlugin.get(action.pluginId);
if (!pluginActions) {
pluginActions = [];
actionsByPlugin.set(action.pluginId, pluginActions);
}
pluginActions.push(action.element);
}
return new DefaultPluginHeaderActionsApi(actionsByPlugin);
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2025 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.
*/
export { DefaultPluginHeaderActionsApi } from './DefaultPluginHeaderActionsApi';
+150 -20
View File
@@ -20,55 +20,117 @@ import {
createExtensionInput,
NavItemBlueprint,
routeResolutionApiRef,
appTreeApiRef,
IconComponent,
IconElement,
RouteRef,
RouteResolutionApi,
useApi,
} from '@backstage/frontend-plugin-api';
import {
NavContentBlueprint,
NavContentComponent,
NavContentComponentProps,
NavContentNavItem,
NavContentNavItems,
} from '@backstage/plugin-app-react';
import { Sidebar, SidebarItem } from '@backstage/core-components';
import { useMemo } from 'react';
class NavItemBag implements NavContentNavItems {
readonly #items: NavContentNavItem[];
readonly #index: Map<string, NavContentNavItem>;
readonly #taken: Set<string>;
constructor(items: NavContentNavItem[], taken?: Iterable<string>) {
this.#items = items;
this.#index = new Map(items.map(item => [item.node.spec.id, item]));
this.#taken = new Set(taken);
}
take(id: string): NavContentNavItem | undefined {
const item = this.#index.get(id);
if (item) {
this.#taken.add(id);
}
return item;
}
rest(): NavContentNavItem[] {
return this.#items.filter(item => !this.#taken.has(item.node.spec.id));
}
clone(): NavContentNavItems {
return new NavItemBag(this.#items, this.#taken);
}
withComponent(Component: (props: NavContentNavItem) => JSX.Element) {
return {
take: (id: string) => {
const item = this.take(id);
return item ? <Component {...item} /> : null;
},
rest: (options?: { sortBy?: 'title' }) => {
const items = this.rest();
if (options?.sortBy === 'title') {
items.sort((a, b) => a.title.localeCompare(b.title));
}
return items.map(item => (
<Component key={item.node.spec.id} {...item} />
));
},
};
}
}
function DefaultNavContent(props: NavContentComponentProps) {
const items = props.navItems.rest();
return (
<Sidebar>
{props.items.map((item, index) => (
{items.map(item => (
<SidebarItem
to={item.to}
icon={item.icon}
text={item.text}
key={index}
to={item.href}
icon={() => item.icon}
text={item.title}
key={item.node.spec.id}
/>
))}
</Sidebar>
);
}
// This helps defer rendering until the app is being rendered, which is needed
// because the RouteResolutionApi can't be called until the app has been fully initialized.
// Tries to resolve a routeRef to a link path, returning undefined if it
// can't be resolved (e.g. parameterized routes).
function tryResolveLink(
routeResolutionApi: RouteResolutionApi,
routeRef: RouteRef,
): string | undefined {
try {
const link = routeResolutionApi.resolve(routeRef);
return link?.();
} catch {
return undefined;
}
}
// Defers rendering until the app is fully initialized so that APIs like
// RouteResolutionApi and AppTreeApi are available.
function NavContentRenderer(props: {
Content: NavContentComponent;
items: Array<{
legacyNavItems: Array<{
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
}>;
}) {
const appTreeApi = useApi(appTreeApiRef);
const routeResolutionApi = useApi(routeResolutionApiRef);
const items = useMemo(() => {
return props.items.flatMap(item => {
// Deprecated items: just resolve nav item routeRefs to paths, no page discovery.
const legacyItems = useMemo(() => {
return props.legacyNavItems.flatMap(item => {
const link = routeResolutionApi.resolve(item.routeRef);
if (!link) {
// eslint-disable-next-line no-console
console.warn(
`NavItemBlueprint: unable to resolve route ref ${item.routeRef}`,
);
return [];
}
if (!link) return [];
return [
{
to: link(),
@@ -79,9 +141,77 @@ function NavContentRenderer(props: {
},
];
});
}, [props.items, routeResolutionApi]);
}, [props.legacyNavItems, routeResolutionApi]);
return <props.Content items={items} />;
// New navItems: discover pages from the extension tree, merged with nav items.
const navItems = useMemo(() => {
const { tree } = appTreeApi.getTree();
const routesNode = tree.nodes.get('app/routes');
if (!routesNode) return new NavItemBag([]);
// Index nav items by routeRef for matching against pages
const navItemsByRouteRef = new Map<
RouteRef,
{ title: string; icon: IconComponent }
>(props.legacyNavItems.map(item => [item.routeRef, item]));
const pageNodes = routesNode.edges.attachments.get('routes') ?? [];
const items = pageNodes.flatMap((node): NavContentNavItem[] => {
if (!node.instance || node.spec.disabled) {
return [];
}
const routeRef = node.instance.getData(coreExtensionData.routeRef);
if (!routeRef) {
return [];
}
const matchingNavItem = navItemsByRouteRef.get(routeRef);
// PageBlueprint resolves title as: config.title ?? params.title ?? plugin.title ?? pluginId
// We want the priority: page (config/params) -> nav item -> plugin -> pluginId
const resolvedTitle = node.instance.getData(coreExtensionData.title);
const pluginTitle = node.spec.plugin.title;
const pluginId = node.spec.plugin.pluginId;
const hasExplicitPageTitle =
resolvedTitle !== undefined &&
resolvedTitle !== pluginTitle &&
resolvedTitle !== pluginId;
const title = hasExplicitPageTitle
? resolvedTitle
: matchingNavItem?.title ?? pluginTitle ?? pluginId;
// PageBlueprint resolves icon as: params.icon ?? plugin.icon
// We want the priority: page (params) -> nav item -> plugin -> (excluded)
const resolvedIcon = node.instance.getData(coreExtensionData.icon);
const hasExplicitPageIcon = resolvedIcon && !node.spec.plugin.icon;
const NavItemIcon = matchingNavItem?.icon;
let icon: IconElement | undefined;
if (hasExplicitPageIcon) {
icon = resolvedIcon;
} else if (NavItemIcon) {
icon = <NavItemIcon />;
} else if (resolvedIcon) {
icon = resolvedIcon;
}
if (!title || !icon) {
return [];
}
const to = tryResolveLink(routeResolutionApi, routeRef);
if (!to) {
return [];
}
return [{ node, href: to, title, icon, routeRef }];
});
return new NavItemBag(items);
}, [appTreeApi, routeResolutionApi, props.legacyNavItems]);
return <props.Content navItems={navItems} items={legacyItems} />;
}
export const AppNav = createExtension({
@@ -103,7 +233,7 @@ export const AppNav = createExtension({
yield coreExtensionData.reactElement(
<NavContentRenderer
items={inputs.items.map(item =>
legacyNavItems={inputs.items.map(item =>
item.get(NavItemBlueprint.dataRefs.target),
)}
Content={Content}
@@ -0,0 +1,49 @@
/*
* Copyright 2025 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,
pluginHeaderActionsApiRef,
createExtensionInput,
ApiBlueprint,
} from '@backstage/frontend-plugin-api';
import { DefaultPluginHeaderActionsApi } from '../apis/PluginHeaderActionsApi';
/**
* Contains the plugin-scoped header actions installed into the app.
*/
export const PluginHeaderActionsApi = ApiBlueprint.makeWithOverrides({
name: 'plugin-header-actions',
inputs: {
actions: createExtensionInput([coreExtensionData.reactElement]),
},
factory: (originalFactory, { inputs }) => {
return originalFactory(defineParams =>
defineParams({
api: pluginHeaderActionsApiRef,
deps: {},
factory: () => {
return DefaultPluginHeaderActionsApi.fromActions(
inputs.actions.map(actionInput => ({
element: actionInput.get(coreExtensionData.reactElement),
pluginId: actionInput.node.spec.plugin.pluginId,
})),
);
},
}),
);
},
});
+40
View File
@@ -17,6 +17,8 @@ import {
NotFoundErrorPage as SwappableNotFoundErrorPage,
Progress as SwappableProgress,
ErrorDisplay as SwappableErrorDisplay,
PageLayout as SwappablePageLayout,
type PageLayoutProps,
} from '@backstage/frontend-plugin-api';
import { SwappableComponentBlueprint } from '@backstage/plugin-app-react';
import {
@@ -24,7 +26,9 @@ import {
ErrorPanel,
Progress as ProgressComponent,
} from '@backstage/core-components';
import { PluginHeader } from '@backstage/ui';
import Button from '@material-ui/core/Button';
import { useMemo } from 'react';
export const Progress = SwappableComponentBlueprint.make({
name: 'core-progress',
@@ -63,3 +67,39 @@ export const ErrorDisplay = SwappableComponentBlueprint.make({
},
}),
});
export const PageLayout = SwappableComponentBlueprint.make({
name: 'core-page-layout',
params: define =>
define({
component: SwappablePageLayout,
loader: () => (props: PageLayoutProps) => {
const { title, icon, noHeader, headerActions, tabs, children } = props;
const tabsWithMatchStrategy = useMemo(
() =>
tabs?.map(tab => ({
...tab,
matchStrategy: 'prefix' as const,
})),
[tabs],
);
if (tabsWithMatchStrategy) {
return (
<>
{!noHeader && (
<PluginHeader
title={title}
icon={icon}
tabs={tabsWithMatchStrategy}
customActions={headerActions}
/>
)}
{children}
</>
);
}
return <>{children}</>;
},
}),
});
+7 -1
View File
@@ -31,5 +31,11 @@ export {
oauthRequestDialogAppRootElement,
alertDisplayAppRootElement,
} from './elements';
export { Progress, NotFoundErrorPage, ErrorDisplay } from './components';
export {
Progress,
NotFoundErrorPage,
ErrorDisplay,
PageLayout,
} from './components';
export { PluginWrapperApi } from './PluginWrapperApi';
export { PluginHeaderActionsApi } from './PluginHeaderActionsApi';
+4
View File
@@ -29,6 +29,7 @@ import {
IconsApi,
FeatureFlagsApi,
PluginWrapperApi,
PluginHeaderActionsApi,
TranslationsApi,
oauthRequestDialogAppRootElement,
alertDisplayAppRootElement,
@@ -37,6 +38,7 @@ import {
Progress,
NotFoundErrorPage,
ErrorDisplay,
PageLayout,
LegacyComponentsApi,
} from './extensions';
import { apis } from './defaultApis';
@@ -60,6 +62,7 @@ export const appPlugin = createFrontendPlugin({
IconsApi,
FeatureFlagsApi,
PluginWrapperApi,
PluginHeaderActionsApi,
TranslationsApi,
DefaultSignInPage,
oauthRequestDialogAppRootElement,
@@ -68,6 +71,7 @@ export const appPlugin = createFrontendPlugin({
Progress,
NotFoundErrorPage,
ErrorDisplay,
PageLayout,
LegacyComponentsApi,
],
});
+56 -3
View File
@@ -4,7 +4,10 @@
```ts
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -22,26 +25,76 @@ const _default: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef;
noHeader?: boolean;
};
}>;
}
+56 -3
View File
@@ -6,12 +6,15 @@
import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityCardType } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -157,6 +160,7 @@ const _default: OverridableFrontendPlugin<
relationPairs: [string, string][] | undefined;
zoom: 'disabled' | 'enabled' | 'enable-on-click' | undefined;
path: string | undefined;
title: string | undefined;
};
configInput: {
curve?: 'curveStepBefore' | 'curveMonotoneX' | undefined;
@@ -172,26 +176,75 @@ const _default: OverridableFrontendPlugin<
selectedRelations?: string[] | undefined;
selectedKinds?: string[] | undefined;
showFilters?: boolean | undefined;
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
kind: 'page';
name: undefined;
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
}
+56 -3
View File
@@ -6,8 +6,11 @@
import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -115,26 +118,76 @@ const _default: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
}
+1 -1
View File
@@ -258,7 +258,6 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
@@ -266,6 +265,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
@@ -6,10 +6,13 @@
import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { DevToolsContentBlueprintParams } from '@backstage/plugin-devtools-react';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -64,26 +67,76 @@ const _default: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
}
@@ -104,7 +157,6 @@ export const unprocessedEntitiesDevToolsContent: OverridableExtensionDefinition<
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
@@ -112,6 +164,7 @@ export const unprocessedEntitiesDevToolsContent: OverridableExtensionDefinition<
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>;
inputs: {};
params: DevToolsContentBlueprintParams;
@@ -68,6 +68,8 @@ export const catalogUnprocessedEntitiesNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'catalog-unprocessed-entities',
title: 'Unprocessed Entities',
icon: <QueueIcon />,
info: { packageJson: () => import('../../package.json') },
routes: {
root: rootRouteRef,
+104 -5
View File
@@ -18,6 +18,7 @@ import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { IconLinkVerticalProps } from '@backstage/core-components';
import { JSX as JSX_2 } from 'react';
import { JSXElementConstructor } from 'react';
@@ -756,7 +757,6 @@ const _default: OverridableFrontendPlugin<
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
@@ -764,6 +764,7 @@ const _default: OverridableFrontendPlugin<
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
@@ -998,6 +999,7 @@ const _default: OverridableFrontendPlugin<
limit?: number | undefined;
};
path: string | undefined;
title: string | undefined;
};
configInput: {
pagination?:
@@ -1008,19 +1010,64 @@ const _default: OverridableFrontendPlugin<
limit?: number | undefined;
}
| undefined;
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
filters: ExtensionInput<
ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>,
{
@@ -1035,8 +1082,11 @@ const _default: OverridableFrontendPlugin<
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
'page:catalog/entity': OverridableExtensionDefinition<{
@@ -1052,6 +1102,7 @@ const _default: OverridableFrontendPlugin<
| undefined;
showNavItemIcons: boolean;
path: string | undefined;
title: string | undefined;
};
configInput: {
groups?:
@@ -1064,19 +1115,64 @@ const _default: OverridableFrontendPlugin<
>[]
| undefined;
showNavItemIcons?: boolean | undefined;
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
headers: ExtensionInput<
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
@@ -1168,8 +1264,11 @@ const _default: OverridableFrontendPlugin<
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
'search-result-list-item:catalog': OverridableExtensionDefinition<{
+4
View File
@@ -31,6 +31,7 @@ import {
EntityHeaderBlueprint,
EntityContentGroupDefinitions,
} from '@backstage/plugin-catalog-react/alpha';
import CategoryIcon from '@material-ui/icons/Category';
import { rootRouteRef } from '../routes';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
import { buildFilterFn } from './filter/FilterWrapper';
@@ -58,6 +59,8 @@ export const catalogPage = PageBlueprint.makeWithOverrides({
return originalFactory({
path: '/catalog',
routeRef: rootRouteRef,
icon: <CategoryIcon />,
title: 'Catalog',
loader: async () => {
const { BaseCatalogPage } = await import('../components/CatalogPage');
const filters = inputs.filters.map(filter =>
@@ -116,6 +119,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
factory(originalFactory, { config, inputs }) {
return originalFactory({
path: '/catalog/:namespace/:kind/:name',
title: 'Catalog Entity',
// NOTE: The `convertLegacyRouteRef` call here ensures that this route ref
// is mutated to support the new frontend system. Removing this conversion
// is a potentially breaking change since this is a singleton and the
+6 -2
View File
@@ -15,8 +15,8 @@
*/
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import CategoryIcon from '@material-ui/icons/Category';
import {
createComponentRouteRef,
@@ -39,7 +39,11 @@ import contextMenuItems from './contextMenuItems';
/** @alpha */
export default createFrontendPlugin({
pluginId: 'catalog',
info: { packageJson: () => import('../../package.json') },
title: 'Catalog',
icon: <CategoryIcon />,
info: {
packageJson: () => import('../../package.json'),
},
routes: {
catalogIndex: rootRouteRef,
catalogEntity: entityRouteRef,
+1 -1
View File
@@ -15,7 +15,6 @@ export const DevToolsContentBlueprint: ExtensionBlueprint<{
params: DevToolsContentBlueprintParams;
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
@@ -23,6 +22,7 @@ export const DevToolsContentBlueprint: ExtensionBlueprint<{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<string, 'core.title', {}>;
inputs: {};
config: {
+52 -2
View File
@@ -11,6 +11,7 @@ import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -63,21 +64,67 @@ const _default: OverridableFrontendPlugin<
'page:devtools': OverridableExtensionDefinition<{
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
contents: ExtensionInput<
| ConfigurableExtensionDataRef<string, 'core.title', {}>
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
@@ -101,8 +148,11 @@ const _default: OverridableFrontendPlugin<
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
}
+2
View File
@@ -88,6 +88,8 @@ export const devToolsNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'devtools',
title: 'DevTools',
icon: <BuildIcon />,
info: { packageJson: () => import('../../package.json') },
routes: {
root: rootRouteRef,
+52 -2
View File
@@ -14,6 +14,7 @@ import { HomePageLayoutProps } from '@backstage/plugin-home-react/alpha';
import { HomePageWidgetBlueprintParams } from '@backstage/plugin-home-react/alpha';
import { HomePageWidgetData } from '@backstage/plugin-home-react/alpha';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -104,21 +105,67 @@ const _default: OverridableFrontendPlugin<
'page:home': OverridableExtensionDefinition<{
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
widgets: ExtensionInput<
ConfigurableExtensionDataRef<
HomePageWidgetData,
@@ -149,8 +196,11 @@ const _default: OverridableFrontendPlugin<
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef;
noHeader?: boolean;
};
}>;
}
+3
View File
@@ -63,6 +63,7 @@ const homePage = PageBlueprint.makeWithOverrides({
factory(originalFactory, { node, inputs }) {
return originalFactory({
path: '/home',
noHeader: true,
routeRef: rootRouteRef,
loader: async () => {
const LazyDefaultLayout = reactLazy(() =>
@@ -207,6 +208,8 @@ const homePageRandomJokeWidget = HomePageWidgetBlueprint.make({
*/
export default createFrontendPlugin({
pluginId: 'home',
title: 'Home',
icon: <HomeIcon />,
info: { packageJson: () => import('../package.json') },
extensions: [
homePage,
+57 -4
View File
@@ -6,11 +6,14 @@
import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { defaultEntityContentGroups } from '@backstage/plugin-catalog-react/alpha';
import { Entity } from '@backstage/catalog-model';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { JSXElementConstructor } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
@@ -106,7 +109,6 @@ const _default: OverridableFrontendPlugin<
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
@@ -114,6 +116,7 @@ const _default: OverridableFrontendPlugin<
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
@@ -162,26 +165,76 @@ const _default: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
}
+56 -3
View File
@@ -5,7 +5,10 @@
```ts
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { JSX as JSX_3 } from 'react/jsx-runtime';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
@@ -36,26 +39,76 @@ const _default: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
}
+56 -3
View File
@@ -6,8 +6,11 @@
import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -42,26 +45,76 @@ const _default: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
}
+52 -2
View File
@@ -20,6 +20,7 @@ import { FormField } from '@backstage/plugin-scaffolder-react/alpha';
import type { FormProps as FormProps_2 } from '@rjsf/core';
import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { IconLinkVerticalProps } from '@backstage/core-components';
import { JSX as JSX_2 } from 'react';
import { LayoutOptions } from '@backstage/plugin-scaffolder-react';
@@ -194,21 +195,67 @@ const _default: OverridableFrontendPlugin<
'page:scaffolder': OverridableExtensionDefinition<{
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
formFields: ExtensionInput<
ConfigurableExtensionDataRef<
() => Promise<FormField>,
@@ -227,8 +274,11 @@ const _default: OverridableFrontendPlugin<
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
'scaffolder-form-field:scaffolder/entity-name-picker': OverridableExtensionDefinition<{
+3
View File
@@ -15,6 +15,7 @@
*/
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import {
actionsRouteRef,
editRouteRef,
@@ -59,6 +60,8 @@ const scaffolderEntityIconLink = EntityIconLinkBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'scaffolder',
title: 'Create',
icon: <CreateComponentIcon />,
info: { packageJson: () => import('../../package.json') },
routes: {
root: rootRouteRef,
+103 -4
View File
@@ -11,6 +11,7 @@ import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -68,22 +69,68 @@ const _default: OverridableFrontendPlugin<
config: {
noTrack: boolean;
path: string | undefined;
title: string | undefined;
};
configInput: {
noTrack?: boolean | undefined;
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
items: ExtensionInput<
ConfigurableExtensionDataRef<
{
@@ -136,8 +183,11 @@ const _default: OverridableFrontendPlugin<
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef;
noHeader?: boolean;
};
}>;
}
@@ -189,22 +239,68 @@ export const searchPage: OverridableExtensionDefinition<{
config: {
noTrack: boolean;
path: string | undefined;
title: string | undefined;
};
configInput: {
noTrack?: boolean | undefined;
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
items: ExtensionInput<
ConfigurableExtensionDataRef<
{
@@ -257,8 +353,11 @@ export const searchPage: OverridableExtensionDefinition<{
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef;
noHeader?: boolean;
};
}>;
+2
View File
@@ -276,6 +276,8 @@ export const searchNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'search',
title: 'Search',
icon: <SearchIcon />,
info: { packageJson: () => import('../package.json') },
extensions: [searchApi, searchPage, searchNavItem],
routes: {
+106 -6
View File
@@ -14,6 +14,7 @@ import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { IconLinkVerticalProps } from '@backstage/core-components';
import { JSX as JSX_2 } from 'react';
import { JSXElementConstructor } from 'react';
@@ -139,7 +140,6 @@ const _default: OverridableFrontendPlugin<
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
@@ -147,6 +147,7 @@ const _default: OverridableFrontendPlugin<
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
@@ -284,26 +285,76 @@ const _default: OverridableFrontendPlugin<
name: undefined;
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {};
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
'page:techdocs/reader': OverridableExtensionDefinition<{
@@ -311,23 +362,69 @@ const _default: OverridableFrontendPlugin<
withoutSearch: boolean;
withoutHeader: boolean;
path: string | undefined;
title: string | undefined;
};
configInput: {
withoutSearch?: boolean | undefined;
withoutHeader?: boolean | undefined;
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef_2<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
addons: ExtensionInput<
ConfigurableExtensionDataRef<
TechDocsAddonOptions,
@@ -346,8 +443,11 @@ const _default: OverridableFrontendPlugin<
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef_2;
noHeader?: boolean;
};
}>;
'search-result-list-item:techdocs': OverridableExtensionDefinition<{
+2
View File
@@ -278,6 +278,8 @@ const techDocsNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'techdocs',
title: 'Docs',
icon: <LibraryBooks />,
info: { packageJson: () => import('../../package.json') },
extensions: [
techDocsClientApi,
+52 -2
View File
@@ -8,6 +8,7 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { IconElement } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -46,21 +47,67 @@ const _default: OverridableFrontendPlugin<
'page:user-settings': OverridableExtensionDefinition<{
config: {
path: string | undefined;
title: string | undefined;
};
configInput: {
title?: string | undefined;
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>;
inputs: {
pages: ExtensionInput<
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ConfigurableExtensionDataRef<string, 'core.routing.path', {}>
| ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'core.title',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
IconElement,
'core.icon',
{
optional: true;
}
>,
{
singleton: false;
optional: false;
internal: false;
}
>;
providerSettings: ExtensionInput<
ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>,
{
@@ -75,8 +122,11 @@ const _default: OverridableFrontendPlugin<
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
title?: string;
icon?: IconElement;
loader?: () => Promise<JSX_2.Element>;
routeRef?: RouteRef;
noHeader?: boolean;
};
}>;
}
+2
View File
@@ -62,6 +62,8 @@ export const settingsNavItem = NavItemBlueprint.make({
*/
export default createFrontendPlugin({
pluginId: 'user-settings',
title: 'Settings',
icon: <SettingsIcon />,
info: { packageJson: () => import('../package.json') },
extensions: [userSettingsPage, settingsNavItem],
routes: {
+1
View File
@@ -4297,6 +4297,7 @@ __metadata:
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/ui": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@material-ui/core": "npm:^4.9.13"
"@material-ui/icons": "npm:^4.9.1"