From 4b996d05d3c91ef77d9fe3a3edd100da9bd6d49c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 14:09:00 +0100 Subject: [PATCH 01/47] frontend-plugin-api: initial support for subpages Signed-off-by: Patrik Oldsberg --- header-architecture-summary.md | 419 ++++++++++++++++++ header.md | 43 ++ .../src/blueprints/HeaderActionBlueprint.tsx | 35 ++ .../src/blueprints/PageBlueprint.tsx | 87 +++- .../src/blueprints/SubPageBlueprint.tsx | 90 ++++ .../src/blueprints/index.ts | 2 + .../src/components/PageLayout.tsx | 126 ++++++ .../src/components/index.ts | 5 + .../AppVisualizerPage/AppVisualizerPage.tsx | 13 +- .../AppVisualizerPage/DetailedVisualizer.tsx | 7 +- .../AppVisualizerPage/TextVisualizer.tsx | 10 +- .../AppVisualizerPage/TreeVisualizer.tsx | 11 +- plugins/app-visualizer/src/plugin.tsx | 93 +++- plugins/app/package.json | 1 + plugins/app/src/extensions/AppRoutes.tsx | 15 +- plugins/app/src/extensions/components.tsx | 22 + plugins/app/src/extensions/index.ts | 7 +- plugins/app/src/plugin.ts | 2 + plugins/catalog/src/alpha/plugin.tsx | 5 +- subpage-implementation-summary.md | 270 +++++++++++ swappable-component-refactor-summary.md | 337 ++++++++++++++ yarn.lock | 1 + 22 files changed, 1570 insertions(+), 31 deletions(-) create mode 100644 header-architecture-summary.md create mode 100644 header.md create mode 100644 packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx create mode 100644 packages/frontend-plugin-api/src/blueprints/SubPageBlueprint.tsx create mode 100644 packages/frontend-plugin-api/src/components/PageLayout.tsx create mode 100644 subpage-implementation-summary.md create mode 100644 swappable-component-refactor-summary.md diff --git a/header-architecture-summary.md b/header-architecture-summary.md new file mode 100644 index 0000000000..30173f353a --- /dev/null +++ b/header-architecture-summary.md @@ -0,0 +1,419 @@ +# Page Header Architecture - Implementation Summary + +## Overview + +This branch implements a new page header architecture for Backstage's New Frontend System (NFS) that provides: + +- Consistent headers across all plugin pages +- Support for sub-pages with tab navigation +- Header actions (buttons/controls in the header) +- Plugin-level display metadata (titles and icons) + +## Architectural Changes + +### 1. New Blueprint: `HeaderActionBlueprint` + +**Location:** `packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx` + +A new extension blueprint that allows plugins to register actions (buttons, controls) that appear in the page header. + +**Key Features:** + +- Attaches to `app/routes` with input `headerActions` +- Outputs `coreExtensionData.reactElement` +- Uses lazy loading with `ExtensionBoundary` + +**Usage Example** (from api-docs plugin): + +```typescript +HeaderActionBlueprint.make({ + name: 'register-api', + params: { + loader: async () => + compatWrapper( + Register Existing API, + ), + }, +}); +``` + +### 2. Enhanced `PageBlueprint` + +**Location:** `packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx` + +The `PageBlueprint` has been significantly enhanced to support sub-pages and tabbed navigation. + +**New Features:** + +#### Inputs + +- New `pages` input that accepts sub-pages with: + - `routePath` + - `routeRef` (optional) + - `reactElement` + - `title` (optional) + +#### Outputs + +- Added `title` output (optional) + +#### Configuration + +- New `title` config schema property + +#### Parameters + +- `title?: string` - Page title displayed in header +- `loader` is now optional (not required when using sub-pages) +- `routeRef` now accepts both `RouteRef` and `SubRouteRef` + +#### Behavior Changes + +1. **With loader (traditional page):** Renders Header + page content +2. **Without loader (parent page with tabs):** Renders Header with tabs populated from `inputs.pages` + +### 3. Central Routing Changes: `AppRoutes` + +**Location:** `plugins/app/src/extensions/AppRoutes.tsx` + +The core routing extension has been substantially refactored to orchestrate the new header architecture. + +**Key Implementation Details:** + +#### Header Action Aggregation + +```typescript +const headerActionsByPluginId = new Map>(); +``` + +- Collects all header actions from inputs +- Groups them by plugin ID +- Passes them to the Header component via `customActions` prop + +#### Page Aggregation by Path + +```typescript +const pagesByPath = new Map< + string, + Array<{ element: JSX.Element; title: string; node: AppNode }> +>(); +``` + +- Groups pages by their route path +- Enables detection of pages with sub-pages +- Stores title and node reference for each page + +#### Conditional Rendering Logic + +1. **Multiple pages at same path (tabbed navigation):** + + - Renders `Header` with tabs + - Uses nested `` for tab content + - Tab matching uses prefix strategy + +2. **Single page (traditional):** + - Renders `Header` with plugin title + - Renders page element directly + +### 4. Plugin Display Metadata + +**Location:** `plugins/catalog/src/alpha/plugin.tsx` + +Plugins can now define display metadata: + +```typescript +export default createFrontendPlugin({ + pluginId: 'catalog', + info: { + packageJson: () => import('../../package.json'), + }, + display: { + icon: 'catalog', + title: 'Software Catalog', + }, + // ... +}); +``` + +### 5. Header Component Styling Updates + +**Location:** `packages/ui/src/components/Header/Header.module.css` + +- Commented out default `margin-bottom` on `.bui-HeaderToolbar` +- Allows tighter integration between header and content +- Maintains conditional margin for tabbed headers + +## Implementation Examples + +### Example 1: App Visualizer (Multi-page with Tabs) + +**Location:** `plugins/app-visualizer/src/plugin.tsx` + +Demonstrates the sub-page pattern: + +1. **Parent page** (no loader): + +```typescript +const appVisualizerPage = PageBlueprint.make({ + params: { + path: '/visualizer', + routeRef: rootRouteRef, + title: 'Visualizer', + // No loader - will show tabs + }, +}); +``` + +2. **Sub-pages** (using `SubPageBlueprint` - not yet fully implemented): + +```typescript +const appVisualizerTreePage = SubPageBlueprint.make({ + attachTo: { id: 'page:app-visualizer', input: 'pages' }, + name: 'tree', + params: { + path: '/tree', + title: 'Tree', + loader: () => import('./components/...'), + }, +}); +``` + +Multiple sub-pages are defined: + +- Tree view (`/tree`) +- Detailed view (`/details`) +- Text view (`/text`) + +### Example 2: API Docs (Header Actions) + +**Location:** `plugins/api-docs/src/alpha.tsx` + +Demonstrates header actions: + +```typescript +HeaderActionBlueprint.make({ + name: 'register-api', + params: { + loader: async () => + compatWrapper( + Register Existing API, + ), + }, +}); +``` + +## Data Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AppRoutes │ +│ (Central orchestrator) │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌─────────────┴─────────────┐ + │ │ + ▼ ▼ + ┌───────────────────┐ ┌────────────────────┐ + │ headerActions │ │ routes │ + │ input │ │ input │ + └───────────────────┘ └────────────────────┘ + │ │ + │ │ + ▼ ▼ + Group by plugin ID Group by route path + │ │ + │ │ + └─────────────┬─────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Route Renderer │ + └─────────────────┘ + │ + ┌─────────────┴──────────────┐ + │ │ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────────┐ + │ Single Page │ │ Multi-page (Tabs) │ + │ │ │ │ + │ Header + │ │ Header (w/ tabs) + │ + │ Page Element │ │ Nested Routes │ + └──────────────────┘ └──────────────────────┘ +``` + +## Extension Data Types Used + +The implementation relies on these core extension data types: + +- `coreExtensionData.routePath` - Route paths for pages +- `coreExtensionData.routeRef` - Route references for navigation +- `coreExtensionData.reactElement` - React components to render +- `coreExtensionData.title` - Page/sub-page titles + +## Technical Considerations + +### 1. Plugin-Relative Attachment Points + +The implementation uses string-based attachment points: + +```typescript +attachTo: { id: 'page:app-visualizer', input: 'pages' } +``` + +**Future consideration:** TypeScript-based attachment points for type safety: + +```typescript +attachTo: appVisualizerPage.inputs.pages; +``` + +### 2. SubPageBlueprint + +The code references `SubPageBlueprint` which is not yet fully implemented in the blueprint exports. This suggests it's either: + +- Still being developed +- Implemented locally for testing +- Planned for future implementation + +### 3. Header Component Integration + +The `Header` component from `@backstage/ui` is used directly in: + +- `PageBlueprint` rendering +- `AppRoutes` conditional rendering + +This component accepts: + +- `title` - Page title +- `tabs` - Array of tab configurations +- `customActions` - Array of React nodes for header actions + +### 4. Route Matching Strategy + +Sub-pages use different route matching: + +- Parent pages: Match with trailing `/*` for catch-all +- Tab content: Direct path matching +- Tab component: Uses `matchStrategy: 'prefix'` + +## Outstanding Questions & TODOs + +From `header.md`: + +### Decisions Needed + +- [ ] Should we add titles to all plugins? +- [ ] Should we add icons to all plugins? +- [ ] Is `HeaderActionBlueprint` too specific? (Note: "a bit wonky") +- [ ] PageBlueprint additions or ContentBlueprint? + +### Implementation Items + +- [ ] Ship the Figma design +- [ ] TopBarActionBlueprint? (alternative naming consideration) +- [ ] Add `icon` and `title` to plugin info (partially done) +- [ ] Add support for plugin-relative attachment points +- [ ] Consider TypeScript-based attachment points +- [ ] Add `coreExtensionData.navTarget` +- [ ] Add swappable component for PageBlueprint React element + +## Breaking Changes + +This implementation introduces breaking changes: + +1. **PageBlueprint API changes:** + + - New optional `title` parameter + - `loader` is now optional + - `routeRef` accepts `SubRouteRef` in addition to `RouteRef` + +2. **AppRoutes behavior:** + + - Automatically wraps all pages with Header component + - Changes route structure for multi-page plugins + +3. **Header styling:** + - Removes default bottom margin on headers + +## Migration Path + +For existing plugins to adopt the new header architecture: + +1. **Add display metadata to plugin:** + +```typescript +display: { + icon: 'plugin-icon', + title: 'Plugin Name', +} +``` + +2. **Add titles to pages:** + +```typescript +PageBlueprint.make({ + params: { + title: 'Page Title', + // ... other params + }, +}); +``` + +3. **Optional - Add header actions:** + +```typescript +HeaderActionBlueprint.make({ + params: { + loader: () => , + }, +}); +``` + +4. **Optional - Add sub-pages for tabbed navigation:** + Create multiple pages at the same route path with different titles. + +## Files Changed Summary + +| File | Lines Changed | Type of Change | +| ------------------------------------------ | ------------- | ---------------------- | +| `header.md` | +43 | Documentation | +| `HeaderActionBlueprint.tsx` | +35 | New Blueprint | +| `PageBlueprint.tsx` | +52/-12 | Enhancement | +| `blueprints/index.ts` | +1 | Export | +| `Header.module.css` | +1/-1 | Styling | +| `plugins/api-docs/src/alpha.tsx` | +13/-1 | Example Implementation | +| `plugins/app-visualizer/src/plugin.tsx` | +120/-1 | Example Implementation | +| `plugins/app/package.json` | +1 | Dependency | +| `plugins/app/src/extensions/AppRoutes.tsx` | +77/-2 | Core Logic | +| `plugins/catalog/src/alpha/plugin.tsx` | +9/-1 | Display Metadata | +| `yarn.lock` | +1 | Dependency | + +**Total: 11 files, ~354 insertions, ~20 deletions** + +## Next Steps + +1. **Complete SubPageBlueprint implementation** - Currently referenced but not fully exported +2. **Decide on naming** - HeaderActionBlueprint vs TopBarActionBlueprint +3. **Type-safe attachment points** - Consider TypeScript-based approach +4. **Migration guide** - Document for plugin authors +5. **Testing** - Comprehensive tests for new routing logic +6. **Figma alignment** - Ensure implementation matches design specs +7. **Plugin adoption** - Roll out to all core plugins +8. **API documentation** - Update API docs for new blueprints + +## Architecture Benefits + +1. **Consistency:** All pages have consistent header treatment +2. **Flexibility:** Supports both simple pages and complex tabbed interfaces +3. **Extensibility:** Header actions allow plugins to add controls +4. **Navigation:** Two-level navigation (page + sub-pages) is now native +5. **Declarative:** Uses extension system patterns for discoverability +6. **Centralized:** AppRoutes orchestrates header rendering for consistency + +## Potential Concerns + +1. **Complexity:** AppRoutes has increased complexity with conditional rendering +2. **Performance:** Multiple map operations on every render +3. **Breaking:** Changes existing PageBlueprint behavior +4. **Incomplete:** SubPageBlueprint appears incomplete +5. **Debugging:** Console.log statements present (need cleanup) +6. **Migration:** All existing plugins will need updates for full adoption diff --git a/header.md b/header.md new file mode 100644 index 0000000000..d3cf8fc4cd --- /dev/null +++ b/header.md @@ -0,0 +1,43 @@ +## TODO + +- [ ] Shipping https://www.figma.com/design/zJxHainw6yO7L9oEsStAh7/Header?node- [ ]id=103- [ ]5709&t=HNkpc2MqdUbaN8Jz- [ ]1 +- [ ] TopBarActionBlueprint? +- [ ] PageBlueprint additions or ContentBlueprint? +- [ ] Should we add titles to all plugins? +- [ ] Should we add icons to all plugins? + +- [ ] Add `icon` and `title` to the plugin info + +## Notes + +- HeaderActionBlueprint is a bit wonky, too specific? + +## Requirements + +- Consistency of headers across all plugins in NFS +- Possibility to put breadcrumbs in the top bar +- Two levels of navigation for plugins - page + sub pages + +## Technical Requirements + +- Sub pages must be represented in some form as extensions +- Sub pages must have a title and path relative to the parent page +- Plugins must have titles and icons +- Sub pages must be attachments of the parent page + +## Potential Solutions + +### Make all(\*) pages navigable, get rid of nav items + +## Implementation Plan + +- Add support for plugin-relative attachment points, i.e. `attachTo: { id: 'page:{pluginId}', input: 'pages' }` + - Alternative: typescript-based attachment points, i.e. `attachTo: appVisualizerPage` or `attachTo: appVisualizerPage.inputs.pages` +- Add a `SubPageBlueprint` that attaches to the `pages` of `PageBlueprint`, using plugin-relative attachment +- Add a swappable component for the `PageBlueprint` React element +- Add `title` param and output to `PageBlueprint` + +To consider: + +- Add `display` options for plugins +- Add `coreExtensionData.navTarget`, either marker or with actual data diff --git a/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx new file mode 100644 index 0000000000..469407a404 --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { coreExtensionData, createExtensionBlueprint } from '../wiring'; +import { ExtensionBoundary } from '../components'; + +/** + * Createx extensions that are routable React page components. + * + * @public + */ +export const HeaderActionBlueprint = createExtensionBlueprint({ + kind: 'header-action', + attachTo: { id: 'app/routes', input: 'headerActions' }, + output: [coreExtensionData.reactElement], + + *factory(params: { loader: () => Promise }, { node }) { + yield coreExtensionData.reactElement( + ExtensionBoundary.lazy(node, params.loader), + ); + }, +}); diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx index 92e2ab6172..1721f4172d 100644 --- a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx @@ -14,9 +14,14 @@ * limitations under the License. */ +import { Routes, Route, Navigate } from 'react-router-dom'; import { RouteRef } from '../routing'; -import { coreExtensionData, createExtensionBlueprint } from '../wiring'; -import { ExtensionBoundary } from '../components'; +import { + coreExtensionData, + createExtensionBlueprint, + createExtensionInput, +} from '../wiring'; +import { ExtensionBoundary, PageLayout, PageTab } from '../components'; /** * Createx extensions that are routable React page components. @@ -26,34 +31,100 @@ import { ExtensionBoundary } from '../components'; export const PageBlueprint = createExtensionBlueprint({ kind: 'page', attachTo: { id: 'app/routes', input: 'routes' }, + inputs: { + pages: createExtensionInput([ + coreExtensionData.routePath, + coreExtensionData.routeRef.optional(), + coreExtensionData.reactElement, + coreExtensionData.title.optional(), + ]), + }, output: [ coreExtensionData.routePath, coreExtensionData.reactElement, coreExtensionData.routeRef.optional(), + coreExtensionData.title.optional(), ], config: { schema: { path: z => z.string().optional(), + title: z => z.string().optional(), }, }, *factory( params: { /** - * @deprecated Use the `path` param instead. + * @deprecated Use the `path' param instead. */ defaultPath?: [Error: `Use the 'path' param instead`]; path: string; - loader: () => Promise; + title?: string; + loader?: () => Promise; routeRef?: RouteRef; }, - { config, node }, + { config, node, inputs }, ) { + const title = config.title ?? params.title ?? node.spec.plugin.pluginId; + yield coreExtensionData.routePath(config.path ?? params.path); - yield coreExtensionData.reactElement( - ExtensionBoundary.lazy(node, params.loader), - ); + if (params.loader) { + // Simple page with loader - render header + content + const loader = params.loader; // Capture for closure + const PageContent = () => ( + + {ExtensionBoundary.lazy(node, loader)} + + ); + yield coreExtensionData.reactElement(); + } else if (inputs.pages.length > 0) { + // Parent page with sub-pages - render Header with tabs and Routes for sub-pages + const tabs: PageTab[] = inputs.pages.map(page => { + const path = page.get(coreExtensionData.routePath); + const tabTitle = page.get(coreExtensionData.title); + return { + id: path, + label: tabTitle || path, + href: path, + matchStrategy: 'prefix' as const, + }; + }); + + const PageContent = () => { + // Get first sub-page path for default navigation + const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath); + + return ( + + + {/* Index route redirects to first sub-page */} + {firstPagePath && ( + } + /> + )} + {inputs.pages.map((page, index) => { + const path = page.get(coreExtensionData.routePath); + const element = page.get(coreExtensionData.reactElement); + return ( + + ); + })} + + + ); + }; + + yield coreExtensionData.reactElement(); + } else { + // Parent page without loader or sub-pages - render just header + yield coreExtensionData.reactElement(); + } if (params.routeRef) { yield coreExtensionData.routeRef(params.routeRef); } + if (title) { + yield coreExtensionData.title(title); + } }, }); 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..aacf03f4f9 --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/SubPageBlueprint.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RouteRef } from '../routing'; +import { coreExtensionData, createExtensionBlueprint } from '../wiring'; +import { ExtensionBoundary } from '../components'; + +/** + * Creates extensions that are sub-page React components attached to a parent page. + * Sub-pages are rendered as tabs within the parent page's header. + * + * @public + * @example + * ```tsx + * const overviewRouteRef = createRouteRef(); + * + * const mySubPage = SubPageBlueprint.make({ + * attachTo: { id: 'page:my-plugin', input: 'pages' }, + * name: 'overview', + * params: { + * path: '/overview', + * title: 'Overview', + * routeRef: overviewRouteRef, + * loader: () => import('./components/Overview').then(m => ), + * }, + * }); + * ``` + */ +export const SubPageBlueprint = createExtensionBlueprint({ + kind: 'sub-page', + attachTo: { relative: { kind: 'page' }, input: 'pages' }, + output: [ + coreExtensionData.routePath, + coreExtensionData.reactElement, + coreExtensionData.title, + coreExtensionData.routeRef.optional(), + ], + config: { + schema: { + path: z => z.string().optional(), + title: z => z.string().optional(), + }, + }, + *factory( + params: { + /** + * The path for this sub-page, relative to the parent page. + * Should start with '/'. + * @example '/overview', '/settings', '/details' + */ + path: string; + /** + * The title displayed in the tab for this sub-page. + */ + title: string; + /** + * A function that returns a promise resolving to the React element to render. + * This enables lazy loading of the sub-page content. + */ + loader: () => Promise; + /** + * 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); + } + }, +}); diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index 344b44dbff..fd55dbb7a5 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 { HeaderActionBlueprint } from './HeaderActionBlueprint'; 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..ae0399afc9 --- /dev/null +++ b/packages/frontend-plugin-api/src/components/PageLayout.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReactNode } from 'react'; +import { createSwappableComponent } from './createSwappableComponent'; + +/** + * Tab configuration for page navigation + * @public + */ +export interface PageTab { + id: string; + label: string; + href: string; + matchStrategy?: 'prefix' | 'exact'; +} + +/** + * Props for the PageLayout component + * @public + */ +export interface PageLayoutProps { + title?: string; + tabs?: PageTab[]; + children?: ReactNode; +} + +/** + * Default implementation of PageLayout using plain HTML elements + */ +function DefaultPageLayout(props: PageLayoutProps): JSX.Element { + const { title, tabs, children } = props; + + return ( +
+ {(title || tabs) && ( +
+ {title && ( +
+ {title} +
+ )} + {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..f0af38d1df 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -25,3 +25,8 @@ export { } from './createSwappableComponent'; export { useAppNode } from './AppNodeProvider'; export * from './DefaultSwappableComponents'; +export { + PageLayout, + type PageLayoutProps, + type PageTab, +} from './PageLayout'; 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..51527977f6 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx @@ -16,13 +16,13 @@ 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 { @@ -351,7 +351,10 @@ function Legend() { ); } -export function DetailedVisualizer({ tree }: { tree: AppTree }) { +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..c8ad3e4c32 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/TextVisualizer.tsx @@ -14,7 +14,11 @@ * 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 +81,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..4dcbce522d 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/TreeVisualizer.tsx @@ -18,7 +18,12 @@ import { DependencyGraph, DependencyGraphTypes, } from '@backstage/core-components'; -import { AppNode, AppTree } from '@backstage/frontend-plugin-api'; +import { + AppNode, + AppTree, + useApi, + appTreeApiRef, +} from '@backstage/frontend-plugin-api'; import { Flex } from '@backstage/ui'; import { useLayoutEffect, useMemo, useRef, useState } from 'react'; @@ -137,7 +142,9 @@ 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/plugin.tsx b/plugins/app-visualizer/src/plugin.tsx index c9c7857c77..f4936ef088 100644 --- a/plugins/app-visualizer/src/plugin.tsx +++ b/plugins/app-visualizer/src/plugin.tsx @@ -19,6 +19,7 @@ import { createRouteRef, NavItemBlueprint, PageBlueprint, + SubPageBlueprint, } from '@backstage/frontend-plugin-api'; import { RiEyeLine as VisualizerIcon } from '@remixicon/react'; @@ -28,10 +29,87 @@ const appVisualizerPage = PageBlueprint.make({ params: { path: '/visualizer', routeRef: rootRouteRef, + title: 'Visualizer', + // loader: async () =>
The root page
, + }, +}); + +const appVisualizerPage2 = PageBlueprint.make({ + name: '2', + params: { + path: '/visualizer/something-else', + routeRef: createRouteRef(), + title: 'something else', + loader: async () =>
The root sdsd
, + }, +}); +/* +// inputs: +// pages: [explicitly subrouteref as a data type + element + path] +const rootPage = TabbedPageBlueprint.make({ + params: { + path: '/visualizer', + routeRef: rootRouteRef, + actions: [ + ] + subpages: [ + + ] + } +}) + +const treePageThing = PluginContentTopBarNavigableContentBlueprint.make({ + attachTo: { + + } + params: { + routeRef: treeSubRouteRef, + loader: () => null, + } +}) + */ + +const treeRouteRef = createRouteRef(); +const detailedRouteRef = createRouteRef(); +const textRouteRef = createRouteRef(); + +const appVisualizerTreePage = SubPageBlueprint.make({ + attachTo: { id: 'page:app-visualizer', input: 'pages' }, + 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({ + attachTo: { id: 'page:app-visualizer', input: 'pages' }, + name: 'details', + params: { + path: 'details', + routeRef: detailedRouteRef, + title: 'Detailed', + loader: () => + import('./components/AppVisualizerPage/DetailedVisualizer').then( + m => , + ), + }, +}); +const appVisualizerTextPage = SubPageBlueprint.make({ + attachTo: { id: 'page:app-visualizer', input: 'pages' }, + name: 'text', + params: { + path: 'text', + routeRef: textRouteRef, + title: 'Text', + loader: () => + import('./components/AppVisualizerPage/TextVisualizer').then( + m => , + ), }, }); @@ -47,5 +125,12 @@ export const appVisualizerNavItem = NavItemBlueprint.make({ export const visualizerPlugin = createFrontendPlugin({ pluginId: 'app-visualizer', info: { packageJson: () => import('../package.json') }, - extensions: [appVisualizerPage, appVisualizerNavItem], + extensions: [ + appVisualizerPage, + appVisualizerPage2, + appVisualizerTreePage, + appVisualizerDetailedPage, + appVisualizerTextPage, + appVisualizerNavItem, + ], }); 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/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index ddd7962004..567aeec0d3 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -57,6 +57,19 @@ export const AppRoutes = createExtension({ return element; }; - return [coreExtensionData.reactElement()]; + return [ + coreExtensionData.reactElement( +
+ +
, + ), + ]; }, }); diff --git a/plugins/app/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx index 72a77e2446..5c985986ca 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,6 +26,7 @@ import { ErrorPanel, Progress as ProgressComponent, } from '@backstage/core-components'; +import { Header, Flex } from '@backstage/ui'; import Button from '@material-ui/core/Button'; export const Progress = SwappableComponentBlueprint.make({ @@ -63,3 +66,22 @@ export const ErrorDisplay = SwappableComponentBlueprint.make({ }, }), }); + +export const PageLayout = SwappableComponentBlueprint.make({ + name: 'core-page-layout', + params: define => + define({ + component: SwappablePageLayout, + loader: () => (props: PageLayoutProps) => { + const { title, tabs, children } = props; + return ( + +
+ + {children} + + + ); + }, + }), +}); diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index 17ac19ff4b..e97a739e44 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -31,5 +31,10 @@ export { oauthRequestDialogAppRootElement, alertDisplayAppRootElement, } from './elements'; -export { Progress, NotFoundErrorPage, ErrorDisplay } from './components'; +export { + Progress, + NotFoundErrorPage, + ErrorDisplay, + PageLayout, +} from './components'; export { PluginWrapperApi } from './PluginWrapperApi'; diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index ec0c627f47..bc4d8d278e 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -37,6 +37,7 @@ import { Progress, NotFoundErrorPage, ErrorDisplay, + PageLayout, LegacyComponentsApi, } from './extensions'; import { apis } from './defaultApis'; @@ -68,6 +69,7 @@ export const appPlugin = createFrontendPlugin({ Progress, NotFoundErrorPage, ErrorDisplay, + PageLayout, LegacyComponentsApi, ], }); diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index 44cf2101b6..e0a9d24708 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -15,7 +15,6 @@ */ import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; - import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { @@ -39,7 +38,9 @@ import contextMenuItems from './contextMenuItems'; /** @alpha */ export default createFrontendPlugin({ pluginId: 'catalog', - info: { packageJson: () => import('../../package.json') }, + info: { + packageJson: () => import('../../package.json'), + }, routes: { catalogIndex: rootRouteRef, catalogEntity: entityRouteRef, diff --git a/subpage-implementation-summary.md b/subpage-implementation-summary.md new file mode 100644 index 0000000000..496dfd8fe4 --- /dev/null +++ b/subpage-implementation-summary.md @@ -0,0 +1,270 @@ +# SubPageBlueprint Implementation Summary + +## What Was Implemented + +I've successfully implemented the `SubPageBlueprint` pattern for the header architecture, addressing one of the critical gaps identified in the RFC alignment analysis. + +## Files Created/Modified + +### New Files + +1. **`packages/frontend-plugin-api/src/blueprints/SubPageBlueprint.tsx`** + - New blueprint for creating sub-pages that attach to parent pages + - Outputs: `routePath`, `reactElement`, `title` + - Supports lazy loading of sub-page content + - Requires users to specify `attachTo` to target their parent page + +### Modified Files + +2. **`packages/frontend-plugin-api/src/blueprints/index.ts`** + + - Exported `SubPageBlueprint` for public use + +3. **`packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx`** + + - Enhanced to handle sub-pages via `inputs.pages` + - Three rendering modes: + - **With loader**: Renders Header + lazy-loaded content + - **With sub-pages**: Renders Header with tabs + nested Routes for sub-pages + - **Empty**: Renders just Header + - Removed duplicate header rendering issues + +4. **`plugins/app/src/extensions/AppRoutes.tsx`** + + - Simplified to focus on top-level routing only + - Removed duplicate header orchestration logic + - Removed debug console.log statements + - Sub-page routing is now handled by PageBlueprint + +5. **`plugins/app-visualizer/src/plugin.tsx`** + + - Added import for `SubPageBlueprint` + - Cleaned up unused `SubRouteRef` declarations + - Now properly uses SubPageBlueprint pattern + +6. **`plugins/api-docs/src/alpha.tsx`** + + - Removed `HeaderActionBlueprint` usage (feature temporarily removed) + - Cleaned up unused imports + +7. **`plugins/catalog/src/alpha/plugin.tsx`** + - Removed `display` property (not yet supported in plugin options) + +## Architecture Overview + +### SubPageBlueprint API + +```typescript +const mySubPage = SubPageBlueprint.make({ + attachTo: { id: 'page:my-plugin', input: 'pages' }, + name: 'overview', + params: { + path: '/overview', // Relative path from parent + title: 'Overview', // Tab title + loader: () => import('./components/Overview').then(m => ), + }, +}); +``` + +### How It Works + +1. **Parent Page Declaration** (without loader, with sub-pages): + + ```typescript + const parentPage = PageBlueprint.make({ + params: { + path: '/visualizer', + routeRef: rootRouteRef, + title: 'Visualizer', + // No loader - will show tabs + }, + }); + ``` + +2. **Sub-Page Declarations**: + + ```typescript + const treePage = SubPageBlueprint.make({ + attachTo: { id: 'page:app-visualizer', input: 'pages' }, + name: 'tree', + params: { + path: '/tree', + title: 'Tree', + loader: () => import('./TreeView'), + }, + }); + ``` + +3. **Rendering Flow**: + ``` + AppRoutes + └─> PageBlueprint (detects sub-pages via inputs.pages) + ├─> Header (with tabs) + └─> Routes + ├─> /tree -> TreePage content + ├─> /details -> DetailsPage content + └─> /text -> TextPage content + ``` + +## Key Design Decisions + +### 1. **Simplified AppRoutes** + +- Removed header orchestration from AppRoutes +- AppRoutes now only handles top-level routing +- PageBlueprint is responsible for its own header rendering + +### 2. **No Header Duplication** + +- Pages with loaders: Header rendered by PageBlueprint +- Pages with sub-pages: Header with tabs rendered by PageBlueprint +- No duplicate headers between AppRoutes and PageBlueprint + +### 3. **Plugin-Relative Attachment** + +- Sub-pages attach using: `{ id: 'page:plugin-id', input: 'pages' }` +- Follows the pattern described in header.md and the RFC + +### 4. **Lazy Loading** + +- All sub-pages use lazy loading via `ExtensionBoundary.lazy()` +- Improves initial page load performance + +### 5. **Removed Features (Temporarily)** + +- **HeaderActionBlueprint**: Removed from AppRoutes orchestration (needs redesign) +- **Plugin display metadata**: Removed (not yet supported in plugin options type) +- **SubRouteRef support**: Simplified to only support RouteRef + +## Example Usage: App Visualizer + +The app-visualizer plugin demonstrates the complete pattern: + +```typescript +// Parent page without loader +const appVisualizerPage = PageBlueprint.make({ + params: { + path: '/visualizer', + routeRef: rootRouteRef, + title: 'Visualizer', + }, +}); + +// Three sub-pages attached to parent +const appVisualizerTreePage = SubPageBlueprint.make({ + attachTo: { id: 'page:app-visualizer', input: 'pages' }, + name: 'tree', + params: { + path: '/tree', + title: 'Tree', + loader: () => + import('./components/AppVisualizerPage/TreeVisualizer').then(m => { + const Component = () => { + const appTreeApi = useApi(appTreeApiRef); + const { tree } = appTreeApi.getTree(); + return ; + }; + return ; + }), + }, +}); + +// Similar for details and text pages... +``` + +## Technical Benefits + +1. **Declarative**: Sub-pages are extensions that can be discovered and manipulated +2. **Type-Safe**: Full TypeScript support with proper type checking +3. **Consistent**: All pages get headers automatically +4. **Flexible**: Supports both simple pages and complex tabbed interfaces +5. **Performant**: Lazy loading of sub-page content + +## What's Still Missing + +1. **HeaderActionBlueprint Integration**: Needs to be redesigned to work without AppRoutes orchestration +2. **Plugin Display Metadata**: `display: { icon, title }` type support in plugin options +3. **SubRouteRef Support**: Currently only RouteRef is supported for routing +4. **Sidebar Navigation**: The RFC's primary goal for Portal (sidebar navigation API) +5. **Breadcrumbs**: Mentioned in RFC but not implemented + +## Testing Recommendations + +1. **Test sub-page navigation**: Click through tabs in app-visualizer +2. **Test lazy loading**: Verify sub-pages load on demand +3. **Test routing**: Verify URLs update correctly when switching tabs +4. **Test nested routes**: Verify sub-pages render at correct paths +5. **Test simple pages**: Verify pages with loaders still work + +## Migration Path for Other Plugins + +To add sub-pages to an existing plugin: + +1. **Update parent page** to remove loader: + + ```typescript + const myPage = PageBlueprint.make({ + params: { + path: '/my-plugin', + title: 'My Plugin', + // Remove loader to enable sub-pages + }, + }); + ``` + +2. **Create sub-pages**: + + ```typescript + const mySubPage = SubPageBlueprint.make({ + attachTo: { id: 'page:my-plugin', input: 'pages' }, + name: 'overview', + params: { + path: '/overview', + title: 'Overview', + loader: () => import('./Overview'), + }, + }); + ``` + +3. **Add to plugin extensions**: + ```typescript + export default createFrontendPlugin({ + extensions: [ + myPage, + mySubPage, + // ... other sub-pages + ], + }); + ``` + +## Type Safety + +All type errors have been resolved: + +- ✅ No unused imports +- ✅ Proper type checking for all parameters +- ✅ Correct extension data types +- ✅ Valid blueprint definitions + +## Next Steps + +1. **Add tests** for SubPageBlueprint and updated PageBlueprint +2. **Implement HeaderActionBlueprint redesign** (perhaps as page-level extensions) +3. **Add plugin display metadata support** in plugin options types +4. **Implement sidebar navigation API** (RFC primary goal) +5. **Add breadcrumb support** for navigation hierarchy +6. **Document the pattern** for community plugin authors +7. **Update existing plugins** to use the new pattern + +## Conclusion + +The SubPageBlueprint implementation successfully delivers on the core technical requirements from the RFC: + +- ✅ Sub-pages are represented as extensions +- ✅ Sub-pages have titles and relative paths +- ✅ Sub-pages attach to parent pages +- ✅ Two levels of navigation (page + sub-pages) +- ✅ Consistent headers across plugins +- ✅ Plugin-relative attachment points + +The implementation is type-safe, follows Backstage patterns, and provides a clean API for plugin authors to create rich, multi-page experiences within their plugins. diff --git a/swappable-component-refactor-summary.md b/swappable-component-refactor-summary.md new file mode 100644 index 0000000000..86393f97a4 --- /dev/null +++ b/swappable-component-refactor-summary.md @@ -0,0 +1,337 @@ +# Swappable Component Architecture Refactor + +## Problem + +The initial implementation had `@backstage/frontend-plugin-api` directly importing `Header` from `@backstage/ui`, which violated the package dependency architecture: + +- `@backstage/frontend-plugin-api` is a low-level API package +- `@backstage/ui` is a higher-level UI component library +- API packages should not depend on UI libraries + +## Solution + +Implemented a **swappable component pattern** that: + +1. Provides a default implementation using plain HTML elements in `@backstage/frontend-plugin-api` +2. Ships the `@backstage/ui` Header implementation via the `@backstage/plugin-app` plugin +3. Allows apps to override the component with custom implementations + +This follows the same pattern used for other swappable components like `Progress`, `NotFoundErrorPage`, and `ErrorDisplay`. + +## Architecture + +### 1. Created PageWrapper Swappable Component + +**Location:** `packages/frontend-plugin-api/src/components/PageWrapper.tsx` + +```typescript +export interface PageWrapperProps { + title?: string; + tabs?: PageTab[]; + children?: ReactNode; +} + +// Default implementation using plain HTML +function DefaultPageWrapper(props: PageWrapperProps): JSX.Element { + // Uses plain
,
,
, ); }); + + 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 2dbdc3da70..f88ba48a38 100644 --- a/plugins/app-react/src/blueprints/NavContentBlueprint.ts +++ b/plugins/app-react/src/blueprints/NavContentBlueprint.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ComponentType } from 'react'; import { AppNode, IconComponent, @@ -30,7 +31,7 @@ import { * * @public */ -export interface NavItem { +export interface NavContentNavItem { /** The app node of the page extension that this nav item points to */ node: AppNode; /** The resolved route path */ @@ -43,19 +44,36 @@ export interface NavItem { 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 NavItems { +export interface NavContentNavItems { /** Take an item by extension ID, removing it from the collection. */ - take(id: string): NavItem | undefined; + take(id: string): NavContentNavItem | undefined; /** All items not yet taken. */ - rest(): NavItem[]; + rest(): NavContentNavItem[]; /** Create a copy of the collection preserving the current taken state. */ - clone(): NavItems; + clone(): NavContentNavItems; + /** Create a renderer that wraps take/rest to return pre-rendered elements. */ + withComponent( + Component: ComponentType, + ): NavContentNavItemsWithComponent; } /** @@ -68,7 +86,7 @@ export interface NavContentComponentProps { * Nav items auto-discovered from page extensions, with take/rest semantics * for placing specific items in specific positions. */ - navItems: NavItems; + navItems: NavContentNavItems; /** * Flat list of nav items for simple rendering. Use `navItems` for more diff --git a/plugins/app-react/src/blueprints/index.ts b/plugins/app-react/src/blueprints/index.ts index 8a3e518749..318691c8db 100644 --- a/plugins/app-react/src/blueprints/index.ts +++ b/plugins/app-react/src/blueprints/index.ts @@ -20,8 +20,9 @@ export { NavContentBlueprint } from './NavContentBlueprint'; export type { NavContentComponent, NavContentComponentProps, - NavItem, - NavItems, + NavContentNavItem, + NavContentNavItemsWithComponent, + NavContentNavItems, } from './NavContentBlueprint'; export { RouterBlueprint } from './RouterBlueprint'; export { SignInPageBlueprint } from './SignInPageBlueprint'; diff --git a/plugins/app/src/extensions/AppNav.tsx b/plugins/app/src/extensions/AppNav.tsx index 858fda15da..ffa6ced7b0 100644 --- a/plugins/app/src/extensions/AppNav.tsx +++ b/plugins/app/src/extensions/AppNav.tsx @@ -31,24 +31,24 @@ import { NavContentBlueprint, NavContentComponent, NavContentComponentProps, - NavItem, - NavItems, + NavContentNavItem, + NavContentNavItems, } from '@backstage/plugin-app-react'; import { Sidebar, SidebarItem } from '@backstage/core-components'; import { useMemo } from 'react'; -class NavItemBag implements NavItems { - readonly #items: NavItem[]; - readonly #index: Map; +class NavItemBag implements NavContentNavItems { + readonly #items: NavContentNavItem[]; + readonly #index: Map; readonly #taken: Set; - constructor(items: NavItem[], taken?: Iterable) { + 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): NavItem | undefined { + take(id: string): NavContentNavItem | undefined { const item = this.#index.get(id); if (item) { this.#taken.add(id); @@ -56,13 +56,31 @@ class NavItemBag implements NavItems { return item; } - rest(): NavItem[] { + rest(): NavContentNavItem[] { return this.#items.filter(item => !this.#taken.has(item.node.spec.id)); } - clone(): NavItems { + 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) { @@ -138,7 +156,7 @@ function NavContentRenderer(props: { >(props.legacyNavItems.map(item => [item.routeRef, item])); const pageNodes = routesNode.edges.attachments.get('routes') ?? []; - const items = pageNodes.flatMap((node): NavItem[] => { + const items = pageNodes.flatMap((node): NavContentNavItem[] => { if (!node.instance || node.spec.disabled) { return []; } From 018ca876d0b6c775d6f063ca30375e2abc471a15 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Feb 2026 11:32:25 +0100 Subject: [PATCH 32/47] add plugin title and icons Signed-off-by: Patrik Oldsberg --- .changeset/plugin-title-and-icon.md | 13 +++++++++++++ plugins/api-docs/src/alpha.tsx | 2 ++ .../src/alpha/plugin.tsx | 2 ++ plugins/catalog/src/alpha/pages.tsx | 4 ++++ plugins/catalog/src/alpha/plugin.tsx | 3 +++ plugins/devtools/src/alpha/plugin.tsx | 2 ++ plugins/home/src/alpha.tsx | 2 ++ plugins/scaffolder/src/alpha/plugin.tsx | 3 +++ plugins/search/src/alpha.tsx | 2 ++ plugins/techdocs/src/alpha/index.tsx | 2 ++ plugins/user-settings/src/alpha.tsx | 2 ++ 11 files changed, 37 insertions(+) create mode 100644 .changeset/plugin-title-and-icon.md 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/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/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/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 e0a9d24708..8db026016a 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -16,6 +16,7 @@ import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import CategoryIcon from '@material-ui/icons/Category'; import { createComponentRouteRef, @@ -38,6 +39,8 @@ import contextMenuItems from './contextMenuItems'; /** @alpha */ export default createFrontendPlugin({ pluginId: 'catalog', + title: 'Catalog', + icon: , info: { packageJson: () => import('../../package.json'), }, 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/src/alpha.tsx b/plugins/home/src/alpha.tsx index 1b91effa29..194f322317 100644 --- a/plugins/home/src/alpha.tsx +++ b/plugins/home/src/alpha.tsx @@ -208,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/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/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/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/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: { From 273655886fc8460bc28ad01bda0c67a97a0284e1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Feb 2026 13:06:49 +0100 Subject: [PATCH 33/47] changesets: remove unnecessary changeset Signed-off-by: Patrik Oldsberg --- .changeset/ui-header-spacing.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/ui-header-spacing.md diff --git a/.changeset/ui-header-spacing.md b/.changeset/ui-header-spacing.md deleted file mode 100644 index e9fc82d15d..0000000000 --- a/.changeset/ui-header-spacing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/ui': patch ---- - -Adjusted `Header` component spacing to remove bottom margins from the toolbar and tab wrapper. From 85261a3b3cf08d7e37b218aa6d80304a711cb67e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Feb 2026 13:08:08 +0100 Subject: [PATCH 34/47] frontend-plugin-api: use react element for header action Signed-off-by: Patrik Oldsberg --- .../src/blueprints/HeaderActionBlueprint.tsx | 24 +++++++------- .../DefaultHeaderActionsApi.test.tsx | 33 +++++++------------ .../DefaultHeaderActionsApi.tsx | 16 ++------- .../app/src/extensions/HeaderActionsApi.ts | 7 ++-- 4 files changed, 31 insertions(+), 49 deletions(-) diff --git a/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx index cbc6f6ccf5..c2bf6052b7 100644 --- a/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx @@ -14,16 +14,14 @@ * limitations under the License. */ +import { lazy as reactLazy } from 'react'; +import { ExtensionBoundary } from '../components'; import { + coreExtensionData, createExtensionBlueprint, createExtensionBlueprintParams, - createExtensionDataRef, } from '../wiring'; -const actionDataRef = createExtensionDataRef<() => Promise>().with( - { id: 'core.header-action.loader' }, -); - /** * Creates extensions that provide plugin-scoped header actions. * @@ -37,14 +35,18 @@ const actionDataRef = createExtensionDataRef<() => Promise>().with( export const HeaderActionBlueprint = createExtensionBlueprint({ kind: 'header-action', attachTo: { id: 'api:app/header-actions', input: 'actions' }, - output: [actionDataRef], - dataRefs: { - action: actionDataRef, - }, + output: [coreExtensionData.reactElement], defineParams(params: { loader: () => Promise }) { return createExtensionBlueprintParams(params); }, - *factory(params) { - yield actionDataRef(params.loader); + *factory(params, { node }) { + const LazyAction = reactLazy(() => + params.loader().then(element => ({ default: () => element })), + ); + yield coreExtensionData.reactElement( + + + , + ); }, }); diff --git a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx b/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx index dd881a786f..4713896f40 100644 --- a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx +++ b/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.test.tsx @@ -18,17 +18,15 @@ import { render, screen } from '@testing-library/react'; import { DefaultHeaderActionsApi } from './DefaultHeaderActionsApi'; describe('DefaultHeaderActionsApi', () => { - it('should return actions for a specific plugin', async () => { + it('should return actions for a specific plugin', () => { const api = DefaultHeaderActionsApi.fromActions([ { - loader: async () => , + element: , pluginId: 'plugin-a', - nodeId: 'plugin-header-action:plugin-a/action-a', }, { - loader: async () => , + element: , pluginId: 'plugin-b', - nodeId: 'plugin-header-action:plugin-b/action-b', }, ]); @@ -36,34 +34,31 @@ describe('DefaultHeaderActionsApi', () => { expect(api.getHeaderActions('plugin-b')).toHaveLength(1); render(<>{api.getHeaderActions('plugin-a')}); - await expect( - screen.findByRole('button', { name: 'Action A' }), - ).resolves.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Action A' }), + ).toBeInTheDocument(); }); it('should return an empty array for unknown plugins', () => { const api = DefaultHeaderActionsApi.fromActions([ { - loader: async () => Action, + element: Action, pluginId: 'plugin-a', - nodeId: 'plugin-header-action:plugin-a/action', }, ]); expect(api.getHeaderActions('unknown-plugin')).toEqual([]); }); - it('should group multiple actions by plugin', async () => { + it('should group multiple actions by plugin', () => { const api = DefaultHeaderActionsApi.fromActions([ { - loader: async () => , + element: , pluginId: 'plugin-a', - nodeId: 'plugin-header-action:plugin-a/first', }, { - loader: async () => , + element: , pluginId: 'plugin-a', - nodeId: 'plugin-header-action:plugin-a/second', }, ]); @@ -71,11 +66,7 @@ describe('DefaultHeaderActionsApi', () => { expect(actions).toHaveLength(2); render(<>{actions}); - await expect( - screen.findByRole('button', { name: 'First' }), - ).resolves.toBeInTheDocument(); - await expect( - screen.findByRole('button', { name: 'Second' }), - ).resolves.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'First' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Second' })).toBeInTheDocument(); }); }); diff --git a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx b/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx index 13f94da55b..d165410a4d 100644 --- a/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx +++ b/plugins/app/src/apis/HeaderActionsApi/DefaultHeaderActionsApi.tsx @@ -14,13 +14,12 @@ * limitations under the License. */ -import { Suspense, ReactNode, lazy } from 'react'; +import { ReactNode } from 'react'; import { type HeaderActionsApi } from '@backstage/frontend-plugin-api'; type ActionInput = { - loader: () => Promise; + element: JSX.Element; pluginId: string; - nodeId: string; }; /** @@ -45,16 +44,7 @@ export class DefaultHeaderActionsApi implements HeaderActionsApi { actionsByPlugin.set(action.pluginId, pluginActions); } - const LazyAction = lazy(async () => { - const element = await action.loader(); - return { default: () => element }; - }); - - pluginActions.push( - - - , - ); + pluginActions.push(action.element); } return new DefaultHeaderActionsApi(actionsByPlugin); diff --git a/plugins/app/src/extensions/HeaderActionsApi.ts b/plugins/app/src/extensions/HeaderActionsApi.ts index 6768bb3655..b1779b09a3 100644 --- a/plugins/app/src/extensions/HeaderActionsApi.ts +++ b/plugins/app/src/extensions/HeaderActionsApi.ts @@ -15,7 +15,7 @@ */ import { - HeaderActionBlueprint, + coreExtensionData, headerActionsApiRef, createExtensionInput, ApiBlueprint, @@ -28,7 +28,7 @@ import { DefaultHeaderActionsApi } from '../apis/HeaderActionsApi'; export const HeaderActionsApi = ApiBlueprint.makeWithOverrides({ name: 'header-actions', inputs: { - actions: createExtensionInput([HeaderActionBlueprint.dataRefs.action]), + actions: createExtensionInput([coreExtensionData.reactElement]), }, factory: (originalFactory, { inputs }) => { return originalFactory(defineParams => @@ -38,9 +38,8 @@ export const HeaderActionsApi = ApiBlueprint.makeWithOverrides({ factory: () => { return DefaultHeaderActionsApi.fromActions( inputs.actions.map(actionInput => ({ - loader: actionInput.get(HeaderActionBlueprint.dataRefs.action), + element: actionInput.get(coreExtensionData.reactElement), pluginId: actionInput.node.spec.plugin.pluginId, - nodeId: actionInput.node.spec.id, })), ); }, From 65ba8200e3c9ef3ca37fae1d8ff811ee766328ac Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Feb 2026 13:08:22 +0100 Subject: [PATCH 35/47] changesets: add missing create-app changeset for new nav content API Signed-off-by: Patrik Oldsberg --- .changeset/create-app-nav-sidebar.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/create-app-nav-sidebar.md 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. From b91e17f622fcf1ceb5f82e17afb761925a36ed14 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Feb 2026 13:35:35 +0100 Subject: [PATCH 36/47] snapshot fixes for tests Signed-off-by: Patrik Oldsberg --- .../src/convertLegacyPlugin.test.tsx | 2 + .../frontend-defaults/src/createApp.test.tsx | 4 +- .../src/blueprints/PageBlueprint.test.tsx | 71 ++++++++++++++++++- 3 files changed, 75 insertions(+), 2 deletions(-) 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/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 3a9ea91bec..6770e5f559 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -388,11 +388,13 @@ describe('createApp', () => { + ] + ] @@ -408,7 +410,7 @@ describe('createApp', () => { content [ routes [ - + ] ] diff --git a/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/PageBlueprint.test.tsx index c5c34fc02f..0c4824e4ad 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], @@ -206,6 +274,7 @@ describe('PageBlueprint', () => { "core.reactElement", "core.routing.path", "core.routing.ref", + "core.title", ], } `); From 0f90fda9cc8ec609ad7e2880910ca3faef42dc59 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Feb 2026 21:06:33 +0100 Subject: [PATCH 37/47] remove planning docs Signed-off-by: Patrik Oldsberg --- header-architecture-summary.md | 419 ------------------------ header.md | 43 --- subpage-implementation-summary.md | 270 --------------- swappable-component-refactor-summary.md | 337 ------------------- 4 files changed, 1069 deletions(-) delete mode 100644 header-architecture-summary.md delete mode 100644 header.md delete mode 100644 subpage-implementation-summary.md delete mode 100644 swappable-component-refactor-summary.md diff --git a/header-architecture-summary.md b/header-architecture-summary.md deleted file mode 100644 index 30173f353a..0000000000 --- a/header-architecture-summary.md +++ /dev/null @@ -1,419 +0,0 @@ -# Page Header Architecture - Implementation Summary - -## Overview - -This branch implements a new page header architecture for Backstage's New Frontend System (NFS) that provides: - -- Consistent headers across all plugin pages -- Support for sub-pages with tab navigation -- Header actions (buttons/controls in the header) -- Plugin-level display metadata (titles and icons) - -## Architectural Changes - -### 1. New Blueprint: `HeaderActionBlueprint` - -**Location:** `packages/frontend-plugin-api/src/blueprints/HeaderActionBlueprint.tsx` - -A new extension blueprint that allows plugins to register actions (buttons, controls) that appear in the page header. - -**Key Features:** - -- Attaches to `app/routes` with input `headerActions` -- Outputs `coreExtensionData.reactElement` -- Uses lazy loading with `ExtensionBoundary` - -**Usage Example** (from api-docs plugin): - -```typescript -HeaderActionBlueprint.make({ - name: 'register-api', - params: { - loader: async () => - compatWrapper( - Register Existing API, - ), - }, -}); -``` - -### 2. Enhanced `PageBlueprint` - -**Location:** `packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx` - -The `PageBlueprint` has been significantly enhanced to support sub-pages and tabbed navigation. - -**New Features:** - -#### Inputs - -- New `pages` input that accepts sub-pages with: - - `routePath` - - `routeRef` (optional) - - `reactElement` - - `title` (optional) - -#### Outputs - -- Added `title` output (optional) - -#### Configuration - -- New `title` config schema property - -#### Parameters - -- `title?: string` - Page title displayed in header -- `loader` is now optional (not required when using sub-pages) -- `routeRef` now accepts both `RouteRef` and `SubRouteRef` - -#### Behavior Changes - -1. **With loader (traditional page):** Renders Header + page content -2. **Without loader (parent page with tabs):** Renders Header with tabs populated from `inputs.pages` - -### 3. Central Routing Changes: `AppRoutes` - -**Location:** `plugins/app/src/extensions/AppRoutes.tsx` - -The core routing extension has been substantially refactored to orchestrate the new header architecture. - -**Key Implementation Details:** - -#### Header Action Aggregation - -```typescript -const headerActionsByPluginId = new Map>(); -``` - -- Collects all header actions from inputs -- Groups them by plugin ID -- Passes them to the Header component via `customActions` prop - -#### Page Aggregation by Path - -```typescript -const pagesByPath = new Map< - string, - Array<{ element: JSX.Element; title: string; node: AppNode }> ->(); -``` - -- Groups pages by their route path -- Enables detection of pages with sub-pages -- Stores title and node reference for each page - -#### Conditional Rendering Logic - -1. **Multiple pages at same path (tabbed navigation):** - - - Renders `Header` with tabs - - Uses nested `` for tab content - - Tab matching uses prefix strategy - -2. **Single page (traditional):** - - Renders `Header` with plugin title - - Renders page element directly - -### 4. Plugin Display Metadata - -**Location:** `plugins/catalog/src/alpha/plugin.tsx` - -Plugins can now define display metadata: - -```typescript -export default createFrontendPlugin({ - pluginId: 'catalog', - info: { - packageJson: () => import('../../package.json'), - }, - display: { - icon: 'catalog', - title: 'Software Catalog', - }, - // ... -}); -``` - -### 5. Header Component Styling Updates - -**Location:** `packages/ui/src/components/Header/Header.module.css` - -- Commented out default `margin-bottom` on `.bui-HeaderToolbar` -- Allows tighter integration between header and content -- Maintains conditional margin for tabbed headers - -## Implementation Examples - -### Example 1: App Visualizer (Multi-page with Tabs) - -**Location:** `plugins/app-visualizer/src/plugin.tsx` - -Demonstrates the sub-page pattern: - -1. **Parent page** (no loader): - -```typescript -const appVisualizerPage = PageBlueprint.make({ - params: { - path: '/visualizer', - routeRef: rootRouteRef, - title: 'Visualizer', - // No loader - will show tabs - }, -}); -``` - -2. **Sub-pages** (using `SubPageBlueprint` - not yet fully implemented): - -```typescript -const appVisualizerTreePage = SubPageBlueprint.make({ - attachTo: { id: 'page:app-visualizer', input: 'pages' }, - name: 'tree', - params: { - path: '/tree', - title: 'Tree', - loader: () => import('./components/...'), - }, -}); -``` - -Multiple sub-pages are defined: - -- Tree view (`/tree`) -- Detailed view (`/details`) -- Text view (`/text`) - -### Example 2: API Docs (Header Actions) - -**Location:** `plugins/api-docs/src/alpha.tsx` - -Demonstrates header actions: - -```typescript -HeaderActionBlueprint.make({ - name: 'register-api', - params: { - loader: async () => - compatWrapper( - Register Existing API, - ), - }, -}); -``` - -## Data Flow - -``` -┌─────────────────────────────────────────────────────────────┐ -│ AppRoutes │ -│ (Central orchestrator) │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌─────────────┴─────────────┐ - │ │ - ▼ ▼ - ┌───────────────────┐ ┌────────────────────┐ - │ headerActions │ │ routes │ - │ input │ │ input │ - └───────────────────┘ └────────────────────┘ - │ │ - │ │ - ▼ ▼ - Group by plugin ID Group by route path - │ │ - │ │ - └─────────────┬─────────────┘ - │ - ▼ - ┌─────────────────┐ - │ Route Renderer │ - └─────────────────┘ - │ - ┌─────────────┴──────────────┐ - │ │ - ▼ ▼ - ┌──────────────────┐ ┌──────────────────────┐ - │ Single Page │ │ Multi-page (Tabs) │ - │ │ │ │ - │ Header + │ │ Header (w/ tabs) + │ - │ Page Element │ │ Nested Routes │ - └──────────────────┘ └──────────────────────┘ -``` - -## Extension Data Types Used - -The implementation relies on these core extension data types: - -- `coreExtensionData.routePath` - Route paths for pages -- `coreExtensionData.routeRef` - Route references for navigation -- `coreExtensionData.reactElement` - React components to render -- `coreExtensionData.title` - Page/sub-page titles - -## Technical Considerations - -### 1. Plugin-Relative Attachment Points - -The implementation uses string-based attachment points: - -```typescript -attachTo: { id: 'page:app-visualizer', input: 'pages' } -``` - -**Future consideration:** TypeScript-based attachment points for type safety: - -```typescript -attachTo: appVisualizerPage.inputs.pages; -``` - -### 2. SubPageBlueprint - -The code references `SubPageBlueprint` which is not yet fully implemented in the blueprint exports. This suggests it's either: - -- Still being developed -- Implemented locally for testing -- Planned for future implementation - -### 3. Header Component Integration - -The `Header` component from `@backstage/ui` is used directly in: - -- `PageBlueprint` rendering -- `AppRoutes` conditional rendering - -This component accepts: - -- `title` - Page title -- `tabs` - Array of tab configurations -- `customActions` - Array of React nodes for header actions - -### 4. Route Matching Strategy - -Sub-pages use different route matching: - -- Parent pages: Match with trailing `/*` for catch-all -- Tab content: Direct path matching -- Tab component: Uses `matchStrategy: 'prefix'` - -## Outstanding Questions & TODOs - -From `header.md`: - -### Decisions Needed - -- [ ] Should we add titles to all plugins? -- [ ] Should we add icons to all plugins? -- [ ] Is `HeaderActionBlueprint` too specific? (Note: "a bit wonky") -- [ ] PageBlueprint additions or ContentBlueprint? - -### Implementation Items - -- [ ] Ship the Figma design -- [ ] TopBarActionBlueprint? (alternative naming consideration) -- [ ] Add `icon` and `title` to plugin info (partially done) -- [ ] Add support for plugin-relative attachment points -- [ ] Consider TypeScript-based attachment points -- [ ] Add `coreExtensionData.navTarget` -- [ ] Add swappable component for PageBlueprint React element - -## Breaking Changes - -This implementation introduces breaking changes: - -1. **PageBlueprint API changes:** - - - New optional `title` parameter - - `loader` is now optional - - `routeRef` accepts `SubRouteRef` in addition to `RouteRef` - -2. **AppRoutes behavior:** - - - Automatically wraps all pages with Header component - - Changes route structure for multi-page plugins - -3. **Header styling:** - - Removes default bottom margin on headers - -## Migration Path - -For existing plugins to adopt the new header architecture: - -1. **Add display metadata to plugin:** - -```typescript -display: { - icon: 'plugin-icon', - title: 'Plugin Name', -} -``` - -2. **Add titles to pages:** - -```typescript -PageBlueprint.make({ - params: { - title: 'Page Title', - // ... other params - }, -}); -``` - -3. **Optional - Add header actions:** - -```typescript -HeaderActionBlueprint.make({ - params: { - loader: () => , - }, -}); -``` - -4. **Optional - Add sub-pages for tabbed navigation:** - Create multiple pages at the same route path with different titles. - -## Files Changed Summary - -| File | Lines Changed | Type of Change | -| ------------------------------------------ | ------------- | ---------------------- | -| `header.md` | +43 | Documentation | -| `HeaderActionBlueprint.tsx` | +35 | New Blueprint | -| `PageBlueprint.tsx` | +52/-12 | Enhancement | -| `blueprints/index.ts` | +1 | Export | -| `Header.module.css` | +1/-1 | Styling | -| `plugins/api-docs/src/alpha.tsx` | +13/-1 | Example Implementation | -| `plugins/app-visualizer/src/plugin.tsx` | +120/-1 | Example Implementation | -| `plugins/app/package.json` | +1 | Dependency | -| `plugins/app/src/extensions/AppRoutes.tsx` | +77/-2 | Core Logic | -| `plugins/catalog/src/alpha/plugin.tsx` | +9/-1 | Display Metadata | -| `yarn.lock` | +1 | Dependency | - -**Total: 11 files, ~354 insertions, ~20 deletions** - -## Next Steps - -1. **Complete SubPageBlueprint implementation** - Currently referenced but not fully exported -2. **Decide on naming** - HeaderActionBlueprint vs TopBarActionBlueprint -3. **Type-safe attachment points** - Consider TypeScript-based approach -4. **Migration guide** - Document for plugin authors -5. **Testing** - Comprehensive tests for new routing logic -6. **Figma alignment** - Ensure implementation matches design specs -7. **Plugin adoption** - Roll out to all core plugins -8. **API documentation** - Update API docs for new blueprints - -## Architecture Benefits - -1. **Consistency:** All pages have consistent header treatment -2. **Flexibility:** Supports both simple pages and complex tabbed interfaces -3. **Extensibility:** Header actions allow plugins to add controls -4. **Navigation:** Two-level navigation (page + sub-pages) is now native -5. **Declarative:** Uses extension system patterns for discoverability -6. **Centralized:** AppRoutes orchestrates header rendering for consistency - -## Potential Concerns - -1. **Complexity:** AppRoutes has increased complexity with conditional rendering -2. **Performance:** Multiple map operations on every render -3. **Breaking:** Changes existing PageBlueprint behavior -4. **Incomplete:** SubPageBlueprint appears incomplete -5. **Debugging:** Console.log statements present (need cleanup) -6. **Migration:** All existing plugins will need updates for full adoption diff --git a/header.md b/header.md deleted file mode 100644 index d3cf8fc4cd..0000000000 --- a/header.md +++ /dev/null @@ -1,43 +0,0 @@ -## TODO - -- [ ] Shipping https://www.figma.com/design/zJxHainw6yO7L9oEsStAh7/Header?node- [ ]id=103- [ ]5709&t=HNkpc2MqdUbaN8Jz- [ ]1 -- [ ] TopBarActionBlueprint? -- [ ] PageBlueprint additions or ContentBlueprint? -- [ ] Should we add titles to all plugins? -- [ ] Should we add icons to all plugins? - -- [ ] Add `icon` and `title` to the plugin info - -## Notes - -- HeaderActionBlueprint is a bit wonky, too specific? - -## Requirements - -- Consistency of headers across all plugins in NFS -- Possibility to put breadcrumbs in the top bar -- Two levels of navigation for plugins - page + sub pages - -## Technical Requirements - -- Sub pages must be represented in some form as extensions -- Sub pages must have a title and path relative to the parent page -- Plugins must have titles and icons -- Sub pages must be attachments of the parent page - -## Potential Solutions - -### Make all(\*) pages navigable, get rid of nav items - -## Implementation Plan - -- Add support for plugin-relative attachment points, i.e. `attachTo: { id: 'page:{pluginId}', input: 'pages' }` - - Alternative: typescript-based attachment points, i.e. `attachTo: appVisualizerPage` or `attachTo: appVisualizerPage.inputs.pages` -- Add a `SubPageBlueprint` that attaches to the `pages` of `PageBlueprint`, using plugin-relative attachment -- Add a swappable component for the `PageBlueprint` React element -- Add `title` param and output to `PageBlueprint` - -To consider: - -- Add `display` options for plugins -- Add `coreExtensionData.navTarget`, either marker or with actual data diff --git a/subpage-implementation-summary.md b/subpage-implementation-summary.md deleted file mode 100644 index 496dfd8fe4..0000000000 --- a/subpage-implementation-summary.md +++ /dev/null @@ -1,270 +0,0 @@ -# SubPageBlueprint Implementation Summary - -## What Was Implemented - -I've successfully implemented the `SubPageBlueprint` pattern for the header architecture, addressing one of the critical gaps identified in the RFC alignment analysis. - -## Files Created/Modified - -### New Files - -1. **`packages/frontend-plugin-api/src/blueprints/SubPageBlueprint.tsx`** - - New blueprint for creating sub-pages that attach to parent pages - - Outputs: `routePath`, `reactElement`, `title` - - Supports lazy loading of sub-page content - - Requires users to specify `attachTo` to target their parent page - -### Modified Files - -2. **`packages/frontend-plugin-api/src/blueprints/index.ts`** - - - Exported `SubPageBlueprint` for public use - -3. **`packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx`** - - - Enhanced to handle sub-pages via `inputs.pages` - - Three rendering modes: - - **With loader**: Renders Header + lazy-loaded content - - **With sub-pages**: Renders Header with tabs + nested Routes for sub-pages - - **Empty**: Renders just Header - - Removed duplicate header rendering issues - -4. **`plugins/app/src/extensions/AppRoutes.tsx`** - - - Simplified to focus on top-level routing only - - Removed duplicate header orchestration logic - - Removed debug console.log statements - - Sub-page routing is now handled by PageBlueprint - -5. **`plugins/app-visualizer/src/plugin.tsx`** - - - Added import for `SubPageBlueprint` - - Cleaned up unused `SubRouteRef` declarations - - Now properly uses SubPageBlueprint pattern - -6. **`plugins/api-docs/src/alpha.tsx`** - - - Removed `HeaderActionBlueprint` usage (feature temporarily removed) - - Cleaned up unused imports - -7. **`plugins/catalog/src/alpha/plugin.tsx`** - - Removed `display` property (not yet supported in plugin options) - -## Architecture Overview - -### SubPageBlueprint API - -```typescript -const mySubPage = SubPageBlueprint.make({ - attachTo: { id: 'page:my-plugin', input: 'pages' }, - name: 'overview', - params: { - path: '/overview', // Relative path from parent - title: 'Overview', // Tab title - loader: () => import('./components/Overview').then(m => ), - }, -}); -``` - -### How It Works - -1. **Parent Page Declaration** (without loader, with sub-pages): - - ```typescript - const parentPage = PageBlueprint.make({ - params: { - path: '/visualizer', - routeRef: rootRouteRef, - title: 'Visualizer', - // No loader - will show tabs - }, - }); - ``` - -2. **Sub-Page Declarations**: - - ```typescript - const treePage = SubPageBlueprint.make({ - attachTo: { id: 'page:app-visualizer', input: 'pages' }, - name: 'tree', - params: { - path: '/tree', - title: 'Tree', - loader: () => import('./TreeView'), - }, - }); - ``` - -3. **Rendering Flow**: - ``` - AppRoutes - └─> PageBlueprint (detects sub-pages via inputs.pages) - ├─> Header (with tabs) - └─> Routes - ├─> /tree -> TreePage content - ├─> /details -> DetailsPage content - └─> /text -> TextPage content - ``` - -## Key Design Decisions - -### 1. **Simplified AppRoutes** - -- Removed header orchestration from AppRoutes -- AppRoutes now only handles top-level routing -- PageBlueprint is responsible for its own header rendering - -### 2. **No Header Duplication** - -- Pages with loaders: Header rendered by PageBlueprint -- Pages with sub-pages: Header with tabs rendered by PageBlueprint -- No duplicate headers between AppRoutes and PageBlueprint - -### 3. **Plugin-Relative Attachment** - -- Sub-pages attach using: `{ id: 'page:plugin-id', input: 'pages' }` -- Follows the pattern described in header.md and the RFC - -### 4. **Lazy Loading** - -- All sub-pages use lazy loading via `ExtensionBoundary.lazy()` -- Improves initial page load performance - -### 5. **Removed Features (Temporarily)** - -- **HeaderActionBlueprint**: Removed from AppRoutes orchestration (needs redesign) -- **Plugin display metadata**: Removed (not yet supported in plugin options type) -- **SubRouteRef support**: Simplified to only support RouteRef - -## Example Usage: App Visualizer - -The app-visualizer plugin demonstrates the complete pattern: - -```typescript -// Parent page without loader -const appVisualizerPage = PageBlueprint.make({ - params: { - path: '/visualizer', - routeRef: rootRouteRef, - title: 'Visualizer', - }, -}); - -// Three sub-pages attached to parent -const appVisualizerTreePage = SubPageBlueprint.make({ - attachTo: { id: 'page:app-visualizer', input: 'pages' }, - name: 'tree', - params: { - path: '/tree', - title: 'Tree', - loader: () => - import('./components/AppVisualizerPage/TreeVisualizer').then(m => { - const Component = () => { - const appTreeApi = useApi(appTreeApiRef); - const { tree } = appTreeApi.getTree(); - return ; - }; - return ; - }), - }, -}); - -// Similar for details and text pages... -``` - -## Technical Benefits - -1. **Declarative**: Sub-pages are extensions that can be discovered and manipulated -2. **Type-Safe**: Full TypeScript support with proper type checking -3. **Consistent**: All pages get headers automatically -4. **Flexible**: Supports both simple pages and complex tabbed interfaces -5. **Performant**: Lazy loading of sub-page content - -## What's Still Missing - -1. **HeaderActionBlueprint Integration**: Needs to be redesigned to work without AppRoutes orchestration -2. **Plugin Display Metadata**: `display: { icon, title }` type support in plugin options -3. **SubRouteRef Support**: Currently only RouteRef is supported for routing -4. **Sidebar Navigation**: The RFC's primary goal for Portal (sidebar navigation API) -5. **Breadcrumbs**: Mentioned in RFC but not implemented - -## Testing Recommendations - -1. **Test sub-page navigation**: Click through tabs in app-visualizer -2. **Test lazy loading**: Verify sub-pages load on demand -3. **Test routing**: Verify URLs update correctly when switching tabs -4. **Test nested routes**: Verify sub-pages render at correct paths -5. **Test simple pages**: Verify pages with loaders still work - -## Migration Path for Other Plugins - -To add sub-pages to an existing plugin: - -1. **Update parent page** to remove loader: - - ```typescript - const myPage = PageBlueprint.make({ - params: { - path: '/my-plugin', - title: 'My Plugin', - // Remove loader to enable sub-pages - }, - }); - ``` - -2. **Create sub-pages**: - - ```typescript - const mySubPage = SubPageBlueprint.make({ - attachTo: { id: 'page:my-plugin', input: 'pages' }, - name: 'overview', - params: { - path: '/overview', - title: 'Overview', - loader: () => import('./Overview'), - }, - }); - ``` - -3. **Add to plugin extensions**: - ```typescript - export default createFrontendPlugin({ - extensions: [ - myPage, - mySubPage, - // ... other sub-pages - ], - }); - ``` - -## Type Safety - -All type errors have been resolved: - -- ✅ No unused imports -- ✅ Proper type checking for all parameters -- ✅ Correct extension data types -- ✅ Valid blueprint definitions - -## Next Steps - -1. **Add tests** for SubPageBlueprint and updated PageBlueprint -2. **Implement HeaderActionBlueprint redesign** (perhaps as page-level extensions) -3. **Add plugin display metadata support** in plugin options types -4. **Implement sidebar navigation API** (RFC primary goal) -5. **Add breadcrumb support** for navigation hierarchy -6. **Document the pattern** for community plugin authors -7. **Update existing plugins** to use the new pattern - -## Conclusion - -The SubPageBlueprint implementation successfully delivers on the core technical requirements from the RFC: - -- ✅ Sub-pages are represented as extensions -- ✅ Sub-pages have titles and relative paths -- ✅ Sub-pages attach to parent pages -- ✅ Two levels of navigation (page + sub-pages) -- ✅ Consistent headers across plugins -- ✅ Plugin-relative attachment points - -The implementation is type-safe, follows Backstage patterns, and provides a clean API for plugin authors to create rich, multi-page experiences within their plugins. diff --git a/swappable-component-refactor-summary.md b/swappable-component-refactor-summary.md deleted file mode 100644 index 86393f97a4..0000000000 --- a/swappable-component-refactor-summary.md +++ /dev/null @@ -1,337 +0,0 @@ -# Swappable Component Architecture Refactor - -## Problem - -The initial implementation had `@backstage/frontend-plugin-api` directly importing `Header` from `@backstage/ui`, which violated the package dependency architecture: - -- `@backstage/frontend-plugin-api` is a low-level API package -- `@backstage/ui` is a higher-level UI component library -- API packages should not depend on UI libraries - -## Solution - -Implemented a **swappable component pattern** that: - -1. Provides a default implementation using plain HTML elements in `@backstage/frontend-plugin-api` -2. Ships the `@backstage/ui` Header implementation via the `@backstage/plugin-app` plugin -3. Allows apps to override the component with custom implementations - -This follows the same pattern used for other swappable components like `Progress`, `NotFoundErrorPage`, and `ErrorDisplay`. - -## Architecture - -### 1. Created PageWrapper Swappable Component - -**Location:** `packages/frontend-plugin-api/src/components/PageWrapper.tsx` - -```typescript -export interface PageWrapperProps { - title?: string; - tabs?: PageTab[]; - children?: ReactNode; -} - -// Default implementation using plain HTML -function DefaultPageWrapper(props: PageWrapperProps): JSX.Element { - // Uses plain
,
,