Merge pull request #32812 from backstage/mob/header
frontend-plugin-api: new system for page extensions
This commit is contained in:
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Updated the app template sidebar to use the new `NavContentBlueprint` API for page-based navigation.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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}</>;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user