diff --git a/.changeset/app-visualizer-subpages.md b/.changeset/app-visualizer-subpages.md new file mode 100644 index 0000000000..164dcaa126 --- /dev/null +++ b/.changeset/app-visualizer-subpages.md @@ -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`. diff --git a/.changeset/create-app-nav-sidebar.md b/.changeset/create-app-nav-sidebar.md new file mode 100644 index 0000000000..30697e4a0f --- /dev/null +++ b/.changeset/create-app-nav-sidebar.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Updated the app template sidebar to use the new `NavContentBlueprint` API for page-based navigation. diff --git a/.changeset/icon-element-migration.md b/.changeset/icon-element-migration.md new file mode 100644 index 0000000000..9c751bc6f3 --- /dev/null +++ b/.changeset/icon-element-migration.md @@ -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. diff --git a/.changeset/nav-items-page-discovery.md b/.changeset/nav-items-page-discovery.md new file mode 100644 index 0000000000..d1f60b342b --- /dev/null +++ b/.changeset/nav-items-page-discovery.md @@ -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`. diff --git a/.changeset/page-layout-and-header-actions.md b/.changeset/page-layout-and-header-actions.md new file mode 100644 index 0000000000..19cee39a3e --- /dev/null +++ b/.changeset/page-layout-and-header-actions.md @@ -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`. diff --git a/.changeset/plugin-title-and-icon.md b/.changeset/plugin-title-and-icon.md new file mode 100644 index 0000000000..72aac96435 --- /dev/null +++ b/.changeset/plugin-title-and-icon.md @@ -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. diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 779cc00cf7..2c4b234fb2 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -422,6 +422,7 @@ SCM SCMs scrollable scrollbar +scrollbars sdks seb semlas diff --git a/docs/frontend-system/architecture/15-plugins.md b/docs/frontend-system/architecture/15-plugins.md index 0918961c73..c2f5f237c3 100644 --- a/docs/frontend-system/architecture/15-plugins.md +++ b/docs/frontend-system/architecture/15-plugins.md @@ -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: , + 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. diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index 3ac5109fc7..2849b7d68b 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -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 }) => ( - - - } to="/search"> - - - - }> - ... - - - - {/* Items in this group will be scrollable if they run out of space */} - {items.map((item, index) => ( - - ))} - - - - ), + component: ({ navItems }) => { + const nav = navItems.withComponent(item => ( + item.icon} to={item.href} text={item.title} /> + )); + + return ( + + + } to="/search"> + + + + }> + {nav.take('page:catalog')} + {nav.take('page:scaffolder')} + + + {nav.rest({ sortBy: 'title' })} + + + + ); + }, }, }); ``` -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 ``) 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 `` element. In a small app they might look something like this: diff --git a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md index 3bb613848f..5a3fb530a4 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -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. diff --git a/docs/frontend-system/building-plugins/04-built-in-data-refs.md b/docs/frontend-system/building-plugins/04-built-in-data-refs.md index c01520d7bd..cb16c4975c 100644 --- a/docs/frontend-system/building-plugins/04-built-in-data-refs.md +++ b/docs/frontend-system/building-plugins/04-built-in-data-refs.md @@ -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 | diff --git a/packages/app-example-plugin/report.api.md b/packages/app-example-plugin/report.api.md index 028795ed72..70b3446a2b 100644 --- a/packages/app-example-plugin/report.api.md +++ b/packages/app-example-plugin/report.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef; + noHeader?: boolean; }; }>; } diff --git a/packages/app/src/modules/appModuleNav.tsx b/packages/app/src/modules/appModuleNav.tsx index 9da6358dd3..a507679c73 100644 --- a/packages/app/src/modules/appModuleNav.tsx +++ b/packages/app/src/modules/appModuleNav.tsx @@ -103,34 +103,45 @@ export const appModuleNav = createFrontendModule({ extensions: [ NavContentBlueprint.make({ params: { - component: ({ items }) => ( - - - } to="/search"> - - - - }> - - {items.map((item, index) => ( - - ))} - - - - - - } - to="/settings" - > - - - - - - ), + component: ({ navItems }) => { + const nav = navItems.withComponent(item => ( + item.icon} + to={item.href} + text={item.title} + /> + )); + nav.take('page:home'); // Skip home + return ( + + + } to="/search"> + + + + }> + {nav.take('page:catalog')} + {nav.take('page:scaffolder')} + + + {nav.rest({ sortBy: 'title' })} + + + + + + } + to="/settings" + > + + + + + + ); + }, }, }), ], diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx index 6464032673..1ca118eb88 100644 --- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx @@ -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); } diff --git a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx index 5ded572ca2..f8436e7434 100644 --- a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx +++ b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx @@ -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], diff --git a/packages/core-plugin-api/src/app/useApp.test.tsx b/packages/core-plugin-api/src/app/useApp.test.tsx index 2cee7f6f3c..7888df0464 100644 --- a/packages/core-plugin-api/src/app/useApp.test.tsx +++ b/packages/core-plugin-api/src/app/useApp.test.tsx @@ -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, ), diff --git a/packages/create-app/templates/next-app/packages/app/src/modules/nav/Sidebar.tsx b/packages/create-app/templates/next-app/packages/app/src/modules/nav/Sidebar.tsx index 9b7cd7a7e4..d436252edf 100644 --- a/packages/create-app/templates/next-app/packages/app/src/modules/nav/Sidebar.tsx +++ b/packages/create-app/templates/next-app/packages/app/src/modules/nav/Sidebar.tsx @@ -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 => ( + item.icon} + to={item.href} + text={item.title} + /> + )); + return compatWrapper( } to="/search"> @@ -28,20 +33,11 @@ export const SidebarContent = NavContentBlueprint.make({ }> - {/* Global nav, not org-specific */} - - - {/* End global nav */} + {nav.take('page:catalog')} + {nav.take('page:scaffolder')} - {/* Items in this group will be scrollable if they run out of space */} - {items.map((item, index) => ( - - ))} + {nav.rest({ sortBy: 'title' })} @@ -56,6 +52,7 @@ export const SidebarContent = NavContentBlueprint.make({ , - ), + ); + }, }, }); diff --git a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts new file mode 100644 index 0000000000..cfe6236b95 --- /dev/null +++ b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts @@ -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')); + }); +}); diff --git a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts index 53a7fa6421..cc5ef69b6f 100644 --- a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts +++ b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts @@ -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; + #icons: Map; + #components = new Map(); - 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 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[] { diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 3a9ea91bec..55ca0b3772 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -388,11 +388,13 @@ describe('createApp', () => { + ] + ] diff --git a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts index 3e964af5ca..4564be2bb1 100644 --- a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts +++ b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts @@ -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[]; readonly featureFlags: FeatureFlagConfig[]; readonly infoOptions?: { diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 89fc72992a..6ba548af19 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -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; export const AnalyticsContext: (options: { attributes: Partial; 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; + output: ExtensionDataRef_2; inputs: {}; config: {}; configInput: {}; @@ -388,8 +389,9 @@ export interface ConfigurableExtensionDataRef< // @public (undocumented) export const coreExtensionData: { title: ConfigurableExtensionDataRef_2; + icon: ConfigurableExtensionDataRef_2; reactElement: ConfigurableExtensionDataRef_2< - JSX_3.Element, + JSX_2.Element, 'core.reactElement', {} >; @@ -1101,7 +1103,7 @@ export type ExtensionBlueprintParams = { }; // @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; 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; // (undocumented) withOverrides(options: { - extensions: Array; + extensions?: Array; + title?: string; + icon?: IconElement; info?: FrontendPluginInfoOptions; }): OverridableFrontendPlugin; } @@ -1710,29 +1720,113 @@ export const PageBlueprint: ExtensionBlueprint_2<{ params: { defaultPath?: [Error: `Use the 'path' param instead`]; path: string; - loader: () => Promise; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef; + noHeader?: boolean; }; output: | ExtensionDataRef_2 - | ExtensionDataRef_2 | ExtensionDataRef_2< RouteRef, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef_2 + | ExtensionDataRef_2< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef_2< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput_2< + | ConfigurableExtensionDataRef_2 + | ConfigurableExtensionDataRef_2 + | ConfigurableExtensionDataRef_2< + RouteRef, + '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; +}; + +// @public +export interface PageLayoutProps { + // (undocumented) + children?: ReactNode; + // (undocumented) + headerActions?: Array; + // (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; }; +// @public +export const PluginHeaderActionBlueprint: ExtensionBlueprint_2<{ + kind: 'plugin-header-action'; + params: (params: { + loader: () => Promise; + }) => ExtensionBlueprintParams_2<{ + loader: () => Promise; + }>; + output: ExtensionDataRef_2; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: never; +}>; + +// @public +export type PluginHeaderActionsApi = { + getPluginHeaderActions(pluginId: string): Array; +}; + +// @public +export const pluginHeaderActionsApiRef: ApiRef_2; + // @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 = value: TValue; }; +// @public +export const SubPageBlueprint: ExtensionBlueprint_2<{ + kind: 'sub-page'; + params: { + path: string; + title: string; + icon?: IconElement; + loader: () => Promise; + routeRef?: RouteRef; + }; + output: + | ExtensionDataRef_2 + | ExtensionDataRef_2< + RouteRef, + 'core.routing.ref', + { + optional: true; + } + > + | ExtensionDataRef_2 + | ExtensionDataRef_2 + | 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, PluralKeys, IMessages, - string | JSX_3.Element + string | JSX_2.Element > - ): JSX_3.Element; + ): JSX_2.Element; } : never; @@ -2158,7 +2317,7 @@ export function withApis( ): ( WrappedComponent: ComponentType, ) => { - (props: PropsWithChildren>): JSX_2.Element; + (props: PropsWithChildren>): JSX_3.Element; displayName: string; }; ``` diff --git a/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts index fbc9928dc1..d22ebcce4a 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/IconsApi.ts @@ -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[]; diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts new file mode 100644 index 0000000000..78d0e2623f --- /dev/null +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginHeaderActionsApi.ts @@ -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; +}; + +/** + * The `ApiRef` of {@link PluginHeaderActionsApi}. + * + * @public + */ +export const pluginHeaderActionsApiRef = createApiRef({ + id: 'core.plugin-header-actions', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 5e7f713fcf..06d96a50a3 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -49,3 +49,4 @@ export * from './RouteResolutionApi'; export * from './StorageApi'; export * from './AnalyticsApi'; export * from './TranslationApi'; +export * from './PluginHeaderActionsApi'; diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx index c5c34fc02f..8fc9c56bfe 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx @@ -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], diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx index 92e2ab6172..e439d177e2 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx @@ -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; + title?: string; + icon?: IconElement; + loader?: () => Promise; 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 ( + + {ExtensionBoundary.lazy(node, loader)} + + ); + }; + yield coreExtensionData.reactElement(); + } 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 ( + + + {firstPagePath && ( + } + /> + )} + {inputs.pages.map((page, index) => { + const path = page.get(coreExtensionData.routePath); + const element = page.get(coreExtensionData.reactElement); + return ( + + ); + })} + + + ); + }; + + yield coreExtensionData.reactElement(); + } else { + const PageContent = () => { + const headerActionsApi = useApi(pluginHeaderActionsApiRef); + const headerActions = headerActionsApi.getPluginHeaderActions(pluginId); + return ( + + ); + }; + yield coreExtensionData.reactElement(); + } if (params.routeRef) { yield coreExtensionData.routeRef(params.routeRef); } + if (title) { + yield coreExtensionData.title(title); + } + if (icon) { + yield coreExtensionData.icon(icon); + } }, }); diff --git a/packages/frontend-plugin-api/src/blueprints/PluginHeaderActionBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginHeaderActionBlueprint.tsx new file mode 100644 index 0000000000..84ed9da7ff --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/PluginHeaderActionBlueprint.tsx @@ -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 }) { + return createExtensionBlueprintParams(params); + }, + *factory(params, { node }) { + const LazyAction = reactLazy(() => + params.loader().then(element => ({ default: () => element })), + ); + yield coreExtensionData.reactElement( + + + , + ); + }, +}); diff --git a/packages/frontend-plugin-api/src/blueprints/SubPageBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/SubPageBlueprint.tsx new file mode 100644 index 0000000000..1d2cd47984 --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/SubPageBlueprint.tsx @@ -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 => ), + * }, + * }); + * ``` + */ +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; + /** + * 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); + } + }, +}); diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index 344b44dbff..d413776419 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -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'; diff --git a/packages/frontend-plugin-api/src/components/PageLayout.tsx b/packages/frontend-plugin-api/src/components/PageLayout.tsx new file mode 100644 index 0000000000..0eadc07ffd --- /dev/null +++ b/packages/frontend-plugin-api/src/components/PageLayout.tsx @@ -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; + 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 ( +
+ {(title || tabs) && ( +
+ {title && ( +
+ {icon} + {title} + {headerActions && ( +
{headerActions}
+ )} +
+ )} + {tabs && tabs.length > 0 && ( + + )} +
+ )} +
+ {children} +
+
+ ); +} + +/** + * 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({ + id: 'core.page-layout', + loader: () => DefaultPageLayout, +}); diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index 450224bdc4..e04cbb3c65 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -25,3 +25,4 @@ export { } from './createSwappableComponent'; export { useAppNode } from './AppNodeProvider'; export * from './DefaultSwappableComponents'; +export { PageLayout, type PageLayoutProps, type PageTab } from './PageLayout'; diff --git a/packages/frontend-plugin-api/src/icons/index.ts b/packages/frontend-plugin-api/src/icons/index.ts index 9c9e45e54c..913f38d5bf 100644 --- a/packages/frontend-plugin-api/src/icons/index.ts +++ b/packages/frontend-plugin-api/src/icons/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export type { IconComponent } from './types'; +export type { IconComponent, IconElement } from './types'; diff --git a/packages/frontend-plugin-api/src/icons/types.ts b/packages/frontend-plugin-api/src/icons/types.ts index 2b00e1456f..1a45bcd8fc 100644 --- a/packages/frontend-plugin-api/src/icons/types.ts +++ b/packages/frontend-plugin-api/src/icons/types.ts @@ -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 `` 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; diff --git a/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts b/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts index b778539101..9b89fe03cb 100644 --- a/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts +++ b/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts @@ -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().with({ id: 'core.title' }), + /** An icon element for the extension. Should be exactly 24x24 pixels. */ + icon: createExtensionDataRef().with({ id: 'core.icon' }), reactElement: createExtensionDataRef().with({ id: 'core.reactElement', }), diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index 62fd1e88ff..08329b6fd4 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -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; withOverrides(options: { - extensions: Array; + extensions?: Array; + + /** + * 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, diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 3865a53d16..de6388ed2b 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', @@ -356,6 +358,7 @@ const _default: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | ExtensionDataRef< (entity: Entity) => boolean, 'catalog.entity-filter-function', @@ -418,7 +421,6 @@ const _default: OverridableFrontendPlugin< }; output: | ExtensionDataRef - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', @@ -426,6 +428,7 @@ const _default: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | 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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; } diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index 44ded088f1..0c44d5d51c 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -208,6 +208,8 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({ export default createFrontendPlugin({ pluginId: 'api-docs', + title: 'APIs', + icon: , info: { packageJson: () => import('../package.json') }, routes: { root: rootRoute, diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index 78f4bd3bfb..98e977ef20 100644 --- a/plugins/app-react/report.api.md +++ b/plugins/app-react/report.api.md @@ -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, + ): NavContentNavItemsWithComponent; +} + +// @public +export interface NavContentNavItemsWithComponent { + rest(options?: { sortBy?: 'title' }): JSX.Element[]; + take(id: string): JSX.Element | null; } // @public diff --git a/plugins/app-react/src/blueprints/IconBundleBlueprint.ts b/plugins/app-react/src/blueprints/IconBundleBlueprint.ts index ce340c2aeb..99c6abcd72 100644 --- a/plugins/app-react/src/blueprints/IconBundleBlueprint.ts +++ b/plugins/app-react/src/blueprints/IconBundleBlueprint.ts @@ -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, }, diff --git a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx index a113a2f21d..cb782409b1 100644 --- a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx @@ -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(); + 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 ; + } + 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 => ( + + )); + }, + }; + }, + }; +} + 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: () =>
Nav content
, - }, - }); - - const tester = createExtensionTester(extension); - - expect( - tester.get(NavContentBlueprint.dataRefs.component)({ items: [] }), - ).toEqual(
Nav content
); - }); - - 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', () => { , ); }); + + it('should return a valid component with navItems', () => { + const items: NavContentNavItem[] = [ + { + node: mockNode('page:home'), + href: '/', + title: 'Home', + icon: home, + routeRef, + }, + { + node: mockNode('page:catalog'), + href: '/catalog', + title: 'Catalog', + icon: catalog, + routeRef, + }, + { + node: mockNode('page:docs'), + href: '/docs', + title: 'Docs', + icon: docs, + routeRef, + }, + ]; + + const extension = NavContentBlueprint.make({ + name: 'test', + params: { + component: ({ navItems }) => ( +
+ {navItems.rest().map(item => ( + + {item.title} + + ))} +
+ ), + }, + }); + + const tester = createExtensionTester(extension); + + expect( + tester.get(NavContentBlueprint.dataRefs.component)({ + navItems: mockNavItems(items), + items: [], + }), + ).toEqual( +
+ {[ + + Home + , + + Catalog + , + + Docs + , + ]} +
, + ); + }); + + it('should support withComponent for take and rest', () => { + const items: NavContentNavItem[] = [ + { + node: mockNode('page:home'), + href: '/', + title: 'Home', + icon: home, + routeRef, + }, + { + node: mockNode('page:catalog'), + href: '/catalog', + title: 'Catalog', + icon: catalog, + routeRef, + }, + { + node: mockNode('page:docs'), + href: '/docs', + title: 'Docs', + icon: docs, + routeRef, + }, + ]; + + const extension = NavContentBlueprint.make({ + name: 'test', + params: { + component: ({ navItems }) => { + const nav = navItems.withComponent(item => ( + {item.title} + )); + return ( +
+
{nav.take('page:home')}
+ +
+ ); + }, + }, + }); + + const tester = createExtensionTester(extension); + const Component = tester.get(NavContentBlueprint.dataRefs.component); + + render(); + + 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(); + }); }); diff --git a/plugins/app-react/src/blueprints/NavContentBlueprint.ts b/plugins/app-react/src/blueprints/NavContentBlueprint.ts index 0f21ed832a..f88ba48a38 100644 --- a/plugins/app-react/src/blueprints/NavContentBlueprint.ts +++ b/plugins/app-react/src/blueprints/NavContentBlueprint.ts @@ -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, + ): 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; - - // Additional props to simplify item rendering to: string; text: string; }>; diff --git a/plugins/app-react/src/blueprints/index.ts b/plugins/app-react/src/blueprints/index.ts index 15a0fdcd47..318691c8db 100644 --- a/plugins/app-react/src/blueprints/index.ts +++ b/plugins/app-react/src/blueprints/index.ts @@ -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'; diff --git a/plugins/app-visualizer/report.api.md b/plugins/app-visualizer/report.api.md index 180507677e..e0e3f26dd2 100644 --- a/plugins/app-visualizer/report.api.md +++ b/plugins/app-visualizer/report.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef, + '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; + routeRef?: RouteRef; + noHeader?: boolean; + }; + }>; + 'plugin-header-action:app-visualizer': OverridableExtensionDefinition<{ + kind: 'plugin-header-action'; + name: undefined; + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: {}; + params: (params: { + loader: () => Promise; + }) => ExtensionBlueprintParams<{ + loader: () => Promise; + }>; + }>; + '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 + | ExtensionDataRef< + RouteRef, + 'core.routing.ref', + { + optional: true; + } + > + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } + >; + inputs: {}; + params: { + path: string; + title: string; + icon?: IconElement; + loader: () => Promise; + 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 + | ExtensionDataRef< + RouteRef, + 'core.routing.ref', + { + optional: true; + } + > + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } + >; + inputs: {}; + params: { + path: string; + title: string; + icon?: IconElement; + loader: () => Promise; + 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 + | ExtensionDataRef< + RouteRef, + 'core.routing.ref', + { + optional: true; + } + > + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } + >; + inputs: {}; + params: { + path: string; + title: string; + icon?: IconElement; loader: () => Promise; routeRef?: RouteRef; }; diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/AppVisualizerPage.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/AppVisualizerPage.tsx index c005ccb18f..7bdd1e8900 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/AppVisualizerPage.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/AppVisualizerPage.tsx @@ -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: , + element: , }, { id: 'detailed', path: 'detailed', label: 'Detailed', - element: , + element: , }, { id: 'text', path: 'text', label: 'Text', - element: , + element: , }, ], - [tree], + [], ); const location = useLocation(); diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx index 90ab44b626..e88f994627 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx @@ -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 ( - - - - +export function DetailedVisualizer() { + const appTreeApi = useApi(appTreeApiRef); + const { tree } = appTreeApi.getTree(); - - - - + return ( + + + + + + + + + + + ); } diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx index 99fb20ac28..954c1b7a5c 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx @@ -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); diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx index d3a9145bb3..b851df0b26 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx @@ -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 ( - - - + + + + + ); } diff --git a/plugins/app-visualizer/src/components/CopyTreeButton.tsx b/plugins/app-visualizer/src/components/CopyTreeButton.tsx new file mode 100644 index 0000000000..4b430e65dc --- /dev/null +++ b/plugins/app-visualizer/src/components/CopyTreeButton.tsx @@ -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 = {}; + 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 ( + + ); +} diff --git a/plugins/app-visualizer/src/plugin.tsx b/plugins/app-visualizer/src/plugin.tsx index c9c7857c77..9fa7b2cd72 100644 --- a/plugins/app-visualizer/src/plugin.tsx +++ b/plugins/app-visualizer/src/plugin.tsx @@ -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 => ( - + import('./components/AppVisualizerPage/TreeVisualizer').then(m => ( + )), }, }); +const appVisualizerDetailedPage = SubPageBlueprint.make({ + name: 'details', + params: { + path: 'details', + routeRef: detailedRouteRef, + title: 'Detailed', + loader: () => + import('./components/AppVisualizerPage/DetailedVisualizer').then(m => ( + + )), + }, +}); +const appVisualizerTextPage = SubPageBlueprint.make({ + name: 'text', + params: { + path: 'text', + routeRef: textRouteRef, + title: 'Text', + loader: () => + import('./components/AppVisualizerPage/TextVisualizer').then(m => ( + + )), + }, +}); + +const copyTreeAsJson = PluginHeaderActionBlueprint.make({ + params: defineParams => + defineParams({ + loader: () => + import('./components/CopyTreeButton').then(m => ), + }), +}); export const appVisualizerNavItem = NavItemBlueprint.make({ params: { title: 'Visualizer', - icon: () => , + icon: () => , routeRef: rootRouteRef, }, }); @@ -46,6 +94,15 @@ export const appVisualizerNavItem = NavItemBlueprint.make({ /** @public */ export const visualizerPlugin = createFrontendPlugin({ pluginId: 'app-visualizer', + title: 'App Visualizer', + icon: , info: { packageJson: () => import('../package.json') }, - extensions: [appVisualizerPage, appVisualizerNavItem], + extensions: [ + appVisualizerPage, + appVisualizerTreePage, + appVisualizerDetailedPage, + appVisualizerTextPage, + appVisualizerNavItem, + copyTreeAsJson, + ], }); diff --git a/plugins/app/package.json b/plugins/app/package.json index b5e9305043..b4ea8edb6b 100644 --- a/plugins/app/package.json +++ b/plugins/app/package.json @@ -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", diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 256517b86f..b1d851dab9 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -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, ) => ExtensionBlueprintParams; }>; + 'api:app/plugin-header-actions': OverridableExtensionDefinition<{ + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: { + actions: ExtensionInput< + ConfigurableExtensionDataRef, + { + 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, + ) => ExtensionBlueprintParams; + }>; '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: >(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'; diff --git a/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.test.tsx b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.test.tsx new file mode 100644 index 0000000000..9023c86ce8 --- /dev/null +++ b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.test.tsx @@ -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: , + pluginId: 'plugin-a', + }, + { + element: , + 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: Action, + pluginId: 'plugin-a', + }, + ]); + + expect(api.getPluginHeaderActions('unknown-plugin')).toEqual([]); + }); + + it('should group multiple actions by plugin', () => { + const api = DefaultPluginHeaderActionsApi.fromActions([ + { + element: , + pluginId: 'plugin-a', + }, + { + element: , + 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(); + }); +}); diff --git a/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx new file mode 100644 index 0000000000..ae114e4e24 --- /dev/null +++ b/plugins/app/src/apis/PluginHeaderActionsApi/DefaultPluginHeaderActionsApi.tsx @@ -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(); + +type ActionInput = { + element: JSX.Element; + pluginId: string; +}; + +/** + * Default implementation of PluginHeaderActionsApi. + * + * @internal + */ +export class DefaultPluginHeaderActionsApi implements PluginHeaderActionsApi { + constructor( + private readonly actionsByPlugin: Map>, + ) {} + + getPluginHeaderActions(pluginId: string): Array { + return this.actionsByPlugin.get(pluginId) ?? EMPTY_ACTIONS; + } + + static fromActions( + actions: Array, + ): DefaultPluginHeaderActionsApi { + const actionsByPlugin = new Map>(); + + 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); + } +} diff --git a/plugins/app/src/apis/PluginHeaderActionsApi/index.ts b/plugins/app/src/apis/PluginHeaderActionsApi/index.ts new file mode 100644 index 0000000000..be7906a2a7 --- /dev/null +++ b/plugins/app/src/apis/PluginHeaderActionsApi/index.ts @@ -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'; diff --git a/plugins/app/src/extensions/AppNav.tsx b/plugins/app/src/extensions/AppNav.tsx index 12e0c1a5c8..ffa6ced7b0 100644 --- a/plugins/app/src/extensions/AppNav.tsx +++ b/plugins/app/src/extensions/AppNav.tsx @@ -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; + readonly #taken: Set; + + constructor(items: NavContentNavItem[], taken?: Iterable) { + 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 ? : 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 => ( + + )); + }, + }; + } +} + function DefaultNavContent(props: NavContentComponentProps) { + const items = props.navItems.rest(); return ( - {props.items.map((item, index) => ( + {items.map(item => ( item.icon} + text={item.title} + key={item.node.spec.id} /> ))} ); } -// 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; }>; }) { + 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 ; + // 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 = ; + } 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 ; } export const AppNav = createExtension({ @@ -103,7 +233,7 @@ export const AppNav = createExtension({ yield coreExtensionData.reactElement( + legacyNavItems={inputs.items.map(item => item.get(NavItemBlueprint.dataRefs.target), )} Content={Content} diff --git a/plugins/app/src/extensions/PluginHeaderActionsApi.ts b/plugins/app/src/extensions/PluginHeaderActionsApi.ts new file mode 100644 index 0000000000..58c67e239a --- /dev/null +++ b/plugins/app/src/extensions/PluginHeaderActionsApi.ts @@ -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, + })), + ); + }, + }), + ); + }, +}); diff --git a/plugins/app/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx index 72a77e2446..230db89767 100644 --- a/plugins/app/src/extensions/components.tsx +++ b/plugins/app/src/extensions/components.tsx @@ -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 && ( + + )} + {children} + + ); + } + return <>{children}; + }, + }), +}); diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index 17ac19ff4b..d9e2b666f1 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -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'; diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index ec0c627f47..8d2fd56720 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -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, ], }); diff --git a/plugins/auth/report.api.md b/plugins/auth/report.api.md index 1d346a9ce4..a281d265dc 100644 --- a/plugins/auth/report.api.md +++ b/plugins/auth/report.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef; + noHeader?: boolean; }; }>; } diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index 826155e87a..f11aa9a21b 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; } diff --git a/plugins/catalog-import/report-alpha.api.md b/plugins/catalog-import/report-alpha.api.md index 85c412f84d..ba841a7f70 100644 --- a/plugins/catalog-import/report-alpha.api.md +++ b/plugins/catalog-import/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; } diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 04c900ee41..a514820f20 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -258,7 +258,6 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ }; output: | ExtensionDataRef - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', @@ -266,6 +265,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ optional: true; } > + | ExtensionDataRef | ExtensionDataRef< (entity: Entity) => boolean, 'catalog.entity-filter-function', diff --git a/plugins/catalog-unprocessed-entities/report-alpha.api.md b/plugins/catalog-unprocessed-entities/report-alpha.api.md index d3bd6dd28f..30b115f07e 100644 --- a/plugins/catalog-unprocessed-entities/report-alpha.api.md +++ b/plugins/catalog-unprocessed-entities/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; } @@ -104,7 +157,6 @@ export const unprocessedEntitiesDevToolsContent: OverridableExtensionDefinition< }; output: | ExtensionDataRef - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', @@ -112,6 +164,7 @@ export const unprocessedEntitiesDevToolsContent: OverridableExtensionDefinition< optional: true; } > + | ExtensionDataRef | ExtensionDataRef; inputs: {}; params: DevToolsContentBlueprintParams; diff --git a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx index 5ccae907aa..8fd614c886 100644 --- a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx +++ b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx @@ -68,6 +68,8 @@ export const catalogUnprocessedEntitiesNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'catalog-unprocessed-entities', + title: 'Unprocessed Entities', + icon: , info: { packageJson: () => import('../../package.json') }, routes: { root: rootRouteRef, diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index d154ed4981..c2d856561f 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', @@ -764,6 +764,7 @@ const _default: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | 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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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, { @@ -1035,8 +1082,11 @@ const _default: OverridableFrontendPlugin< params: { defaultPath?: [Error: `Use the 'path' param instead`]; path: string; - loader: () => Promise; + title?: string; + icon?: IconElement; + loader?: () => Promise; 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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; 'search-result-list-item:catalog': OverridableExtensionDefinition<{ diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index aafbbd2178..acb9ec8d51 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -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: , + 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 diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index 44cf2101b6..8db026016a 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -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: , + info: { + packageJson: () => import('../../package.json'), + }, routes: { catalogIndex: rootRouteRef, catalogEntity: entityRouteRef, diff --git a/plugins/devtools-react/report.api.md b/plugins/devtools-react/report.api.md index d1b1a42250..6916afed84 100644 --- a/plugins/devtools-react/report.api.md +++ b/plugins/devtools-react/report.api.md @@ -15,7 +15,6 @@ export const DevToolsContentBlueprint: ExtensionBlueprint<{ params: DevToolsContentBlueprintParams; output: | ExtensionDataRef - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', @@ -23,6 +22,7 @@ export const DevToolsContentBlueprint: ExtensionBlueprint<{ optional: true; } > + | ExtensionDataRef | ExtensionDataRef; inputs: {}; config: { diff --git a/plugins/devtools/report-alpha.api.md b/plugins/devtools/report-alpha.api.md index 8f3e24f7f3..6c3cfab581 100644 --- a/plugins/devtools/report-alpha.api.md +++ b/plugins/devtools/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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 | ConfigurableExtensionDataRef @@ -101,8 +148,11 @@ const _default: OverridableFrontendPlugin< params: { defaultPath?: [Error: `Use the 'path' param instead`]; path: string; - loader: () => Promise; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; } diff --git a/plugins/devtools/src/alpha/plugin.tsx b/plugins/devtools/src/alpha/plugin.tsx index edeaae5723..b1cfa7027f 100644 --- a/plugins/devtools/src/alpha/plugin.tsx +++ b/plugins/devtools/src/alpha/plugin.tsx @@ -88,6 +88,8 @@ export const devToolsNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'devtools', + title: 'DevTools', + icon: , info: { packageJson: () => import('../../package.json') }, routes: { root: rootRouteRef, diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index 4568fc8c2a..48f7853cd7 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef; + noHeader?: boolean; }; }>; } diff --git a/plugins/home/src/alpha.tsx b/plugins/home/src/alpha.tsx index 23e9d0e42b..194f322317 100644 --- a/plugins/home/src/alpha.tsx +++ b/plugins/home/src/alpha.tsx @@ -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: , info: { packageJson: () => import('../package.json') }, extensions: [ homePage, diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 46a576191f..86b824b2f4 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', @@ -114,6 +116,7 @@ const _default: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | 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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; } diff --git a/plugins/mui-to-bui/report.api.md b/plugins/mui-to-bui/report.api.md index db1d49f828..302804d432 100644 --- a/plugins/mui-to-bui/report.api.md +++ b/plugins/mui-to-bui/report.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; } diff --git a/plugins/notifications/report-alpha.api.md b/plugins/notifications/report-alpha.api.md index 9a27b67d80..7ec24ef81e 100644 --- a/plugins/notifications/report-alpha.api.md +++ b/plugins/notifications/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; } diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 0eda5c0ca9..943b139cb9 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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, @@ -227,8 +274,11 @@ const _default: OverridableFrontendPlugin< params: { defaultPath?: [Error: `Use the 'path' param instead`]; path: string; - loader: () => Promise; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; 'scaffolder-form-field:scaffolder/entity-name-picker': OverridableExtensionDefinition<{ diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index 9b7698250a..374249a804 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -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: , info: { packageJson: () => import('../../package.json') }, routes: { root: rootRouteRef, diff --git a/plugins/search/report-alpha.api.md b/plugins/search/report-alpha.api.md index 2e82c77416..98a47d6b1e 100644 --- a/plugins/search/report-alpha.api.md +++ b/plugins/search/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; 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 - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef; + noHeader?: boolean; }; }>; diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx index c36ae54d33..a2ecb61e0e 100644 --- a/plugins/search/src/alpha.tsx +++ b/plugins/search/src/alpha.tsx @@ -276,6 +276,8 @@ export const searchNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'search', + title: 'Search', + icon: , info: { packageJson: () => import('../package.json') }, extensions: [searchApi, searchPage, searchNavItem], routes: { diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 3092a010d4..5cf7b4fd7a 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', @@ -147,6 +147,7 @@ const _default: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | 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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; - inputs: {}; + inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; 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 - | ExtensionDataRef | ExtensionDataRef< RouteRef_2, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef_2, + '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; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef_2; + noHeader?: boolean; }; }>; 'search-result-list-item:techdocs': OverridableExtensionDefinition<{ diff --git a/plugins/techdocs/src/alpha/index.tsx b/plugins/techdocs/src/alpha/index.tsx index 9e63004046..409bf03269 100644 --- a/plugins/techdocs/src/alpha/index.tsx +++ b/plugins/techdocs/src/alpha/index.tsx @@ -278,6 +278,8 @@ const techDocsNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'techdocs', + title: 'Docs', + icon: , info: { packageJson: () => import('../../package.json') }, extensions: [ techDocsClientApi, diff --git a/plugins/user-settings/report-alpha.api.md b/plugins/user-settings/report-alpha.api.md index 056b5ee557..11ffee9229 100644 --- a/plugins/user-settings/report-alpha.api.md +++ b/plugins/user-settings/report-alpha.api.md @@ -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 - | ExtensionDataRef | ExtensionDataRef< RouteRef, 'core.routing.ref', { optional: true; } + > + | ExtensionDataRef + | ExtensionDataRef< + string, + 'core.title', + { + optional: true; + } + > + | ExtensionDataRef< + IconElement, + 'core.icon', + { + optional: true; + } >; inputs: { + pages: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef, + '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, { @@ -75,8 +122,11 @@ const _default: OverridableFrontendPlugin< params: { defaultPath?: [Error: `Use the 'path' param instead`]; path: string; - loader: () => Promise; + title?: string; + icon?: IconElement; + loader?: () => Promise; routeRef?: RouteRef; + noHeader?: boolean; }; }>; } diff --git a/plugins/user-settings/src/alpha.tsx b/plugins/user-settings/src/alpha.tsx index bea75bbdd8..c0342a9b46 100644 --- a/plugins/user-settings/src/alpha.tsx +++ b/plugins/user-settings/src/alpha.tsx @@ -62,6 +62,8 @@ export const settingsNavItem = NavItemBlueprint.make({ */ export default createFrontendPlugin({ pluginId: 'user-settings', + title: 'Settings', + icon: , info: { packageJson: () => import('../package.json') }, extensions: [userSettingsPage, settingsNavItem], routes: { diff --git a/yarn.lock b/yarn.lock index 318b7c8629..4676831133 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"