diff --git a/.changeset/actions-service-plugin-id.md b/.changeset/actions-service-plugin-id.md
new file mode 100644
index 0000000000..3fc86db8d8
--- /dev/null
+++ b/.changeset/actions-service-plugin-id.md
@@ -0,0 +1,7 @@
+---
+'@backstage/backend-plugin-api': patch
+'@backstage/backend-defaults': patch
+'@backstage/backend-test-utils': patch
+---
+
+Added `pluginId` field to `ActionsServiceAction` type, populated from the registering plugin's metadata.
diff --git a/.changeset/add-cli-plugin-role.md b/.changeset/add-cli-plugin-role.md
new file mode 100644
index 0000000000..1f09a08ade
--- /dev/null
+++ b/.changeset/add-cli-plugin-role.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli-node': patch
+---
+
+Added a new `cli-module` package role for packages that provide CLI plugin extensions.
diff --git a/.changeset/add-frontend-dev-utils.md b/.changeset/add-frontend-dev-utils.md
new file mode 100644
index 0000000000..987f02c6d9
--- /dev/null
+++ b/.changeset/add-frontend-dev-utils.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-dev-utils': minor
+---
+
+Added `@backstage/frontend-dev-utils`, a new package that provides a minimal helper for wiring up a development app for frontend plugins using the new frontend system. It exports a `createDevApp` function that handles creating and rendering a development app from a `dev/` entry point. The dev app automatically bypasses the sign-in page and loads the `@backstage/ui` CSS. The options interface accepts `features` together with route bindings through `bindRoutes`.
diff --git a/.changeset/app-visualizer-use-frontend-dev-utils.md b/.changeset/app-visualizer-use-frontend-dev-utils.md
new file mode 100644
index 0000000000..f047fe8f7e
--- /dev/null
+++ b/.changeset/app-visualizer-use-frontend-dev-utils.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-app-visualizer': patch
+---
+
+Switched dev entry point to use `createDevApp` from `@backstage/frontend-dev-utils`.
diff --git a/.changeset/auth-migrate-to-bui.md b/.changeset/auth-migrate-to-bui.md
new file mode 100644
index 0000000000..974fe5abee
--- /dev/null
+++ b/.changeset/auth-migrate-to-bui.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-auth': patch
+---
+
+Migrated the ConsentPage UI from Material-UI and `@backstage/core-components` to `@backstage/ui`.
diff --git a/.changeset/auth-provider-icon-element-core-components.md b/.changeset/auth-provider-icon-element-core-components.md
new file mode 100644
index 0000000000..7bd7aa6d52
--- /dev/null
+++ b/.changeset/auth-provider-icon-element-core-components.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-components': patch
+---
+
+The login request dialog now handles auth provider icons passed as `IconElement` in addition to `IconComponent`.
diff --git a/.changeset/auth-provider-icon-element-user-settings.md b/.changeset/auth-provider-icon-element-user-settings.md
new file mode 100644
index 0000000000..16b9adb94f
--- /dev/null
+++ b/.changeset/auth-provider-icon-element-user-settings.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-user-settings': patch
+---
+
+The `ProviderSettingsItem` `icon` prop now accepts `IconElement` in addition to `IconComponent`.
diff --git a/.changeset/auth-provider-icon-element.md b/.changeset/auth-provider-icon-element.md
new file mode 100644
index 0000000000..def6666c2d
--- /dev/null
+++ b/.changeset/auth-provider-icon-element.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+The `icon` field on `AuthProviderInfo` now accepts `IconElement` in addition to `IconComponent`, letting you pass `` instead of `MyIcon`.
diff --git a/.changeset/brave-pens-argue.md b/.changeset/brave-pens-argue.md
new file mode 100644
index 0000000000..2ae66c29cf
--- /dev/null
+++ b/.changeset/brave-pens-argue.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-dynamic-feature-service': patch
+---
+
+Fixed `resolvePackagePath` resolution for bundled dynamic plugins. When a plugin bundles its own copy of `@backstage/backend-plugin-api` inside `node_modules`, the `CommonJSModuleLoader` fallback now correctly resolves the plugin's `package.json` by name. Previously the fallback only applied when the resolution originated from the host application; it now also applies when originating from a bundled dependency, which is the case for plugins produced by the `backstage-cli package bundle` command.
diff --git a/.changeset/bui-table-root-loading-prop.md b/.changeset/bui-table-root-loading-prop.md
new file mode 100644
index 0000000000..7c3f453c82
--- /dev/null
+++ b/.changeset/bui-table-root-loading-prop.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Added a `loading` prop and `data-loading` data attribute to `TableRoot`, allowing consumers to distinguish between stale data and initial loading states. Both `stale` and `loading` set `aria-busy` on the table.
+
+Affected components: TableRoot
diff --git a/.changeset/bui-table-skeleton-loading.md b/.changeset/bui-table-skeleton-loading.md
new file mode 100644
index 0000000000..df20393c50
--- /dev/null
+++ b/.changeset/bui-table-skeleton-loading.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Improved the `Table` component loading state to show a skeleton UI with visible headers instead of plain "Loading..." text. The table now renders its full structure during loading, with animated skeleton rows in place of data. The loading state includes proper accessibility support with `aria-busy` on the table and screen reader announcements.
+
+Affected components: Table
diff --git a/.changeset/catalog-remove-entity-transaction.md b/.changeset/catalog-remove-entity-transaction.md
new file mode 100644
index 0000000000..e8bd5f5c8f
--- /dev/null
+++ b/.changeset/catalog-remove-entity-transaction.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend': patch
+---
+
+Improved catalog entity deletion so parent invalidation and deferred relation restitch scheduling are coordinated more safely.
diff --git a/.changeset/cli-defaults-introduce.md b/.changeset/cli-defaults-introduce.md
new file mode 100644
index 0000000000..b6bb175e62
--- /dev/null
+++ b/.changeset/cli-defaults-introduce.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli-defaults': minor
+---
+
+Introduced `@backstage/cli-defaults`, a convenience package that bundles all standard Backstage CLI modules. Install this single package as a `devDependency` to get the full default set of CLI commands without listing each module individually.
diff --git a/.changeset/cli-discover-modules.md b/.changeset/cli-discover-modules.md
new file mode 100644
index 0000000000..dac5f73f75
--- /dev/null
+++ b/.changeset/cli-discover-modules.md
@@ -0,0 +1,41 @@
+---
+'@backstage/cli': minor
+---
+
+The CLI now automatically discovers CLI modules from the project root's `dependencies` and `devDependencies`. Any installed package with the `cli-module` Backstage role will be loaded automatically without needing to be hardcoded in the CLI itself.
+
+If no CLI modules are found in the project dependencies, the CLI falls back to the built-in set of modules and prints a deprecation warning. This fallback will be removed in a future release. To prepare for this, add `@backstage/cli-defaults` as a `devDependency` in your root `package.json`:
+
+```json
+{
+ "devDependencies": {
+ "@backstage/cli-defaults": "backstage:^"
+ }
+}
+```
+
+If you are not using the Backstage Yarn plugin, run the following instead:
+
+```sh
+yarn workspace root add --dev @backstage/cli-defaults
+```
+
+For fine-grained control you can instead install individual CLI modules:
+
+```json
+{
+ "devDependencies": {
+ "@backstage/cli-module-auth": "backstage:^",
+ "@backstage/cli-module-build": "backstage:^",
+ "@backstage/cli-module-config": "backstage:^",
+ "@backstage/cli-module-github": "backstage:^",
+ "@backstage/cli-module-info": "backstage:^",
+ "@backstage/cli-module-lint": "backstage:^",
+ "@backstage/cli-module-maintenance": "backstage:^",
+ "@backstage/cli-module-migrate": "backstage:^",
+ "@backstage/cli-module-new": "backstage:^",
+ "@backstage/cli-module-test-jest": "backstage:^",
+ "@backstage/cli-module-translations": "backstage:^"
+ }
+}
+```
diff --git a/.changeset/cli-node-add-cli-plugin.md b/.changeset/cli-node-add-cli-plugin.md
new file mode 100644
index 0000000000..2a6a5f20bc
--- /dev/null
+++ b/.changeset/cli-node-add-cli-plugin.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli-node': minor
+---
+
+Added `createCliModule` API and related types for building Backstage CLI plugins.
diff --git a/.changeset/cli-package-bundle.md b/.changeset/cli-package-bundle.md
new file mode 100644
index 0000000000..602d631fd1
--- /dev/null
+++ b/.changeset/cli-package-bundle.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli-module-build': minor
+---
+
+Added `package bundle` command to create self-contained plugin bundles for dynamic loading, to be used by the `backend-dynamic-feature-service`. Supports backend and frontend plugins, with optional `--pre-packed-dir` for batch bundling from a pre-built workspace.
diff --git a/.changeset/cli-support-cli-plugin-role.md b/.changeset/cli-support-cli-plugin-role.md
new file mode 100644
index 0000000000..b336f7f551
--- /dev/null
+++ b/.changeset/cli-support-cli-plugin-role.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Added support for the new `cli-module` package role in the build system, ESLint configuration, Jest configuration, and maintenance commands.
diff --git a/.changeset/cli-use-cli-plugin-api.md b/.changeset/cli-use-cli-plugin-api.md
new file mode 100644
index 0000000000..c155483c10
--- /dev/null
+++ b/.changeset/cli-use-cli-plugin-api.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Migrated CLI plugin modules to use `createCliModule` from `@backstage/cli-node`.
diff --git a/.changeset/create-app-cli-modules.md b/.changeset/create-app-cli-modules.md
new file mode 100644
index 0000000000..c1a8ca9a26
--- /dev/null
+++ b/.changeset/create-app-cli-modules.md
@@ -0,0 +1,5 @@
+---
+'@backstage/create-app': patch
+---
+
+The create-app templates now include `@backstage/cli-defaults` as a `devDependency`, enabling the CLI's automatic module discovery for newly created projects.
diff --git a/.changeset/cruel-cities-jump.md b/.changeset/cruel-cities-jump.md
new file mode 100644
index 0000000000..f37f8dcef1
--- /dev/null
+++ b/.changeset/cruel-cities-jump.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-node': patch
+---
+
+Added `removeFiles` helper function for staging file removals in Git.
diff --git a/.changeset/devtools-cancel-scheduled-task.md b/.changeset/devtools-cancel-scheduled-task.md
new file mode 100644
index 0000000000..fadd51f2aa
--- /dev/null
+++ b/.changeset/devtools-cancel-scheduled-task.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-devtools-common': patch
+'@backstage/plugin-devtools': patch
+---
+
+Added `cancelScheduledTask` to the DevTools API and a cancel button to the scheduled tasks UI.
diff --git a/.changeset/docs-frontend-plugin-installation.md b/.changeset/docs-frontend-plugin-installation.md
new file mode 100644
index 0000000000..8a13f5c12d
--- /dev/null
+++ b/.changeset/docs-frontend-plugin-installation.md
@@ -0,0 +1,16 @@
+---
+'@backstage/plugin-api-docs': patch
+'@backstage/plugin-catalog': patch
+'@backstage/plugin-catalog-graph': patch
+'@backstage/plugin-catalog-import': patch
+'@backstage/plugin-catalog-unprocessed-entities': patch
+'@backstage/plugin-devtools': patch
+'@backstage/plugin-home': patch
+'@backstage/plugin-kubernetes': patch
+'@backstage/plugin-mui-to-bui': patch
+'@backstage/plugin-org': patch
+'@backstage/plugin-scaffolder': patch
+'@backstage/plugin-user-settings': patch
+---
+
+Updated installation documentation to use feature discovery as the default.
diff --git a/.changeset/document-table-cell-requirement.md b/.changeset/document-table-cell-requirement.md
new file mode 100644
index 0000000000..fd99de519f
--- /dev/null
+++ b/.changeset/document-table-cell-requirement.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': patch
+---
+
+Added documentation for the table cell wrapper requirement to TSDoc comments for `Cell`, `CellText`, `CellProfile`, `ColumnConfig`, and `RowRenderFn`.
diff --git a/.changeset/few-clouds-pull.md b/.changeset/few-clouds-pull.md
new file mode 100644
index 0000000000..4f8a67389b
--- /dev/null
+++ b/.changeset/few-clouds-pull.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend-module-github': patch
+---
+
+Added optional `returnWorkflowRunDetails` input to `github:actions:dispatch` action. When true, exposes `workflowRunId`, `workflowRunUrl`, and `workflowRunHtmlUrl` as outputs using the GitHub API `return_run_details` parameter.
diff --git a/.changeset/fine-dragons-scream.md b/.changeset/fine-dragons-scream.md
new file mode 100644
index 0000000000..b1373e25f3
--- /dev/null
+++ b/.changeset/fine-dragons-scream.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Added `SearchAutocomplete` and `SearchAutocompleteItem` components for building accessible search-with-results patterns. Built on React Aria's Autocomplete with keyboard navigation and screen reader support. Designed for async/external search results with a configurable popover width.
+
+**Affected components:** SearchAutocomplete, SearchAutocompleteItem
diff --git a/.changeset/fix-dialog-height-overflow.md b/.changeset/fix-dialog-height-overflow.md
new file mode 100644
index 0000000000..bf91d882dd
--- /dev/null
+++ b/.changeset/fix-dialog-height-overflow.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Fixed `Dialog` content overflowing when no `height` prop is set. The dialog now grows with its content and scrolls when content exceeds the viewport height.
+
+**Affected components**: Dialog
diff --git a/.changeset/fix-header-container-padding.md b/.changeset/fix-header-container-padding.md
new file mode 100644
index 0000000000..cfb3611b8c
--- /dev/null
+++ b/.changeset/fix-header-container-padding.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Fixed incorrect bottom spacing caused by `Container` using `padding-bottom` for its default bottom spacing. Changed to `margin-bottom` and prevented it from applying when `Container` is used as the `Header` root element.
+
+**Affected components:** Container, Header
diff --git a/.changeset/fix-plugin-header-resizeobserver-loop.md b/.changeset/fix-plugin-header-resizeobserver-loop.md
new file mode 100644
index 0000000000..b91620d00c
--- /dev/null
+++ b/.changeset/fix-plugin-header-resizeobserver-loop.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Fixed `PluginHeader` to avoid triggering `ResizeObserver loop completed with undelivered notifications` warnings when used in layouts that react to the header height, such as pages that use `FullPage`.
+
+**Affected components:** PluginHeader
diff --git a/.changeset/fix-table-bui-bg-tint-tokens.md b/.changeset/fix-table-bui-bg-tint-tokens.md
new file mode 100644
index 0000000000..f1e3857744
--- /dev/null
+++ b/.changeset/fix-table-bui-bg-tint-tokens.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': patch
+---
+
+Fixed Table component to use current `--bui-bg-neutral-1` tokens instead of the removed `--bui-bg-tint` tokens, restoring row hover, selected, pressed, and disabled background colors.
diff --git a/.changeset/fix-table-complete-mode-loading.md b/.changeset/fix-table-complete-mode-loading.md
new file mode 100644
index 0000000000..8e27f2f23f
--- /dev/null
+++ b/.changeset/fix-table-complete-mode-loading.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': patch
+---
+
+Fixed a bug in the `useTable` hook where the loading skeleton was never shown for `complete` mode when using `getData`. The initial data state was an empty array instead of `undefined`, causing the `Table` component to skip the loading state.
diff --git a/.changeset/fix-table-row-external-href.md b/.changeset/fix-table-row-external-href.md
new file mode 100644
index 0000000000..c3e5b1f494
--- /dev/null
+++ b/.changeset/fix-table-row-external-href.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Fixed `Table` rows with external `href` values to open in a new tab by automatically applying `target="_blank"` and `rel="noopener noreferrer"`.
+
+**Affected components**: Table
diff --git a/.changeset/giant-carpets-train.md b/.changeset/giant-carpets-train.md
new file mode 100644
index 0000000000..aa5aa31a82
--- /dev/null
+++ b/.changeset/giant-carpets-train.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs-module-addons-contrib': patch
+---
+
+Avoid enabling the TechDocs LightBox addon for images wrapped in links, so image links keep working.
diff --git a/.changeset/gke-dns-endpoint-support.md b/.changeset/gke-dns-endpoint-support.md
new file mode 100644
index 0000000000..54361c5115
--- /dev/null
+++ b/.changeset/gke-dns-endpoint-support.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-kubernetes-backend': patch
+---
+
+Added `endpointType` config option to the GKE cluster locator, allowing use of DNS-based control plane endpoints instead of public IP endpoints. Set `endpointType: 'dns'` to use GKE DNS endpoints (e.g. `gke-..gke.goog`) which provide proper TLS certificates and IAM-based access control.
diff --git a/.changeset/gold-squids-rescue.md b/.changeset/gold-squids-rescue.md
new file mode 100644
index 0000000000..8ddc6503f1
--- /dev/null
+++ b/.changeset/gold-squids-rescue.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend': minor
+---
+
+Adds a new `get-scaffolder-task-logs` action to `@backstage/plugin-scaffolder-backend` that retrieves log events for a given scaffolder task, with optional support for retrieving only new events after a given event ID.
diff --git a/.changeset/introduce-cli-modules.md b/.changeset/introduce-cli-modules.md
new file mode 100644
index 0000000000..7381742621
--- /dev/null
+++ b/.changeset/introduce-cli-modules.md
@@ -0,0 +1,15 @@
+---
+'@backstage/cli-module-auth': minor
+'@backstage/cli-module-build': minor
+'@backstage/cli-module-config': minor
+'@backstage/cli-module-github': minor
+'@backstage/cli-module-info': minor
+'@backstage/cli-module-lint': minor
+'@backstage/cli-module-maintenance': minor
+'@backstage/cli-module-migrate': minor
+'@backstage/cli-module-new': minor
+'@backstage/cli-module-test-jest': minor
+'@backstage/cli-module-translations': minor
+---
+
+Initial release of the CLI module packages. Each module provides a set of commands that can be discovered automatically by `@backstage/cli` or executed standalone.
diff --git a/.changeset/lovely-corners-refuse.md b/.changeset/lovely-corners-refuse.md
new file mode 100644
index 0000000000..b44698f230
--- /dev/null
+++ b/.changeset/lovely-corners-refuse.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': patch
+---
+
+Added `virtualized` prop to `Table` component for virtualized rendering of large datasets. Accepts `true` for default row height, `{ rowHeight: number }` for fixed height, or `{ estimatedRowHeight: number }` for variable height rows.
diff --git a/.changeset/mcp-server-name-description.md b/.changeset/mcp-server-name-description.md
new file mode 100644
index 0000000000..55d3629cfa
--- /dev/null
+++ b/.changeset/mcp-server-name-description.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-mcp-actions-backend': patch
+---
+
+Added `mcpActions.name` and `mcpActions.description` config options to customize the MCP server identity. Namespaced tool names now use dot separator to align with the MCP spec convention.
diff --git a/.changeset/mdowryepwlkfidia.md b/.changeset/mdowryepwlkfidia.md
new file mode 100644
index 0000000000..f776d7f820
--- /dev/null
+++ b/.changeset/mdowryepwlkfidia.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': minor
+---
+
+Removed the `ResolvedExtensionInput` and `ExtensionDataRefToValue` helper types from the public API surface to reduce top-level API clutter. These types were internal plumbing that are not needed by plugin authors. If you were relying on `ResolvedExtensionInput`, use the `ResolvedExtensionInputs` type instead, which maps a full set of inputs. If you were using `ExtensionDataRefToValue`, replace it with `ExtensionDataValue` combined with inferred types from your `ExtensionDataRef`.
diff --git a/.changeset/new-frontend-plugin-template-dev-utils.md b/.changeset/new-frontend-plugin-template-dev-utils.md
new file mode 100644
index 0000000000..5808a9307a
--- /dev/null
+++ b/.changeset/new-frontend-plugin-template-dev-utils.md
@@ -0,0 +1,6 @@
+---
+'@backstage/cli': patch
+'@backstage/cli-module-new': patch
+---
+
+Updated the new frontend plugin template to use `@backstage/frontend-dev-utils` in its `dev/` entry point instead of wiring `createApp` manually. Generated plugins now get the same dev app helper setup as the built-in examples.
diff --git a/.changeset/nfs-icon-alpha-plugins.md b/.changeset/nfs-icon-alpha-plugins.md
new file mode 100644
index 0000000000..42c22bbd6e
--- /dev/null
+++ b/.changeset/nfs-icon-alpha-plugins.md
@@ -0,0 +1,12 @@
+---
+'@backstage/plugin-api-docs': patch
+'@backstage/plugin-catalog': patch
+'@backstage/plugin-catalog-unprocessed-entities': patch
+'@backstage/plugin-devtools': patch
+'@backstage/plugin-scaffolder': patch
+'@backstage/plugin-search': patch
+'@backstage/plugin-techdocs': patch
+'@backstage/plugin-user-settings': patch
+---
+
+Updated alpha plugin icons to follow the new frontend icon sizing rules when rendered in plugin and navigation surfaces.
diff --git a/.changeset/nfs-icon-foundations.md b/.changeset/nfs-icon-foundations.md
new file mode 100644
index 0000000000..b36ee47573
--- /dev/null
+++ b/.changeset/nfs-icon-foundations.md
@@ -0,0 +1,7 @@
+---
+'@backstage/core-components': patch
+'@backstage/frontend-app-api': patch
+'@backstage/frontend-plugin-api': patch
+---
+
+Clarified the `IconElement` sizing contract for the new frontend system and aligned legacy system icon rendering with the new icon API.
diff --git a/.changeset/nfs-icon-plugin-app.md b/.changeset/nfs-icon-plugin-app.md
new file mode 100644
index 0000000000..0f6280a6fb
--- /dev/null
+++ b/.changeset/nfs-icon-plugin-app.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-app': patch
+---
+
+The app nav now falls back to `plugin.icon` for navigation items that don't have an explicit icon set.
diff --git a/.changeset/ninety-onions-ask.md b/.changeset/ninety-onions-ask.md
new file mode 100644
index 0000000000..95798043c9
--- /dev/null
+++ b/.changeset/ninety-onions-ask.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-app-api': patch
+---
+
+Added `BUIProvider` inside the legacy app router to enable client-side routing for all BUI components.
diff --git a/.changeset/polite-trains-crash.md b/.changeset/polite-trains-crash.md
new file mode 100644
index 0000000000..8926f11e94
--- /dev/null
+++ b/.changeset/polite-trains-crash.md
@@ -0,0 +1,23 @@
+---
+'@backstage/ui': minor
+---
+
+**BREAKING**: Centralized client-side routing in `BUIProvider`. Components like Link, ButtonLink, Tabs, Menu, TagGroup, and Table now require a `BUIProvider` rendered inside a React Router context for client-side navigation to work.
+
+**Migration:**
+
+This change requires updating `@backstage/plugin-app` and `@backstage/core-app-api` alongside `@backstage/ui`. If you only upgrade `@backstage/ui`, BUI components will fall back to full-page navigation.
+
+If you cannot upgrade all packages together, or if you have a custom app shell, add a `BUIProvider` inside your Router:
+
+```diff
++ import { BUIProvider } from '@backstage/ui';
+
+
++
+
++
+
+```
+
+**Affected components:** Link, ButtonLink, Tabs, Menu, TagGroup, Table
diff --git a/.changeset/pre.json b/.changeset/pre.json
index 62ccda0410..634272b163 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -1,5 +1,5 @@
{
- "mode": "pre",
+ "mode": "exit",
"tag": "next",
"initialVersions": {
"example-app": "0.0.32",
diff --git a/.changeset/promote-plugin-wrapper-api.md b/.changeset/promote-plugin-wrapper-api.md
new file mode 100644
index 0000000000..b9c6af8589
--- /dev/null
+++ b/.changeset/promote-plugin-wrapper-api.md
@@ -0,0 +1,9 @@
+---
+'@backstage/frontend-plugin-api': minor
+---
+
+**BREAKING**: Promoted `PluginWrapperApi`, `pluginWrapperApiRef`, `PluginWrapperBlueprint`, and the new `PluginWrapperDefinition` type from `@alpha` to `@public`. These are now available from the main package entry point rather than only through `/alpha`.
+
+The `PluginWrapperApi` type now has a required `getRootWrapper()` method that returns a root wrapper component. The `pluginWrapperApiRef` ID changed from `core.plugin-wrapper.alpha` to `core.plugin-wrapper`.
+
+The `PluginWrapperBlueprint` now accepts `PluginWrapperDefinition` as the loader return type, which supports an optional `useWrapperValue` hook that allows sharing state between wrapper instances.
diff --git a/.changeset/promote-plugin-wrapper-app.md b/.changeset/promote-plugin-wrapper-app.md
new file mode 100644
index 0000000000..e76d1247b0
--- /dev/null
+++ b/.changeset/promote-plugin-wrapper-app.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-app': patch
+---
+
+Updated the default `PluginWrapperApi` implementation to support the new `useWrapperValue` hook and root wrapper. The root wrapper is now rendered in the app root to manage shared hook state across plugin wrapper instances.
diff --git a/.changeset/ready-ghosts-fail.md b/.changeset/ready-ghosts-fail.md
new file mode 100644
index 0000000000..a1e4a68602
--- /dev/null
+++ b/.changeset/ready-ghosts-fail.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend': patch
+---
+
+Make the `search` foreign key catalog migration non-blocking on large tables by using batch deletes and PostgreSQL `NOT VALID`/`VALIDATE` to reduce lock duration
diff --git a/.changeset/rename-header-css-classes.md b/.changeset/rename-header-css-classes.md
new file mode 100644
index 0000000000..664e4d7118
--- /dev/null
+++ b/.changeset/rename-header-css-classes.md
@@ -0,0 +1,15 @@
+---
+'@backstage/ui': minor
+---
+
+**BREAKING**: Renamed internal CSS classes to match the `Header` component name.
+
+**Migration:**: If you are targeting these classes directly in your styles, update the following:
+
+- `bui-HeaderPage` → `bui-Header`
+- `bui-HeaderPageContent` → `bui-HeaderContent`
+- `bui-HeaderPageBreadcrumbs` → `bui-HeaderBreadcrumbs`
+- `bui-HeaderPageTabsWrapper` → `bui-HeaderTabsWrapper`
+- `bui-HeaderPageControls` → `bui-HeaderControls`
+
+**Affected components:**: Header
diff --git a/.changeset/renovate-422217b.md b/.changeset/renovate-422217b.md
new file mode 100644
index 0000000000..fec1d46bfb
--- /dev/null
+++ b/.changeset/renovate-422217b.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': patch
+---
+
+Updated dependency `globals` to `^17.0.0`.
diff --git a/.changeset/small-feet-arrive.md b/.changeset/small-feet-arrive.md
new file mode 100644
index 0000000000..a0ed7b1b01
--- /dev/null
+++ b/.changeset/small-feet-arrive.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-app': patch
+---
+
+Moved `BUIProvider` inside the app router to enable automatic client-side routing for all BUI components.
diff --git a/.changeset/tidy-friends-act.md b/.changeset/tidy-friends-act.md
new file mode 100644
index 0000000000..6db6b77083
--- /dev/null
+++ b/.changeset/tidy-friends-act.md
@@ -0,0 +1,7 @@
+---
+'@backstage/ui': patch
+---
+
+Fixed `Card` interactive cards not firing the `onPress` handler when clicking the card surface.
+
+**Affected components**: Card
diff --git a/.changeset/young-squids-end.md b/.changeset/young-squids-end.md
new file mode 100644
index 0000000000..9ea2cee92b
--- /dev/null
+++ b/.changeset/young-squids-end.md
@@ -0,0 +1,10 @@
+---
+'@backstage/ui': minor
+---
+
+Removed redundant `selected` and `indeterminate` props from the `Checkbox` component. Use the `isSelected` and `isIndeterminate` props instead, which are the standard React Aria props and already handle both the checkbox behaviour and the corresponding CSS data attributes.
+
+**Migration:**
+Replace any usage of the `selected` and `indeterminate` props on `Checkbox` with the `isSelected` and `isIndeterminate` props. Note that the checked state and related CSS data attributes (such as `data-selected` and `data-indeterminate`) are now driven by React Aria, so any custom logic that previously inspected or set these via the old props should instead rely on the React Aria-managed state and attributes exposed through the new props.
+
+**Affected components:** Checkbox
diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt
index fe8306afa8..f3bb31015b 100644
--- a/.github/vale/config/vocabularies/Backstage/accept.txt
+++ b/.github/vale/config/vocabularies/Backstage/accept.txt
@@ -555,6 +555,7 @@ validators
Valkey
varchar
viewport
+virtualized
vite
VMware
Vodafone
diff --git a/.github/workflows/api-breaking-changes-comment.yml b/.github/workflows/api-breaking-changes-comment.yml
index aca727743a..82e3310ec1 100644
--- a/.github/workflows/api-breaking-changes-comment.yml
+++ b/.github/workflows/api-breaking-changes-comment.yml
@@ -23,7 +23,7 @@ jobs:
comment-cache-key: ${{ steps.hash.outputs.COMMENT_FILE_HASH }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
disable-sudo: true
egress-policy: block
diff --git a/.github/workflows/api-breaking-changes.yml b/.github/workflows/api-breaking-changes.yml
index 4425f81905..2c53aec0de 100644
--- a/.github/workflows/api-breaking-changes.yml
+++ b/.github/workflows/api-breaking-changes.yml
@@ -14,7 +14,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.action != 'closed' }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/automate_area-labels.yml b/.github/workflows/automate_area-labels.yml
index cdf4a28b55..90b8ea9d16 100644
--- a/.github/workflows/automate_area-labels.yml
+++ b/.github/workflows/automate_area-labels.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/automate_changeset_feedback.yml b/.github/workflows/automate_changeset_feedback.yml
index fadb3efb84..34b1791ca8 100644
--- a/.github/workflows/automate_changeset_feedback.yml
+++ b/.github/workflows/automate_changeset_feedback.yml
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/automate_merge_message.yml b/.github/workflows/automate_merge_message.yml
index c1ecde9194..62db24ce17 100644
--- a/.github/workflows/automate_merge_message.yml
+++ b/.github/workflows/automate_merge_message.yml
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/automate_stale.yml b/.github/workflows/automate_stale.yml
index 34ab03f207..4bed28af3a 100644
--- a/.github/workflows/automate_stale.yml
+++ b/.github/workflows/automate_stale.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/ci-noop.yml b/.github/workflows/ci-noop.yml
index 0fca26a12c..71b0c68791 100644
--- a/.github/workflows/ci-noop.yml
+++ b/.github/workflows/ci-noop.yml
@@ -41,7 +41,7 @@ jobs:
name: Test ${{ matrix.node-version }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 24854003fa..bb5474506d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,7 +30,7 @@ jobs:
name: Install ${{ matrix.node-version }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -78,7 +78,7 @@ jobs:
name: Verify ${{ matrix.node-version }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml
index a9415b24e1..61f8a2ce51 100644
--- a/.github/workflows/cron.yml
+++ b/.github/workflows/cron.yml
@@ -10,7 +10,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/deploy_docker-image.yml b/.github/workflows/deploy_docker-image.yml
index 61841f4052..18974a3520 100644
--- a/.github/workflows/deploy_docker-image.yml
+++ b/.github/workflows/deploy_docker-image.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/deploy_microsite.yml b/.github/workflows/deploy_microsite.yml
index f88f84adad..bc45c28cc1 100644
--- a/.github/workflows/deploy_microsite.yml
+++ b/.github/workflows/deploy_microsite.yml
@@ -23,40 +23,41 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- - name: find latest release
+ - name: find latest release branch
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
id: find-release
with:
script: |
- const { data } = await github.rest.repos.listTags({
+ const data = await octokit.paginate(github.rest.repos.listBranches, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
})
- const [{tag}] = data
+ const [{branch}] = data
.map(i => i.name)
- .filter(tag => tag.match(/^v\d+\.\d+\.\d+$/))
- .map(tag => ({
- tag,
- val: tag
+ .filter(branch => branch.match(/^patch\/v\d+\.\d+\.\d+$/))
+ .map(branch => ({
+ branch,
+ val: branch
+ .split('/')[1]
.slice(1)
.split('.')
.reduce((val, part) => Number(val) * 1000 + Number(part))
}))
.sort((a, b) => b.val - a.val)
- return tag
+ return branch
result-encoding: string
- name: checkout latest release
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
- ref: refs/tags/${{ steps.find-release.outputs.result }}
+ ref: refs/heads/${{ steps.find-release.outputs.result }}
- name: Use Node.js 22.x
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -124,7 +125,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -218,7 +219,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -232,7 +233,7 @@ jobs:
- name: checkout latest release
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
- ref: refs/tags/${{ needs.stable.outputs.release }}
+ ref: refs/heads/${{ needs.stable.outputs.release }}
- name: microsite yarn install
run: yarn install --immutable
diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml
index 601751c005..37e188a1f0 100644
--- a/.github/workflows/deploy_packages.yml
+++ b/.github/workflows/deploy_packages.yml
@@ -147,7 +147,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/issue.yaml b/.github/workflows/issue.yaml
index 791ca69a87..4ba57ebb00 100644
--- a/.github/workflows/issue.yaml
+++ b/.github/workflows/issue.yaml
@@ -16,7 +16,7 @@ jobs:
if: github.repository == 'backstage/backstage'
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/mui-migration-tracker.yml b/.github/workflows/mui-migration-tracker.yml
index 36107d33f2..8cd3023cc3 100644
--- a/.github/workflows/mui-migration-tracker.yml
+++ b/.github/workflows/mui-migration-tracker.yml
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 8f7c462785..aa04752c29 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_bui-docs.yml b/.github/workflows/sync_bui-docs.yml
index 15319c19b9..58491479cf 100644
--- a/.github/workflows/sync_bui-docs.yml
+++ b/.github/workflows/sync_bui-docs.yml
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_code-formatting.yml b/.github/workflows/sync_code-formatting.yml
index 2ae13eaa25..38a119edbc 100644
--- a/.github/workflows/sync_code-formatting.yml
+++ b/.github/workflows/sync_code-formatting.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_dependabot-changesets.yml b/.github/workflows/sync_dependabot-changesets.yml
index 14d2a4d630..2b1dd1098f 100644
--- a/.github/workflows/sync_dependabot-changesets.yml
+++ b/.github/workflows/sync_dependabot-changesets.yml
@@ -11,7 +11,7 @@ jobs:
if: github.actor == 'dependabot[bot]' && github.repository == 'backstage/backstage'
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_patch-release.yml b/.github/workflows/sync_patch-release.yml
index 3e96529e53..628635af1d 100644
--- a/.github/workflows/sync_patch-release.yml
+++ b/.github/workflows/sync_patch-release.yml
@@ -20,7 +20,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_pull-requests-scheduled.yml b/.github/workflows/sync_pull-requests-scheduled.yml
index 210fc1b20a..3854a0f4df 100644
--- a/.github/workflows/sync_pull-requests-scheduled.yml
+++ b/.github/workflows/sync_pull-requests-scheduled.yml
@@ -19,7 +19,7 @@ jobs:
count: ${{ steps.batch.outputs.count }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -70,7 +70,7 @@ jobs:
max-parallel: 1
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_pull-requests-trigger.yml b/.github/workflows/sync_pull-requests-trigger.yml
index 152837b811..03d29c262d 100644
--- a/.github/workflows/sync_pull-requests-trigger.yml
+++ b/.github/workflows/sync_pull-requests-trigger.yml
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_pull-requests.yml b/.github/workflows/sync_pull-requests.yml
index 4d607aab13..aa7088bf23 100644
--- a/.github/workflows/sync_pull-requests.yml
+++ b/.github/workflows/sync_pull-requests.yml
@@ -15,7 +15,7 @@ jobs:
github.event.workflow_run.event == 'pull_request'
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_release-manifest.yml b/.github/workflows/sync_release-manifest.yml
index 0fee5f17b5..62478a7891 100644
--- a/.github/workflows/sync_release-manifest.yml
+++ b/.github/workflows/sync_release-manifest.yml
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_renovate-changesets.yml b/.github/workflows/sync_renovate-changesets.yml
index 0940de9c55..04495e606b 100644
--- a/.github/workflows/sync_renovate-changesets.yml
+++ b/.github/workflows/sync_renovate-changesets.yml
@@ -11,7 +11,7 @@ jobs:
if: github.actor == 'renovate[bot]' && github.repository == 'backstage/backstage'
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_snyk-github-issues.yml b/.github/workflows/sync_snyk-github-issues.yml
index 4a067dbbd3..b04c5b9ab7 100644
--- a/.github/workflows/sync_snyk-github-issues.yml
+++ b/.github/workflows/sync_snyk-github-issues.yml
@@ -12,7 +12,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml
index 985ff6fe9a..8bb4947dab 100644
--- a/.github/workflows/sync_snyk-monitor.yml
+++ b/.github/workflows/sync_snyk-monitor.yml
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/sync_version-packages.yml b/.github/workflows/sync_version-packages.yml
index 6cdcddc8be..022ad073c4 100644
--- a/.github/workflows/sync_version-packages.yml
+++ b/.github/workflows/sync_version-packages.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_accessibility-noop.yml b/.github/workflows/verify_accessibility-noop.yml
index 51caba5acd..8cf164ecfa 100644
--- a/.github/workflows/verify_accessibility-noop.yml
+++ b/.github/workflows/verify_accessibility-noop.yml
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_accessibility.yml b/.github/workflows/verify_accessibility.yml
index 312cdd2903..83d27612ae 100644
--- a/.github/workflows/verify_accessibility.yml
+++ b/.github/workflows/verify_accessibility.yml
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_chromatic-noop.yml b/.github/workflows/verify_chromatic-noop.yml
index cb4c96902a..5fe97c8903 100644
--- a/.github/workflows/verify_chromatic-noop.yml
+++ b/.github/workflows/verify_chromatic-noop.yml
@@ -20,7 +20,7 @@ jobs:
name: Chromatic
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_chromatic.yml b/.github/workflows/verify_chromatic.yml
index 13be85fe97..26eaeb5f45 100644
--- a/.github/workflows/verify_chromatic.yml
+++ b/.github/workflows/verify_chromatic.yml
@@ -24,7 +24,7 @@ jobs:
name: Chromatic
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_codeql.yml b/.github/workflows/verify_codeql.yml
index 628577e09d..bb6b659db2 100644
--- a/.github/workflows/verify_codeql.yml
+++ b/.github/workflows/verify_codeql.yml
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml
index 6ace7d72a3..792402f7ad 100644
--- a/.github/workflows/verify_docs-quality.yml
+++ b/.github/workflows/verify_docs-quality.yml
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_e2e-linux-noop.yml b/.github/workflows/verify_e2e-linux-noop.yml
index 38ee55f72c..dbba242bae 100644
--- a/.github/workflows/verify_e2e-linux-noop.yml
+++ b/.github/workflows/verify_e2e-linux-noop.yml
@@ -29,7 +29,7 @@ jobs:
name: E2E Linux ${{ matrix.node-version }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_e2e-linux.yml b/.github/workflows/verify_e2e-linux.yml
index 4e0f82dd2c..66a176e128 100644
--- a/.github/workflows/verify_e2e-linux.yml
+++ b/.github/workflows/verify_e2e-linux.yml
@@ -43,7 +43,7 @@ jobs:
name: E2E Linux ${{ matrix.node-version }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_e2e-techdocs.yml b/.github/workflows/verify_e2e-techdocs.yml
index f3786f19c3..370d10310d 100644
--- a/.github/workflows/verify_e2e-techdocs.yml
+++ b/.github/workflows/verify_e2e-techdocs.yml
@@ -32,7 +32,7 @@ jobs:
name: Techdocs
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_e2e-windows-noop.yml b/.github/workflows/verify_e2e-windows-noop.yml
index 4759555905..baa18cb53b 100644
--- a/.github/workflows/verify_e2e-windows-noop.yml
+++ b/.github/workflows/verify_e2e-windows-noop.yml
@@ -25,7 +25,7 @@ jobs:
name: E2E Windows ${{ matrix.node-version }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_e2e-windows.yml b/.github/workflows/verify_e2e-windows.yml
index 2b0e3d47c3..f4fded1a0d 100644
--- a/.github/workflows/verify_e2e-windows.yml
+++ b/.github/workflows/verify_e2e-windows.yml
@@ -33,7 +33,7 @@ jobs:
name: E2E Windows ${{ matrix.node-version }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_fossa.yml b/.github/workflows/verify_fossa.yml
index 84d666f00e..80cd7907e2 100644
--- a/.github/workflows/verify_fossa.yml
+++ b/.github/workflows/verify_fossa.yml
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_microsite-noop.yml b/.github/workflows/verify_microsite-noop.yml
index acab96962c..08eeac4431 100644
--- a/.github/workflows/verify_microsite-noop.yml
+++ b/.github/workflows/verify_microsite-noop.yml
@@ -21,7 +21,7 @@ jobs:
name: Microsite
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_microsite.yml b/.github/workflows/verify_microsite.yml
index 4fb6a98f3e..b8ce5bfb93 100644
--- a/.github/workflows/verify_microsite.yml
+++ b/.github/workflows/verify_microsite.yml
@@ -28,40 +28,41 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- - name: find latest release
+ - name: find latest release branch
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
id: find-release
with:
script: |
- const { data } = await github.rest.repos.listTags({
+ const data = await octokit.paginate(github.rest.repos.listBranches, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
})
- const [{tag}] = data
+ const [{branch}] = data
.map(i => i.name)
- .filter(tag => tag.match(/^v\d+\.\d+\.\d+$/))
- .map(tag => ({
- tag,
- val: tag
+ .filter(branch => branch.match(/^patch\/v\d+\.\d+\.\d+$/))
+ .map(branch => ({
+ branch,
+ val: branch
+ .split('/')[1]
.slice(1)
.split('.')
.reduce((val, part) => Number(val) * 1000 + Number(part))
}))
.sort((a, b) => b.val - a.val)
- return tag
+ return branch
result-encoding: string
- name: checkout latest release
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
- ref: refs/tags/${{ steps.find-release.outputs.result }}
+ ref: refs/heads/${{ steps.find-release.outputs.result }}
- name: Use Node.js 22.x
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -126,7 +127,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
@@ -212,7 +213,7 @@ jobs:
name: Microsite
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_microsite_accessibility-noop.yml b/.github/workflows/verify_microsite_accessibility-noop.yml
index 87963dd78e..a90b892fae 100644
--- a/.github/workflows/verify_microsite_accessibility-noop.yml
+++ b/.github/workflows/verify_microsite_accessibility-noop.yml
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_microsite_accessibility.yml b/.github/workflows/verify_microsite_accessibility.yml
index ff7a91c716..226f1f71be 100644
--- a/.github/workflows/verify_microsite_accessibility.yml
+++ b/.github/workflows/verify_microsite_accessibility.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml
index 26657cbcd3..3e74e088ec 100644
--- a/.github/workflows/verify_windows.yml
+++ b/.github/workflows/verify_windows.yml
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
+ uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
diff --git a/AGENTS.md b/AGENTS.md
index 001989d9b6..010ebca9e7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,9 +4,9 @@ Backstage is an open platform for building developer portals. This is a TypeScri
- `/packages`: Core framework packages (prefixed `@backstage/`)
- `/plugins`: Plugin packages (prefixed `@backstage/plugin-*`)
-- `/packages/app` and `/packages/backend`: Example app for local development
- `/packages/app`: Main example app using the new frontend system
- `/packages/app-legacy`: Example app using the old frontend system
+- `/packages/backend`: Example backend for local development
- `/docs`: Documentation files
Packages prefixed with `core-` (e.g., `@backstage/core-plugin-api`) are part of the old frontend system. Packages prefixed with `frontend-` (e.g., `@backstage/frontend-plugin-api`) are part of the new frontend system (NFS). Packages prefixed with `backend-` (e.g., `@backstage/backend-plugin-api`) are part of the backend system.
diff --git a/app-config.yaml b/app-config.yaml
index e88955a39f..5eea93084e 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -27,6 +27,130 @@ app:
packageName: example-app
+ routes:
+ bindings:
+ catalog.viewTechDoc: techdocs.docRoot
+ org.catalogIndex: catalog.catalogIndex
+
+ pluginOverrides:
+ - match:
+ pluginId: pages
+ info:
+ description: 'This description was overridden in app-config.yaml'
+ - match:
+ pluginId: /^catalog(-.*)?$/
+ info:
+ ownerEntityRefs: [cubic-belugas]
+ - match:
+ packageName: '@backstage/plugin-scaffolder'
+ info:
+ ownerEntityRefs: [cubic-belugas]
+
+ extensions:
+ # set availableLanguages example
+ - api:app/app-language:
+ config:
+ availableLanguages: ['en', 'es', 'fr', 'de', 'ja']
+ defaultLanguage: 'en'
+ - entity-card:org/members-list:
+ config:
+ showAggregateMembersToggle: true
+ initialRelationAggregation: aggregated
+ - entity-card:org/ownership:
+ config:
+ ownedKinds: ['Component', 'API', 'System']
+
+ # - apis.plugin.graphiql.browse.gitlab: true
+ # - graphiql-endpoint:graphiql/gitlab: true
+
+ - nav-item:search: false
+ - nav-item:user-settings: false
+ - nav-item:catalog
+ - nav-item:api-docs
+ - nav-item:scaffolder
+ - nav-item:app-visualizer
+
+ # Pages
+ - page:catalog/entity:
+ config:
+ showNavItemIcons: true
+ # default content order for all groups, can be 'title' or 'natural'
+ # defaultContentOrder: title
+ groups:
+ # placing a tab at the beginning
+ - overview:
+ title: Overview
+ # example disabling a default group
+ # - development: false
+ # example overriding a default group title
+ - documentation:
+ title: Docs
+ icon: docs
+ # example aliasing a group
+ # aliases:
+ # - docs
+ - deployment:
+ title: Deployments
+ # example adding a new group
+ - custom:
+ title: Custom
+
+ # Entity page cards
+ - entity-card:catalog/about:
+ config:
+ type: info
+ - entity-card:catalog/labels
+ - entity-card:catalog/links:
+ config:
+ filter:
+ kind: component
+ metadata.links:
+ $exists: true
+ # filter: kind:component has:links
+ type: info
+ # - entity-card:linguist/languages
+ - entity-card:catalog-graph/relations:
+ config:
+ height: 300
+ - entity-card:api-docs/has-apis
+ - entity-card:api-docs/consumed-apis
+ - entity-card:api-docs/provided-apis
+ - entity-card:api-docs/providing-components
+ - entity-card:api-docs/consuming-components
+ # Org Plugin
+ - entity-card:org/group-profile
+ - entity-card:org/members-list
+ - entity-card:org/ownership
+ - entity-card:org/user-profile:
+ config:
+ maxRelations: 5
+ hideIcons: true
+ # - entity-card:azure-devops/readme
+
+ # Entity page contents
+ - entity-content:catalog/overview
+ - entity-content:api-docs/definition
+ - entity-content:api-docs/apis:
+ config:
+ # example overriding the default group
+ group: documentation
+ icon: kind:api
+ - entity-content:techdocs:
+ config:
+ icon: techdocs
+ - entity-content:kubernetes/kubernetes:
+ config:
+ # example disassociating from the default group
+ group: false
+ # - entity-content:azure-devops/pipelines
+ # - entity-content:azure-devops/pull-requests
+ # - entity-content:azure-devops/git-tags
+
+ # Enable the catalog-unprocessed-entities tab in devtools
+ - devtools-content:catalog-unprocessed-entities: true
+ # Disable the catalog-unprocessed-entities element outside devtools
+ - page:catalog-unprocessed-entities: false
+
backend:
# Used for enabling authentication, secret is shared by all backend plugins
# See https://backstage.io/docs/auth/service-to-service-auth for
diff --git a/docs-ui/src/app/components/search-autocomplete/components.tsx b/docs-ui/src/app/components/search-autocomplete/components.tsx
new file mode 100644
index 0000000000..4052ee0e3f
--- /dev/null
+++ b/docs-ui/src/app/components/search-autocomplete/components.tsx
@@ -0,0 +1,156 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ SearchAutocomplete,
+ SearchAutocompleteItem,
+} from '../../../../../packages/ui/src/components/SearchAutocomplete/SearchAutocomplete';
+import { PluginHeader } from '../../../../../packages/ui/src/components/PluginHeader/PluginHeader';
+import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex';
+import { Text } from '../../../../../packages/ui/src/components/Text/Text';
+import { MemoryRouter } from 'react-router-dom';
+
+const fruits = [
+ { id: 'apple', name: 'Apple', description: 'A round fruit' },
+ { id: 'banana', name: 'Banana', description: 'A yellow curved fruit' },
+ { id: 'blueberry', name: 'Blueberry', description: 'A small blue berry' },
+ { id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' },
+ { id: 'grape', name: 'Grape', description: 'Grows in clusters on vines' },
+ { id: 'lemon', name: 'Lemon', description: 'A sour yellow citrus fruit' },
+ { id: 'mango', name: 'Mango', description: 'A tropical stone fruit' },
+ { id: 'orange', name: 'Orange', description: 'A citrus fruit' },
+ { id: 'peach', name: 'Peach', description: 'A fuzzy stone fruit' },
+ {
+ id: 'strawberry',
+ name: 'Strawberry',
+ description: 'A red fruit with seeds on its surface',
+ },
+];
+
+export const WithItems = () => {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ );
+};
+
+export const WithRichContent = () => {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ {fruit.description}
+
+
+ ))}
+
+ );
+};
+
+export const InHeader = () => {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ >
+ }
+ />
+
+ );
+};
+
+export const WithSelection = () => {
+ const [inputValue, setInputValue] = useState('');
+ const [selected, setSelected] = useState(null);
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+
+ {filtered.map(fruit => (
+ {
+ setSelected(fruit.name);
+ setInputValue('');
+ }}
+ >
+ {fruit.name}
+
+ ))}
+
+ Last selected: {selected ?? 'none'}
+
+ );
+};
diff --git a/docs-ui/src/app/components/search-autocomplete/page.mdx b/docs-ui/src/app/components/search-autocomplete/page.mdx
new file mode 100644
index 0000000000..ecb58f3377
--- /dev/null
+++ b/docs-ui/src/app/components/search-autocomplete/page.mdx
@@ -0,0 +1,95 @@
+import { PropsTable } from '@/components/PropsTable';
+import { Snippet } from '@/components/Snippet';
+import { ReactAriaLink } from '@/components/ReactAriaLink';
+import {
+ searchAutocompletePropDefs,
+ searchAutocompleteItemPropDefs,
+} from './props-definition';
+import {
+ usage,
+ defaultSnippet,
+ withRichContent,
+ inHeader,
+ withSelection,
+} from './snippets';
+import {
+ WithItems,
+ WithRichContent,
+ InHeader,
+ WithSelection,
+} from './components';
+import { PageTitle } from '@/components/PageTitle';
+import { Theming } from '@/components/Theming';
+import { ChangelogComponent } from '@/components/ChangelogComponent';
+import { CodeBlock } from '@/components/CodeBlock';
+import {
+ SearchAutocompleteDefinition,
+ SearchAutocompleteItemDefinition,
+} from '../../../utils/definitions';
+
+export const reactAriaUrls = {
+ autocomplete: 'https://react-aria.adobe.com/Autocomplete',
+};
+
+
+
+} code={defaultSnippet} />
+
+## Usage
+
+
+
+## API reference
+
+### SearchAutocomplete
+
+
+
+
+
+### SearchAutocompleteItem
+
+Individual result item within the autocomplete list.
+
+
+
+## Examples
+
+### With rich content
+
+Here's a view when items include a title and description.
+
+}
+ code={withRichContent}
+/>
+
+### In header
+
+Use `popoverWidth` to control the dropdown width when placed in constrained layouts like `PluginHeader`.
+
+} code={inHeader} />
+
+### With selection
+
+Use `onAction` on items to handle selection and reset the input.
+
+}
+ code={withSelection}
+/>
+
+
+
+
+
+
diff --git a/docs-ui/src/app/components/search-autocomplete/props-definition.tsx b/docs-ui/src/app/components/search-autocomplete/props-definition.tsx
new file mode 100644
index 0000000000..7ddaea61e2
--- /dev/null
+++ b/docs-ui/src/app/components/search-autocomplete/props-definition.tsx
@@ -0,0 +1,97 @@
+import {
+ classNamePropDefs,
+ stylePropDefs,
+ type PropDef,
+} from '@/utils/propDefs';
+import { Chip } from '@/components/Chip';
+
+export const searchAutocompletePropDefs: Record = {
+ 'aria-label': {
+ type: 'string',
+ description: 'Accessible label for the search input.',
+ },
+ 'aria-labelledby': {
+ type: 'string',
+ description: 'ID of the element that labels the search input.',
+ },
+ inputValue: {
+ type: 'string',
+ description: 'The current input value (controlled).',
+ },
+ onInputChange: {
+ type: 'enum',
+ values: ['(value: string) => void'],
+ description: 'Handler called when the input value changes.',
+ },
+ placeholder: {
+ type: 'string',
+ default: 'Search',
+ description:
+ 'Placeholder text shown when the input is empty. Also used as the accessible label when neither aria-label nor aria-labelledby is provided.',
+ },
+ size: {
+ type: 'enum',
+ values: ['small', 'medium'],
+ default: 'small',
+ responsive: true,
+ description: (
+ <>
+ Visual size of the input. Use small for inline or dense
+ layouts, medium for standalone fields.
+ >
+ ),
+ },
+ isLoading: {
+ type: 'boolean',
+ default: 'false',
+ description:
+ 'Whether results are currently loading. Dims existing results and announces loading state to screen readers.',
+ },
+ popoverWidth: {
+ type: 'string',
+ description:
+ 'Width of the results popover. Accepts any CSS width value. Matches the input width when not set.',
+ },
+ popoverPlacement: {
+ type: 'enum',
+ values: ['bottom start', 'bottom end', 'top start', 'top end'],
+ default: 'bottom start',
+ description: 'Placement of the results popover relative to the input.',
+ },
+ defaultOpen: {
+ type: 'boolean',
+ default: 'false',
+ description: 'Whether the results popover is open by default.',
+ },
+ children: {
+ type: 'enum',
+ values: ['ReactNode'],
+ description: 'The result items to render inside the autocomplete.',
+ },
+ ...classNamePropDefs,
+ ...stylePropDefs,
+};
+
+export const searchAutocompleteItemPropDefs: Record = {
+ id: {
+ type: 'string',
+ description: 'Unique identifier for the item.',
+ },
+ textValue: {
+ type: 'string',
+ description:
+ 'Plain text value used for keyboard navigation and accessibility.',
+ },
+ onAction: {
+ type: 'enum',
+ values: ['() => void'],
+ description: 'Handler called when the item is selected.',
+ },
+ children: {
+ type: 'enum',
+ values: ['ReactNode'],
+ required: true,
+ description: 'Content to render inside the item.',
+ },
+ ...classNamePropDefs,
+};
diff --git a/docs-ui/src/app/components/search-autocomplete/snippets.ts b/docs-ui/src/app/components/search-autocomplete/snippets.ts
new file mode 100644
index 0000000000..fe799ff828
--- /dev/null
+++ b/docs-ui/src/app/components/search-autocomplete/snippets.ts
@@ -0,0 +1,139 @@
+export const usage = `import { SearchAutocomplete, SearchAutocompleteItem } from '@backstage/ui';
+
+
+ {items.map(item => (
+
+ {item.name}
+
+ ))}
+`;
+
+export const defaultSnippet = `const fruits = [
+ { id: 'apple', name: 'Apple' },
+ { id: 'banana', name: 'Banana' },
+ { id: 'cherry', name: 'Cherry' },
+ { id: 'grape', name: 'Grape' },
+ { id: 'orange', name: 'Orange' },
+];
+
+function Example() {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ );
+}`;
+
+export const withRichContent = `const fruits = [
+ { id: 'apple', name: 'Apple', description: 'A round fruit' },
+ { id: 'banana', name: 'Banana', description: 'A yellow curved fruit' },
+ { id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' },
+];
+
+function Example() {
+ const [inputValue, setInputValue] = useState('');
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ {fruit.description}
+
+
+ ))}
+
+ );
+}`;
+
+export const inHeader = `
+
+ {filtered.map(fruit => (
+
+ {fruit.name}
+
+ ))}
+
+ >
+ }
+/>`;
+
+export const withSelection = `function Example() {
+ const [inputValue, setInputValue] = useState('');
+ const [selected, setSelected] = useState(null);
+
+ const filtered = fruits.filter(fruit =>
+ fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
+ );
+
+ return (
+
+
+ {filtered.map(fruit => (
+ {
+ setSelected(fruit.name);
+ setInputValue('');
+ }}
+ >
+ {fruit.name}
+
+ ))}
+
+ Last selected: {selected ?? 'none'}
+
+ );
+}`;
diff --git a/docs-ui/src/app/components/table/page.mdx b/docs-ui/src/app/components/table/page.mdx
index 6eb280cbe2..8d0e803bd2 100644
--- a/docs-ui/src/app/components/table/page.mdx
+++ b/docs-ui/src/app/components/table/page.mdx
@@ -42,6 +42,7 @@ import {
tableCombinedSnippet,
tableCustomRowSnippet,
tablePrimitivesSnippet,
+ tableCellRequirementSnippet,
} from './snippets';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { PageTitle } from '@/components/PageTitle';
@@ -94,6 +95,18 @@ For full control over state, use the controlled props instead:
- `search` / `onSearchChange`
- `filter` / `onFilterChange`
+### Cell Requirement
+
+Every cell rendered via `ColumnConfig.cell` (or inside a custom `RowRenderFn`) **must** return a cell component as the top-level element. The available cell components are:
+
+- **`CellText`** — displays a title with optional description and icon.
+- **`CellProfile`** — displays an avatar with a name and optional description.
+- **`Cell`** — a generic wrapper for fully custom cell content.
+
+Returning bare text, React fragments, or other elements without wrapping them in one of these cell components will break the table layout.
+
+
+
## Common Patterns
### Sorting
diff --git a/docs-ui/src/app/components/table/props-definition.tsx b/docs-ui/src/app/components/table/props-definition.tsx
index abfdd45549..6d35cb3350 100644
--- a/docs-ui/src/app/components/table/props-definition.tsx
+++ b/docs-ui/src/app/components/table/props-definition.tsx
@@ -430,6 +430,17 @@ export const tableRootPropDefs: Record = {
>
),
},
+ loading: {
+ type: 'boolean',
+ default: 'false',
+ description: (
+ <>
+ Whether the table is in a loading state (e.g., initial data fetch). Adds{' '}
+ aria-busy attribute and data-loading data
+ attribute for styling.
+ >
+ ),
+ },
};
export const columnPropDefs: Record = {
diff --git a/docs-ui/src/app/components/table/snippets.ts b/docs-ui/src/app/components/table/snippets.ts
index ab4bf53a58..39f266f2ea 100644
--- a/docs-ui/src/app/components/table/snippets.ts
+++ b/docs-ui/src/app/components/table/snippets.ts
@@ -57,6 +57,23 @@ const {
filter, // { value, onChange } for filters
} = useTable({ ... });`;
+// =============================================================================
+// Cell Requirement
+// =============================================================================
+
+export const tableCellRequirementSnippet = `// ✅ Correct — CellText is the top-level element
+cell: item => ;
+
+// ✅ Correct — Cell wraps custom content
+cell: item => (
+
+
+
+);
+
+// ❌ Wrong — bare text without a cell wrapper
+cell: item => {item.name};`;
+
// =============================================================================
// Common Patterns
// =============================================================================
diff --git a/docs-ui/src/app/get-started/installation/page.mdx b/docs-ui/src/app/get-started/installation/page.mdx
index c5421ae9d7..d87c2bcba4 100644
--- a/docs-ui/src/app/get-started/installation/page.mdx
+++ b/docs-ui/src/app/get-started/installation/page.mdx
@@ -49,23 +49,28 @@ your plugin and import the components you need.
-## Analytics
+## BUIProvider
-BUI components with navigation behavior — Link, ButtonLink, Tab, MenuItem, Tag, and Row — can fire analytics events when clicked. To enable this, you need to connect BUI's analytics layer to Backstage's analytics system.
+`BUIProvider` provides routing and analytics integration for all BUI components. It must be rendered inside a React Router context for client-side navigation to work in components like Link, ButtonLink, Tabs, Menu, TagGroup, and Table.
### Setup
-If you're using the **new frontend system**, analytics is wired automatically via `@backstage/plugin-app` — no setup required.
+If you're using the **new frontend system**, the provider is wired automatically via `@backstage/plugin-app` — no setup required.
For the **old frontend system**, the `BUIProvider` is included in the app shell from `@backstage/core-app-api` and works out of the box.
-If you need to set up the provider manually (e.g. in a custom app shell), wrap your app content with the `BUIProvider` and pass in Backstage's `useAnalytics` hook:
+If you need to set up the provider manually (e.g. in a custom app shell), wrap your app content with the `BUIProvider` inside your Router and pass in Backstage's `useAnalytics` hook:
-### How it works
+
-Once configured, BUI components fire a `click` event through Backstage's analytics system when a user navigates. Events include the link text as the subject and the destination URL in the attributes, along with any `AnalyticsContext` metadata (such as `pluginId`) from the component's position in the tree.
+### Analytics
+
+Once configured, BUI components with navigation behavior — Link, ButtonLink, Tab, MenuItem, Tag, and Row — fire a `click` event through Backstage's analytics system when a user navigates. Events include the link text as the subject and the destination URL in the attributes, along with any `AnalyticsContext` metadata (such as `pluginId`) from the component's position in the tree.
To suppress tracking on an individual component, use the `noTrack` prop:
diff --git a/docs-ui/src/app/get-started/installation/snippets.ts b/docs-ui/src/app/get-started/installation/snippets.ts
index 12dfff4f44..61edb266b3 100644
--- a/docs-ui/src/app/get-started/installation/snippets.ts
+++ b/docs-ui/src/app/get-started/installation/snippets.ts
@@ -7,11 +7,14 @@ export const snippet = `import { Flex, Button, Text } from '@backstage/ui';
export const analyticsSetupSnippet = `import { BUIProvider } from '@backstage/ui';
import { useAnalytics } from '@backstage/core-plugin-api';
+import { BrowserRouter } from 'react-router-dom';
-// Wrap your app content with the provider
-
-
-`;
+// BUIProvider must be inside a Router for client-side navigation
+
+
+
+
+`;
export const analyticsNoTrackSnippet = `// Suppress analytics for a specific link
diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts
index 753473ab64..fc1f841b55 100644
--- a/docs-ui/src/utils/data.ts
+++ b/docs-ui/src/utils/data.ts
@@ -85,6 +85,10 @@ export const components: Page[] = [
title: 'RadioGroup',
slug: 'radio-group',
},
+ {
+ title: 'SearchAutocomplete',
+ slug: 'search-autocomplete',
+ },
{
title: 'SearchField',
slug: 'search-field',
diff --git a/docs-ui/src/utils/types.ts b/docs-ui/src/utils/types.ts
index c2c383796f..670bee3f05 100644
--- a/docs-ui/src/utils/types.ts
+++ b/docs-ui/src/utils/types.ts
@@ -23,6 +23,7 @@ export type Component =
| 'password-field'
| 'radio-group'
| 'scrollarea'
+ | 'search-autocomplete'
| 'searchfield'
| 'select'
| 'skeleton'
diff --git a/docs/ai/mcp-actions.md b/docs/ai/mcp-actions.md
new file mode 100644
index 0000000000..498cd42df2
--- /dev/null
+++ b/docs/ai/mcp-actions.md
@@ -0,0 +1,256 @@
+---
+id: mcp-actions
+title: MCP Actions Backend
+description: The MCP Actions Backend exposes actions registered with the Actions Registry as MCP tools.
+---
+
+The MCP Actions Backend exposes [Actions](../backend-system/core-services/actions.md) registered with the [Actions Registry](../backend-system/core-services/actions-registry.md) as MCP tools.
+
+## Installation
+
+This plugin is installed via the `@backstage/plugin-mcp-actions-backend` package. To add it to your backend package, run the following command:
+
+```bash title="From your root directory"
+yarn --cwd packages/backend add @backstage/plugin-mcp-actions-backend
+```
+
+Then, add the plugin to your backend:
+
+```ts title="packages/backend/src/index.ts"
+const backend = createBackend();
+// ...
+backend.add(import('@backstage/plugin-mcp-actions-backend'));
+// ...
+backend.start();
+```
+
+## Actions Configuration
+
+Populate the `pluginSources` configuration with the list of plugins you want exposed as MCP tools like so:
+
+```yaml
+backend:
+ actions:
+ pluginSources:
+ - 'catalog'
+ - 'my-custom-plugin'
+```
+
+For details on filtering actions, see the [filtering actions documentation](../backend-system/core-services/actions.md#filtering-actions).
+
+## Single MCP Server Name & Description
+
+You can configure the name and description of your Backstage MCP server with the following config:
+
+```yaml title="app-config.yaml"
+mcpActions:
+ name: 'My MCP Server' # defaults to "backstage"
+ description: 'Tools for interacting with My MCP Server' # optional
+```
+
+## Namespaced Tool Names
+
+By default, MCP tool names include the plugin ID prefix to avoid collisions across plugins. For example, an action registered as `greet-user` by `my-custom-plugin` is exposed as `my-custom-plugin.greet-user`.
+
+You can disable this if you need the short names for backward compatibility:
+
+```yaml title="app-config.yaml"
+mcpActions:
+ namespacedToolNames: false
+```
+
+## Multiple MCP Servers
+
+By default, the plugin serves a single MCP server at `/api/mcp-actions/v1` that exposes all available actions. You can split actions into multiple focused servers by configuring `mcpActions.servers`, where each key becomes a separate MCP server endpoint.
+
+```yaml title="app-config.yaml"
+mcpActions:
+ servers:
+ catalog:
+ name: 'Backstage Catalog'
+ description: 'Tools for interacting with the software catalog'
+ filter:
+ include:
+ - id: 'catalog:*'
+ scaffolder:
+ name: 'Backstage Scaffolder'
+ description: 'Tools for creating new software from templates'
+ filter:
+ include:
+ - id: 'scaffolder:*'
+```
+
+This creates two MCP server endpoints:
+
+- `http://localhost:7007/api/mcp-actions/v1/catalog`
+- `http://localhost:7007/api/mcp-actions/v1/scaffolder`
+
+Each server uses include filter rules with glob patterns on action IDs to control which actions are exposed. For example, `id: 'catalog:*'` matches all actions registered by the catalog plugin.
+
+When `mcpActions.servers` is not configured, the plugin behaves exactly as before with a single server at `/api/mcp-actions/v1`.
+
+### Filter Rules
+
+Include and exclude filter rules support glob patterns on action IDs and attribute matching. Exclude rules take precedence over include rules. When include rules are specified, actions must match at least one include rule to be exposed.
+
+```yaml title="app-config.yaml"
+mcpActions:
+ servers:
+ catalog:
+ name: 'Backstage Catalog'
+ filter:
+ include:
+ - id: 'catalog:*'
+ exclude:
+ - attributes:
+ destructive: true
+```
+
+## Authentication Configuration
+
+By default, the Backstage backend requires authentication for all requests.
+
+### External Access with Static Tokens
+
+:::warning
+This is meant to be a temporary workaround until device authentication is completed.
+:::
+
+Configure external access with static tokens in your app configuration:
+
+```yaml title="app-config.yaml"
+backend:
+ auth:
+ externalAccess:
+ - type: static
+ options:
+ token: ${MCP_TOKEN}
+ subject: mcp-clients
+ accessRestrictions:
+ - plugin: mcp-actions
+ - plugin: catalog
+```
+
+Generate a secure token:
+
+```bash
+node -p 'require("crypto").randomBytes(24).toString("base64")'
+```
+
+Set the `MCP_TOKEN` environment variable and configure your MCP client to send:
+
+```http
+Authorization: Bearer
+```
+
+For more details about external access tokens and service-to-service authentication, see the
+[Service-to-Service Auth documentation](../auth/service-to-service-auth.md).
+
+### Experimental: Dynamic Client Registration
+
+:::warning
+This feature is highly experimental and only works with the New Frontend System. Proceed with caution.
+:::
+
+You can configure the auth-backend and install the auth frontend plugin to enable **Dynamic Client Registration** with MCP clients. This means you do not need to manually configure a token in your MCP client settings. Instead, a client can request a token on your behalf. When adding the MCP server to an MCP client like Cursor or Claude, a popup requiring your approval will open in your Backstage instance (powered by the auth plugin).
+
+**Requirements:**
+
+- The `@backstage/plugin-auth-backend` plugin must be configured.
+- The new `@backstage/plugin-auth` frontend plugin must be configured.
+
+**Installation:**
+
+1. Install the `@backstage/plugin-auth` frontend plugin:
+
+ ```bash
+ yarn --cwd packages/app add @backstage/plugin-auth
+ ```
+
+2. If you use [feature discovery](../frontend-system/architecture/10-app.md#feature-discovery) the plugin will be added automatically, if you prefer explicit registration, register the plugin as a feature like this:
+
+ ```tsx title="packages/app/src/App.tsx"
+ import authPlugin from '@backstage/plugin-auth';
+
+ const app = createApp({
+ features: [
+ // ...other features
+ authPlugin,
+ ],
+ });
+ ```
+
+3. Enable the feature:
+
+ ```yaml title="app-config.yaml"
+ auth:
+ experimentalDynamicClientRegistration:
+ enabled: true
+
+ # Optional: limit valid callback URLs for added security
+ allowedRedirectUriPatterns:
+ - cursor://*
+ ```
+
+## Configuring MCP Clients
+
+The MCP server supports both **Server-Sent Events (SSE)** and **Streamable HTTP** protocols.
+
+:::warning
+The SSE protocol is deprecated and will be removed in a future release.
+:::
+
+### Endpoints
+
+- **Streamable HTTP:** `http://localhost:7007/api/mcp-actions/v1`
+- **SSE (deprecated):** `http://localhost:7007/api/mcp-actions/v1/sse`
+
+```json
+{
+ "mcpServers": {
+ "backstage-actions": {
+ "url": "http://localhost:7007/api/mcp-actions/v1",
+ "headers": {
+ "Authorization": "Bearer ${MCP_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+The `${MCP_TOKEN}` environment variable would be an [external access static token](#external-access-with-static-tokens).
+
+### Multiple Servers
+
+When `mcpActions.servers` is configured, each server key becomes part of the URL. For example, with servers named `catalog` and `scaffolder`:
+
+- `http://localhost:7007/api/mcp-actions/v1/catalog`
+- `http://localhost:7007/api/mcp-actions/v1/scaffolder`
+
+```json
+{
+ "mcpServers": {
+ "backstage-catalog": {
+ "url": "http://localhost:7007/api/mcp-actions/v1/catalog",
+ "headers": {
+ "Authorization": "Bearer ${MCP_TOKEN}"
+ }
+ },
+ "backstage-scaffolder": {
+ "url": "http://localhost:7007/api/mcp-actions/v1/scaffolder",
+ "headers": {
+ "Authorization": "Bearer ${MCP_TOKEN}"
+ }
+ }
+ }
+}
+```
+
+## Metrics
+
+The MCP Actions Backend emits metrics for the following operations:
+
+- `mcp.server.operation.duration`: The duration taken to process an individual MCP operation
+- `mcp.server.session.duration`: The duration of the MCP session from the perspective of the server
+
+See the [OpenTelemetry tutorial](../tutorials/setup-opentelemetry.md) to learn how to make these metrics available.
diff --git a/docs/ai/well-known-actions.md b/docs/ai/well-known-actions.md
new file mode 100644
index 0000000000..d6de8407cf
--- /dev/null
+++ b/docs/ai/well-known-actions.md
@@ -0,0 +1,30 @@
+---
+id: well-known-actions
+title: Well-known Actions
+description: This section lists a number of well-known actions that are part of the Actions Registry.
+---
+
+This section lists a number of well-known [Actions](../backend-system/core-services/actions.md) registered with the [Actions Registry](../backend-system/core-services/actions-registry.md).
+
+## Actions
+
+This is a (non-exhaustive) list of actions that are known to be part of the Actions Registry. Entries are in the format: "`action-name` (Action Title): Shortened Action Description"
+
+### Auth
+
+- `auth.who-am-i` (Who Am I): Returns the catalog entity and user info for the currently authenticated user. This action requires user credentials and cannot be used with service or unauthenticated credentials.
+
+### Catalog
+
+- `catalog.get-catalog-entity` (Get Catalog Entity): This allows you to get a single entity from the software catalog.
+- `catalog.query-catalog-entities` (Query Catalog Entities): Query entities from the Backstage Software Catalog using predicate filters.
+- `catalog.register-entity` (Register entity in the Catalog): Registers one or more entities in the Backstage catalog by creating a Location entity that points to a remote `catalog-info.yaml` file.
+- `catalog.unregister-entity` (Unregister entity from the Catalog): Unregisters a Location entity and all entities it owns from the Backstage catalog.
+- `catalog.validate-entity` (Validate Catalog Entity): This action can be used to validate `catalog-info.yaml` file contents meant to be used with the software catalog.
+
+### Scaffolder
+
+- `scaffolder.dry-run-template` (Dry Run Scaffolder Template): Dry-runs a scaffolder template to validate it without making changes. Returns success with execution logs, or errors for validation failures.
+- `scaffolder.list-scaffolder-actions` (List Scaffolder Actions): Lists all installed Scaffolder actions.
+- `scaffolder.list-scaffolder-tasks` (List Scaffolder Tasks): This allows you to list scaffolder tasks that have been created.
+- `scaffolder.get-scaffolder-task-logs` (Get Scaffolder Task Logs): This allows you to fetch the logs of a given scaffolder task.
diff --git a/docs/api/deprecations.md b/docs/api/deprecations.md
index 84c0300543..136d70778e 100644
--- a/docs/api/deprecations.md
+++ b/docs/api/deprecations.md
@@ -83,7 +83,7 @@ migrate to your own custom API.
First, you'll need to define a new Utility API reference. If you're only using
the API for sign-in, you can put the definition in `packages/app/src/apis.ts`.
-However, if you need to access your auth API inside plugins you you'll need to
+However, if you need to access your auth API inside plugins you'll need to
export it from a common package. If you don't already have one, we recommend
creating `@internal/apis` and from there exporting the API reference.
diff --git a/docs/contribute/project-structure.md b/docs/contribute/project-structure.md
index 372fb79844..f1be29c312 100644
--- a/docs/contribute/project-structure.md
+++ b/docs/contribute/project-structure.md
@@ -144,7 +144,10 @@ are separated out into their own folder, see further down.
- [`dev-utils/`](https://github.com/backstage/backstage/tree/master/packages/dev-utils) -
Helps you setup a plugin for isolated development so that it can be served
- separately.
+ separately. This is for plugins using the legacy frontend system.
+
+- [`frontend-dev-utils/`](https://github.com/backstage/backstage/tree/master/packages/frontend-dev-utils) -
+ Utilities for developing frontend plugins using the new frontend system. Provides the `createDevApp` helper for setting up a minimal development app in a plugin's `dev/` entry point.
- [`e2e-test/`](https://github.com/backstage/backstage/tree/master/packages/e2e-test) -
Another CLI that can be run to try out what would happen if you build all the
diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md
index 97f579c5a3..2a0e20cbdc 100644
--- a/docs/features/software-catalog/api.md
+++ b/docs/features/software-catalog/api.md
@@ -150,7 +150,54 @@ Some more real world usable examples:
#### Full text filtering
-TODO
+You can perform a text search across entity fields using the `fullTextFilterTerm`
+query parameter. This performs a case-insensitive substring match against the
+values in the entity YAML fields.
+
+By default, when no `fullTextFilterFields` parameter is specified, the search
+runs against the current sort field (from `orderField`), or `metadata.uid` if
+no sort field is set. This means that without specifying fields explicitly, the
+search may not match against the fields you expect.
+
+To control which fields are searched, pass the `fullTextFilterFields` query
+parameter as a comma-separated list of entity field paths.
+
+Query parameters:
+
+- `fullTextFilterTerm` - The text to search for (case insensitive, substring match)
+- `fullTextFilterFields` - A comma-separated list of entity field paths to
+ search against (e.g. `metadata.name,metadata.title`)
+
+Example:
+
+```text
+/entities/by-query?fullTextFilterTerm=my-service&fullTextFilterFields=metadata.name,metadata.title
+
+ Return entities whose metadata.name OR metadata.title contains "my-service"
+```
+
+Some more real world usable examples:
+
+- Search for components by name:
+
+ `/entities/by-query?filter=kind=component&fullTextFilterTerm=payment&fullTextFilterFields=metadata.name`
+
+- Search across both name and title:
+
+ `/entities/by-query?filter=kind=system&fullTextFilterTerm=platform&fullTextFilterFields=metadata.name,metadata.title`
+
+- Combine with other filters (e.g. owned by a specific group):
+
+ `/entities/by-query?filter=kind=component,relations.ownedBy=group:default/my-team&fullTextFilterTerm=api&fullTextFilterFields=metadata.name`
+
+:::note Note
+
+Full text filtering is mutually exclusive with cursor-based pagination. When a
+`cursor` is provided, `fullTextFilterTerm` and `fullTextFilterFields` are
+ignored — the cursor already encodes the original filter parameters from the
+initial request.
+
+:::
#### Field selection
diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md
index 8c9241eb36..6ceb00b3b5 100644
--- a/docs/features/software-catalog/configuration.md
+++ b/docs/features/software-catalog/configuration.md
@@ -402,3 +402,46 @@ spec:
definition:
$openapi: ./spec/openapi.yaml # by using $openapi Backstage will now resolve all $ref instances
```
+
+## Backstage OpenAPI Module
+
+As Backstage increasingly uses OpenAPI to define its core APIs (such as the Catalog and Scaffolder), discovering and interacting with these APIs is essential for integrating external tools.
+
+You can install the **Backstage OpenAPI Module** to easily expose the OpenAPI specifications for your Backstage instance plugins directly into the catalog.
+
+### Installation
+
+1. Install the module in your backend:
+
+```bash
+yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-backstage-openapi
+```
+
+2. Register the module in your backend:
+
+```ts title="packages/backend/src/index.ts"
+backend.add(
+ import('@backstage/plugin-catalog-backend-module-backstage-openapi'),
+);
+```
+
+3. Add the configuration to your `app-config.yaml`:
+
+```yaml title="app-config.yaml"
+catalog:
+ providers:
+ backstageOpenapi:
+ plugins:
+ - catalog
+ - scaffolder
+ # Optional configuration:
+ # definitionFormat controls how generated definitions are serialized.
+ # Supported values: 'json' (default) or 'yaml'.
+ # definitionFormat: json
+ # entityOverrides can be used to override parts of the produced entities.
+ # For example, to add a tag to all generated APIs:
+ # entityOverrides:
+ # metadata:
+ # tags:
+ # - from-openapi
+```
diff --git a/docs/features/techdocs/addons--new.md b/docs/features/techdocs/addons--new.md
index af7f4d2893..170bd80f70 100644
--- a/docs/features/techdocs/addons--new.md
+++ b/docs/features/techdocs/addons--new.md
@@ -89,12 +89,12 @@ page header, TechDocs Addons whose location is `Header` will not be rendered.
Addons can, in principle, be provided by any plugin! To make it easier to
discover available Addons, we've compiled a list of them here:
-| Addon | Package/Plugin | Description |
-| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| [`techDocsExpandableNavigationAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsExpandableNavigationAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. |
-| [`techDocsReportIssueAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsReportIssueAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. |
-| [`techDocsTextSizeAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsTextSizeAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. |
-| [`techDocsLightBoxAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsLightBoxAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). |
+| Addon | Package/Plugin | Description |
+| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [`techDocsExpandableNavigationAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsExpandableNavigationAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. |
+| [`techDocsReportIssueAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsReportIssueAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. |
+| [`techDocsTextSizeAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsTextSizeAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. |
+| [`techDocsLightBoxAddonModule`](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.plugins_techdocs-module-addons-contrib_src_alpha.techDocsLightBoxAddonModule.html) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). Images inside links are ignored to avoid blocking navigation. |
Got an Addon to contribute? Feel free to add a row above!
diff --git a/docs/features/techdocs/addons.md b/docs/features/techdocs/addons.md
index 78b472edf2..72e8aa39e8 100644
--- a/docs/features/techdocs/addons.md
+++ b/docs/features/techdocs/addons.md
@@ -126,12 +126,12 @@ page header, TechDocs Addons whose location is `Header` will not be rendered.
Addons can, in principle, be provided by any plugin! To make it easier to
discover available Addons, we've compiled a list of them here:
-| Addon | Package/Plugin | Description |
-| -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.ExpandableNavigation.html) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. |
-| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.ReportIssue.html) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. |
-| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.TextSize.html) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. |
-| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.LightBox.html) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). |
+| Addon | Package/Plugin | Description |
+| -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.ExpandableNavigation.html) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. |
+| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.ReportIssue.html) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. |
+| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.TextSize.html) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. |
+| [``](https://backstage.io/api/stable/variables/_backstage_plugin-techdocs-module-addons-contrib.index.LightBox.html) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). Images inside links are ignored to avoid blocking navigation. |
Got an Addon to contribute? Feel free to add a row above!
diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md
index 0303dd5e70..22457bcb30 100644
--- a/docs/frontend-system/architecture/10-app.md
+++ b/docs/frontend-system/architecture/10-app.md
@@ -46,31 +46,7 @@ App feature discovery lets you automatically discover and install features provi
Because feature discovery needs to interact with the compilation process, it is only available when using the `@backstage/cli` to build your app. It is hooked into the WebPack compilation process by scanning your app package for compatible dependencies, which are then made part of the app compilation bundle.
-To enable frontend feature discovery, add the following configuration to your `app-config.yaml`:
-
-```yaml
-app:
- packages: all
-```
-
-This will cause all dependencies in your app package to be installed automatically. If this is not desired, you can use include or exclude filters to narrow down the set of packages:
-
-```yaml
-app:
- packages:
- # Only the following packages will be included
- include:
- - '@backstage/plugin-catalog'
- - '@backstage/plugin-scaffolder'
----
-app:
- packages:
- # All but the following package will be included
- exclude:
- - '@backstage/plugin-catalog'
-```
-
-Note that you do not need to manually exclude packages that you also import explicitly in code, since plugin instances are deduplicated by the app. You will never end up with duplicate plugin installations except if they are in fact two different plugin instances with different IDs.
+For information on how to configure feature discovery and other installation options, see [Installing Plugins](../building-apps/05-installing-plugins.md).
## Plugin Info Resolution
diff --git a/docs/frontend-system/building-apps/01-index.md b/docs/frontend-system/building-apps/01-index.md
index 8610dfb7b5..bd9a0fbae5 100644
--- a/docs/frontend-system/building-apps/01-index.md
+++ b/docs/frontend-system/building-apps/01-index.md
@@ -63,13 +63,9 @@ Visit the [built-in extensions](#customize-or-override-built-in-extensions) sect
Linking routes from different plugins requires this configuration. You can do this either through a configuration file or by coding, visit [this](https://backstage.io/docs/frontend-system/architecture/routes#binding-external-route-references) page for instructions.
-### Enable feature discovery
+### Install plugins
-Use this setting to enable experimental feature discovery when building your app with `@backstage/cli`. With this configuration your application tries to discover and install package extensions automatically, check [here](../architecture/10-app.md#feature-discovery) for more details.
-
-:::warning
-Remember that package extensions that are not auto-discovered must be manually added to the application when creating an app. See [features](#install-features-manually) for more details.
-:::
+Plugins are typically installed by adding them as dependencies of your app package and relying on feature discovery to automatically detect them. For details on how this works, including how to manually install plugins or control which packages are discovered, see [Installing Plugins](./05-installing-plugins.md).
### Configure extensions individually
@@ -83,7 +79,7 @@ Previously you would customize the application routes, components, apis, sidebar
### Install features manually
-A manual installation is required if your packages are not discovered automatically, either because you are not using `@backstage/cli` to build your application or because the features are defined in local modules in the app package. In order to manually install a feature, you must import it and pass it to the `createApp` function:
+Most plugins are installed automatically through [feature discovery](./05-installing-plugins.md#feature-discovery). Manual installation is needed if your packages are not discovered automatically, either because you are not using `@backstage/cli` to build your application or because the features are defined in local modules in the app package. In order to manually install a feature, you must import it and pass it to the `createApp` function:
```tsx title="packages/app/src/App.tsx"
import { createApp } from '@backstage/frontend-defaults';
diff --git a/docs/frontend-system/building-apps/05-installing-plugins.md b/docs/frontend-system/building-apps/05-installing-plugins.md
new file mode 100644
index 0000000000..9007ce0eb1
--- /dev/null
+++ b/docs/frontend-system/building-apps/05-installing-plugins.md
@@ -0,0 +1,71 @@
+---
+id: installing-plugins
+title: Installing Plugins
+sidebar_label: Installing Plugins
+description: How to install frontend plugins in a Backstage app
+---
+
+Frontend plugins are installed in your Backstage app by adding them as dependencies of your app package. Most of the time this is all you need to do, as the app will automatically discover and install the plugin.
+
+## Install a plugin package
+
+To install a plugin, add it as a dependency to your app package. For example, to install the catalog plugin:
+
+```bash title="From your Backstage root directory"
+yarn --cwd packages/app add @backstage/plugin-catalog
+```
+
+If your app is set up with [feature discovery](#feature-discovery), the plugin will be automatically detected and installed in the app. No additional code changes are needed.
+
+## Feature discovery
+
+Feature discovery lets the app automatically discover and install plugins from the dependencies of your app package. This is enabled by setting `app.packages` to `all` in your `app-config.yaml`:
+
+```yaml title="app-config.yaml"
+app:
+ packages: all
+```
+
+This is the recommended setup and is the default for all new Backstage apps. With this enabled, any plugin that is added as a dependency of your app package will be automatically discovered and installed. You can use include or exclude filters to control which packages are discovered:
+
+```yaml title="app-config.yaml"
+app:
+ packages:
+ include:
+ - '@backstage/plugin-catalog'
+ - '@backstage/plugin-scaffolder'
+```
+
+```yaml title="app-config.yaml"
+app:
+ packages:
+ exclude:
+ - '@backstage/plugin-catalog'
+```
+
+Feature discovery requires that your app is built using the `@backstage/cli`, which is the default for all Backstage apps. Note that you do not need to exclude packages that you also install manually in code, since plugin instances are deduplicated by the app.
+
+For more details on how feature discovery works under the hood, see the [Feature Discovery](../architecture/10-app.md#feature-discovery) architecture documentation.
+
+## Manual installation
+
+If your app does not have [feature discovery](#feature-discovery) enabled, or if you need more control over the plugin installation, you can install plugins manually. This is done by importing the plugin and passing it to `createApp`:
+
+```tsx title="packages/app/src/App.tsx"
+import { createApp } from '@backstage/frontend-defaults';
+import catalogPlugin from '@backstage/plugin-catalog/alpha';
+
+const app = createApp({
+ features: [catalogPlugin],
+});
+
+export default app.createRoot();
+```
+
+Manual installation may also be necessary if you need to control the ordering of plugins, for example when customizing route priorities. Since manually installed plugins are deduplicated against automatically discovered ones, you can safely install a plugin both manually and through feature discovery without causing conflicts.
+
+If you need to use a 3rd-party plugin that does not yet support the new frontend system, you can use the conversion utilities from `@backstage/core-compat-api` to wrap it. See [Converting 3rd-party Plugins](./06-plugin-conversion.md) for details.
+
+## Configuring installed plugins
+
+Once a plugin is installed, you can configure its extensions through the `app.extensions` section of your `app-config.yaml`. See [Configuring Extensions](./02-configuring-extensions.md) for details.
diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md
index d9acf76e15..861f454280 100644
--- a/docs/frontend-system/building-plugins/01-index.md
+++ b/docs/frontend-system/building-plugins/01-index.md
@@ -110,6 +110,21 @@ What we've built here is a very common type of plugin. It's a top-level tool tha
We have also provided external access to our route reference by passing it to the plugin `routes` option. This makes it possible for app integrators to bind an external link from a different plugin to our plugin page. You can read more about how this works in the [External Route References](../architecture/36-routes.md#external-route-references) section.
+## Running a dev server
+
+Each frontend plugin package has a `dev/` folder that is used as the entry point when you run `yarn start`. This is a convenient way to run your plugin in isolation during development. The `@backstage/frontend-dev-utils` package provides a `createDevApp` helper that sets up a minimal app for this purpose:
+
+```tsx title="in dev/index.ts"
+import { createDevApp } from '@backstage/frontend-dev-utils';
+import myPlugin from '../src';
+
+createDevApp({ features: [myPlugin] });
+```
+
+This will create and render a Backstage app with only your plugin installed. If you need to include additional features that your plugin depends on, pass them along in the `features` array. You can also use `bindRoutes` to wire up any external routes that your plugin depends on.
+
+The dev setup is started by running `yarn start` in the plugin directory, which uses the `backstage-cli package start` command. It sets up a local development server with hot reloading, just like a full app.
+
## Utility APIs
Another type of extensions that is commonly used are [Utility APIs](../utility-apis/01-index.md). They can encapsulate shared pieces of functionality of your plugin, for example an API client for a backend service. You can optionally export your Utility API for other plugins to use, or allow integrators to replace the implementation of your Utility API with their own. For details on how to define and provide your own Utility API in your plugin, see the section on [creating Utility APIs](../utility-apis/02-creating.md).
diff --git a/docs/getting-started/homepage--old.md b/docs/getting-started/homepage--old.md
new file mode 100644
index 0000000000..98021d76a2
--- /dev/null
+++ b/docs/getting-started/homepage--old.md
@@ -0,0 +1,201 @@
+---
+id: homepage--old
+title: Backstage homepage - Setup and Customization (Old Frontend System)
+description: Documentation on setting up and customizing Backstage homepage
+---
+
+::::info
+This documentation is for Backstage apps that still use the old frontend
+system. If your app uses the new frontend system, read the
+[current homepage guide](./homepage.md) instead.
+::::
+
+## Homepage
+
+Having a good Backstage homepage can significantly improve the discoverability
+of the platform. You want your users to find all the things they need right
+from the homepage and never have to remember direct URLs in Backstage. The
+[Home plugin](https://github.com/backstage/backstage/tree/master/plugins/home)
+introduces a system for composing a homepage for Backstage in order to surface
+relevant info and provide convenient shortcuts for common tasks. It's designed
+with composability in mind with an open ecosystem that allows anyone to
+contribute with any component, to be included in any homepage.
+
+For App Integrators, the system is designed to be composable to give total
+freedom in designing a Homepage that suits the needs of the organization. From
+the perspective of a Component Developer who wishes to contribute with building
+blocks to be included in Homepages, there's a convenient interface for bundling
+the different parts and exporting them with both error boundary and lazy
+loading handled under the surface.
+
+At the end of this tutorial, you can expect:
+
+- Your Backstage app to have a dedicated homepage instead of Software Catalog.
+- Understand the composability of homepage and how to start customizing it for
+ your own organization.
+
+### Prerequisites
+
+Before we begin, make sure
+
+- You have created your own standalone Backstage app using
+ [`@backstage/create-app`](./index.md#1-create-your-backstage-app) and not
+ using a fork of the [backstage](https://github.com/backstage/backstage)
+ repository.
+- You do not have an existing homepage, and by default you are redirected to
+ Software Catalog when you open Backstage.
+
+Now, let's get started by installing the home plugin and creating a simple
+homepage for your Backstage app.
+
+## Setup
+
+### 1. Install the plugin
+
+```bash title="From your Backstage root directory"
+yarn --cwd packages/app add @backstage/plugin-home
+```
+
+### 2. Create a new HomePage component
+
+Inside your `packages/app` directory, create a new file where our new homepage
+component is going to live. Create `packages/app/src/components/home/HomePage.tsx`
+with the following initial code
+
+```tsx
+export const HomePage = () => (
+ /* We will shortly compose a pretty homepage here. */
+
Welcome to Backstage!
+);
+```
+
+### 3. Update router for the root `/` route
+
+If you don't have a homepage already, most likely you have a redirect setup to
+use the Catalog homepage as a homepage.
+
+Inside your `packages/app/src/App.tsx`, look for
+
+```tsx title="packages/app/src/App.tsx"
+const routes = (
+
+
+ {/* ... */}
+
+);
+```
+
+Let's replace the `` line and use the new component we created in the
+previous step as the new homepage.
+
+```tsx title="packages/app/src/App.tsx"
+/* highlight-add-start */
+import { HomepageCompositionRoot } from '@backstage/plugin-home';
+import { HomePage } from './components/home/HomePage';
+/* highlight-add-end */
+
+const routes = (
+
+ {/* highlight-remove-next-line */}
+
+ {/* highlight-add-start */}
+ }>
+
+
+ {/* highlight-add-end */}
+ {/* ... */}
+
+);
+```
+
+### 4. Update sidebar items
+
+Let's update the route for "Home" in the Backstage sidebar to point to the new
+homepage. We'll also add a Sidebar item to quickly open Catalog.
+
+| Before | After |
+| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
+|  |  |
+
+The code for the Backstage sidebar is most likely inside your
+[`packages/app-legacy/src/components/Root/Root.tsx`](https://github.com/backstage/backstage/blob/master/packages/app-legacy/src/components/Root/Root.tsx).
+
+Let's make the following changes
+
+```tsx title="packages/app/src/components/Root/Root.tsx"
+/* highlight-add-next-line */
+import CategoryIcon from '@material-ui/icons/Category';
+
+export const Root = ({ children }: PropsWithChildren<{}>) => (
+
+
+
+ {/* ... */}
+ }>
+ {/* Global nav, not org-specific */}
+ {/* highlight-remove-next-line */}
+
+ {/* highlight-add-start */}
+
+
+ {/* highlight-add-end */}
+
+
+
+
+ {/* End global nav */}
+
+ {/* ... */}
+
+
+
+);
+```
+
+That's it! You should now have _(although slightly boring)_ a homepage!
+
+
+
+
+
+In the next steps, we will make it interesting and useful!
+
+#### Use the default template
+
+There is a default homepage template
+([storybook link](https://backstage.io/storybook/?path=/story/plugins-home-templates--default-template))
+which we will use to set up our homepage. Checkout the
+[blog post announcement](https://backstage.io/blog/2022/01/25/backstage-homepage-templates)
+about the Backstage homepage templates for more information.
+
+
+
+#### Composing your homepage
+
+Composing a homepage is no different from creating a regular React Component,
+i.e. the App Integrator is free to include whatever content they like. However,
+there are components developed with the homepage in mind. If you are looking
+for components to use when composing your homepage, you can take a look at the
+[collection of Homepage components](https://backstage.io/storybook?path=/story/plugins-home-components)
+in storybook. If you don't find a component that suits your needs but want to
+contribute, check the
+[Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing).
+
+> If you want to use one of the available homepage templates you can find the
+> [templates](https://backstage.io/storybook/?path=/story/plugins-home-templates)
+> in the storybook under the "Home" plugin. And if you would like to contribute
+> a template, please see the
+> [Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing)
+
+```tsx
+import Grid from '@material-ui/core/Grid';
+import { HomePageCompanyLogo } from '@backstage/plugin-home';
+
+export const HomePage = () => (
+
+
+
+
+
+);
+```
diff --git a/docs/getting-started/homepage.md b/docs/getting-started/homepage.md
index 05e422610a..22761eb6ed 100644
--- a/docs/getting-started/homepage.md
+++ b/docs/getting-started/homepage.md
@@ -4,6 +4,13 @@ title: Backstage homepage - Setup and Customization
description: Documentation on setting up and customizing Backstage homepage
---
+::::info
+This documentation is written for the new frontend system, which is the default
+in new Backstage apps. If your Backstage app still uses the old frontend system,
+read the [old frontend system version of this guide](./homepage--old.md)
+instead.
+::::
+
## Homepage
Having a good Backstage homepage can significantly improve the discoverability of the platform. You want your users to find all the things they need right from the homepage and never have to remember direct URLs in Backstage. The [Home plugin](https://github.com/backstage/backstage/tree/master/plugins/home) introduces a system for composing a homepage for Backstage in order to surface relevant info and provide convenient shortcuts for common tasks. It's designed with composability in mind with an open ecosystem that allows anyone to contribute with any component, to be included in any homepage.
@@ -24,39 +31,17 @@ Before we begin, make sure
Now, let's get started by installing the home plugin and creating a simple homepage for your Backstage app.
-## Setup Methods
+## Setup
-There are two ways to set up the home plugin, depending on which frontend system your Backstage app uses:
-
-1. **New Frontend System (Recommended)** - For apps using the new plugin system with extensions and blueprints
-2. **Legacy Frontend System** - For existing apps using the legacy plugin architecture
-
-### New Frontend System Setup
-
-If your Backstage app uses the [new frontend system](../frontend-system/index.md), follow these steps:
-
-#### 1. Install the plugin
+### 1. Install the plugin
```bash title="From your Backstage root directory"
yarn --cwd packages/app add @backstage/plugin-home
```
-#### 2. Add the plugin to your app configuration
+Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](../frontend-system/building-apps/05-installing-plugins.md).
-Update your `packages/app/src/app.tsx` to include the home plugin:
-
-```tsx title="packages/app/src/app.tsx"
-import homePlugin from '@backstage/plugin-home/alpha';
-
-const app = createApp({
- features: [
- // ... other plugins
- homePlugin,
- ],
-});
-```
-
-#### 3. Configure the homepage as your root route
+### 2. Configure the homepage as your root route
By default, the homepage will be available at `/home`. To make it your app's landing page at `/`, add this configuration to your `app-config.yaml`:
@@ -70,7 +55,7 @@ app:
The plugin will automatically add a "Home" navigation item to your sidebar and provide a basic homepage layout.
-#### 4. Optional: Enable visit tracking
+### 3. Optional: Enable visit tracking
Visit tracking is an optional feature that allows users to see their recently visited and most visited pages on the homepage. This feature is **disabled by default** to give you control over what data is collected and stored.
@@ -88,156 +73,12 @@ app:
- app-root-element:home/visit-listener: true
```
-#### 5. Customizing your homepage
+### 4. Customizing your homepage
-The New Frontend System provides powerful customization options:
+The home plugin provides powerful customization options:
**Custom Homepage Layouts**: Use the `HomePageLayoutBlueprint` from `@backstage/plugin-home-react/alpha` to create custom homepage layouts with your own design and widget arrangements. A layout receives the installed widgets and is responsible for rendering them. If no custom layout is installed, the plugin provides a built-in default.
**Adding Homepage Widgets**: Register custom widgets using the `HomePageWidgetBlueprint` from the `@backstage/plugin-home-react/alpha` package.
For detailed instructions on creating custom layouts, registering widgets, and advanced configuration options, see the [Home plugin documentation](https://github.com/backstage/backstage/tree/master/plugins/home#readme).
-
-### Legacy Frontend System Setup
-
-If your Backstage app uses the legacy frontend system, follow these steps:
-
-#### 1. Install the plugin
-
-```bash title="From your Backstage root directory"
-yarn --cwd packages/app add @backstage/plugin-home
-```
-
-#### 2. Create a new HomePage component
-
-Inside your `packages/app` directory, create a new file where our new homepage component is going to live. Create `packages/app/src/components/home/HomePage.tsx` with the following initial code
-
-```tsx
-export const HomePage = () => (
- /* We will shortly compose a pretty homepage here. */
-
Welcome to Backstage!
-);
-```
-
-#### 3. Update router for the root `/` route
-
-If you don't have a homepage already, most likely you have a redirect setup to use the Catalog homepage as a homepage.
-
-Inside your `packages/app/src/App.tsx`, look for
-
-```tsx title="packages/app/src/App.tsx"
-const routes = (
-
-
- {/* ... */}
-
-);
-```
-
-Let's replace the `` line and use the new component we created in the previous step as the new homepage.
-
-```tsx title="packages/app/src/App.tsx"
-/* highlight-add-start */
-import { HomepageCompositionRoot } from '@backstage/plugin-home';
-import { HomePage } from './components/home/HomePage';
-/* highlight-add-end */
-
-const routes = (
-
- {/* highlight-remove-next-line */}
-
- {/* highlight-add-start */}
- }>
-
-
- {/* highlight-add-end */}
- {/* ... */}
-
-);
-```
-
-#### 4. Update sidebar items
-
-Let's update the route for "Home" in the Backstage sidebar to point to the new homepage. We'll also add a Sidebar item to quickly open Catalog.
-
-| Before | After |
-| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
-|  |  |
-
-The code for the Backstage sidebar is most likely inside your [`packages/app-legacy/src/components/Root/Root.tsx`](https://github.com/backstage/backstage/blob/master/packages/app-legacy/src/components/Root/Root.tsx).
-
-Let's make the following changes
-
-```tsx title="packages/app/src/components/Root/Root.tsx"
-/* highlight-add-next-line */
-import CategoryIcon from '@material-ui/icons/Category';
-
-export const Root = ({ children }: PropsWithChildren<{}>) => (
-
-
-
- {/* ... */}
- }>
- {/* Global nav, not org-specific */}
- {/* highlight-remove-next-line */}
-
- {/* highlight-add-start */}
-
-
- {/* highlight-add-end */}
-
-
-
-
- {/* End global nav */}
-
- {/* ... */}
-
-
-
-);
-```
-
-That's it! You should now have _(although slightly boring)_ a homepage!
-
-
-
-
-
-In the next steps, we will make it interesting and useful!
-
-### Use the default template
-
-There is a default homepage template ([storybook link](https://backstage.io/storybook/?path=/story/plugins-home-templates--default-template)) which we will use to set up our homepage. Checkout the [blog post announcement](https://backstage.io/blog/2022/01/25/backstage-homepage-templates) about the Backstage homepage templates for more information.
-
-
-
-### Composing your homepage
-
-Composing a homepage is no different from creating a regular React Component,
-i.e. the App Integrator is free to include whatever content they like. However,
-there are components developed with the homepage in mind. If you are looking
-for components to use when composing your homepage, you can take a look at the
-[collection of Homepage components](https://backstage.io/storybook?path=/story/plugins-home-components)
-in storybook. If you don't find a component that suits your needs but want to
-contribute, check the
-[Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing).
-
-> If you want to use one of the available homepage templates you can find the
-> [templates](https://backstage.io/storybook/?path=/story/plugins-home-templates)
-> in the storybook under the "Home" plugin. And if you would like to contribute
-> a template, please see the
-> [Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing)
-
-```tsx
-import Grid from '@material-ui/core/Grid';
-import { HomePageCompanyLogo } from '@backstage/plugin-home';
-
-export const HomePage = () => (
-
-
-
-
-
-);
-```
diff --git a/docs/integrations/azure-blobStorage/discovery.md b/docs/integrations/azure-blobStorage/discovery.md
index f0953815c2..f0019a5c01 100644
--- a/docs/integrations/azure-blobStorage/discovery.md
+++ b/docs/integrations/azure-blobStorage/discovery.md
@@ -61,7 +61,7 @@ the Azure catalog plugin:
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-azure
```
-Then updated your backend by adding the following line:
+Then update your backend by adding the following line:
```ts title="packages/backend/src/index.ts"
backend.add(import('@backstage/plugin-catalog-backend'));
diff --git a/docs/integrations/azure/discovery.md b/docs/integrations/azure/discovery.md
index 0eafc938a2..67d70c7f6d 100644
--- a/docs/integrations/azure/discovery.md
+++ b/docs/integrations/azure/discovery.md
@@ -97,7 +97,7 @@ the Azure catalog plugin:
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-azure
```
-Then updated your backend by adding the following line:
+Then update your backend by adding the following line:
```ts title="packages/backend/src/index.ts"
backend.add(import('@backstage/plugin-catalog-backend'));
diff --git a/docs/integrations/azure/org.md b/docs/integrations/azure/org.md
index 92f3f9c12a..7bd93f38b3 100644
--- a/docs/integrations/azure/org.md
+++ b/docs/integrations/azure/org.md
@@ -41,7 +41,7 @@ catalog:
For large organizations, this plugin can take a long time, so be careful setting low frequency / timeouts and importing a large amount of users / groups for the first try.
:::
-Finally, updated your backend by adding the following line:
+Finally, update your backend by adding the following line:
```ts title="packages/backend/src/index.ts"
backend.add(import('@backstage/plugin-catalog-backend'));
diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md
index 8adc81184a..22a843009f 100644
--- a/docs/integrations/ldap/org.md
+++ b/docs/integrations/ldap/org.md
@@ -40,7 +40,7 @@ catalog:
timeout: PT15M
```
-Finally, updated your backend by adding the following line:
+Finally, update your backend by adding the following line:
```ts title="packages/backend/src/index.ts"
backend.add(import('@backstage/plugin-catalog-backend'));
diff --git a/docs/integrations/okta/org.md b/docs/integrations/okta/org.md
new file mode 100644
index 0000000000..63769e7c85
--- /dev/null
+++ b/docs/integrations/okta/org.md
@@ -0,0 +1,23 @@
+---
+id: org
+title: Okta Organizational Data
+sidebar_label: Org Data
+description: Ingesting organizational data from Okta into Backstage
+---
+
+The Backstage catalog can be set up to ingest organizational data — users and
+groups — directly from Okta. The result is a hierarchy of
+[`User`](../../features/software-catalog/descriptor-format.md#kind-user) and
+[`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind
+entities that mirror your Okta organization.
+
+This integration is provided by the community-maintained
+[`@roadiehq/catalog-backend-module-okta`](https://github.com/RoadieHQ/roadie-backstage-plugins/tree/main/plugins/backend/catalog-backend-module-okta)
+plugin, owned and maintained by [Roadie](https://roadie.io/).
+
+## Installation and configuration
+
+For setup instructions, including authentication options (API token and OAuth
+2.0), user/group filtering, custom naming strategies, and entity transformers,
+see the
+[plugin documentation maintained by Roadie](https://github.com/RoadieHQ/roadie-backstage-plugins/tree/main/plugins/backend/catalog-backend-module-okta).
diff --git a/docs/landing-page/doc-landing-page.md b/docs/landing-page/doc-landing-page.md
index 98a0ad9fb9..b7cc44c696 100644
--- a/docs/landing-page/doc-landing-page.md
+++ b/docs/landing-page/doc-landing-page.md
@@ -115,7 +115,7 @@ description: Documentation landing page.
,
+ }));
+ const render = jest.fn();
+ const createRoot = jest.fn(() => ({ render }));
+
+ jest.doMock('@backstage/frontend-defaults', () => ({
+ createApp,
+ }));
+ jest.doMock('@backstage/plugin-app', () => ({
+ __esModule: true,
+ default: {
+ withOverrides: jest.fn(() => 'app-plugin-override'),
+ getExtension: jest.fn(() => ({
+ override: jest.fn(() => 'disabled-sign-in-page'),
+ })),
+ },
+ }));
+ jest.doMock('react-dom/client', () => ({
+ __esModule: true,
+ createRoot,
+ }));
+
+ const root = document.createElement('div');
+ root.id = 'root';
+ document.body.appendChild(root);
+
+ const isolatedCreateDevApp = loadCreateDevAppIsolated();
+ isolatedCreateDevApp({
+ bindRoutes,
+ features: ['plugin-feature'] as any,
+ });
+
+ await waitFor(() => {
+ expect(createApp).toHaveBeenCalledWith({
+ bindRoutes,
+ features: ['app-plugin-override', 'plugin-feature'],
+ });
+ expect(createRoot).toHaveBeenCalledWith(root);
+ });
+
+ const renderedNode = render.mock.calls[0][0] as any;
+ expect(renderedNode.props.children).toHaveLength(2);
+ expect(renderedNode.props.children[0].props.fallback).toBeNull();
+ expect(renderedNode.props.children[1].props.children).toBe('Test App Root');
+ });
+
+ it('should throw a clear error when the root element is missing', () => {
+ expect(() => createDevApp({ features: [] })).toThrow(
+ "Could not find the dev app root element '#root'; make sure your dev entry HTML contains a root element with that id.",
+ );
+ });
+
+ it('should fall back to legacy react-dom rendering when createRoot is unavailable', async () => {
+ jest.resetModules();
+ delete process.env.HAS_REACT_DOM_CLIENT;
+
+ const createApp = jest.fn(() => ({
+ createRoot: () =>
Test App Root
,
+ }));
+ const render = jest.fn();
+
+ jest.doMock('@backstage/frontend-defaults', () => ({
+ createApp,
+ }));
+ jest.doMock('@backstage/plugin-app', () => ({
+ __esModule: true,
+ default: {
+ withOverrides: jest.fn(() => 'app-plugin-override'),
+ getExtension: jest.fn(() => ({
+ override: jest.fn(() => 'disabled-sign-in-page'),
+ })),
+ },
+ }));
+ jest.doMock('react-dom', () => ({
+ __esModule: true,
+ render,
+ }));
+
+ const root = document.createElement('div');
+ root.id = 'root';
+ document.body.appendChild(root);
+
+ const isolatedCreateDevApp = loadCreateDevAppIsolated();
+ isolatedCreateDevApp({
+ features: ['plugin-feature'] as any,
+ });
+
+ await waitFor(() => {
+ expect(render).toHaveBeenCalled();
+ expect(createApp).toHaveBeenCalledWith({
+ bindRoutes: undefined,
+ features: ['app-plugin-override', 'plugin-feature'],
+ });
+ });
+ });
+});
diff --git a/packages/frontend-dev-utils/src/createDevApp.tsx b/packages/frontend-dev-utils/src/createDevApp.tsx
new file mode 100644
index 0000000000..80cc95dc22
--- /dev/null
+++ b/packages/frontend-dev-utils/src/createDevApp.tsx
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ FrontendFeature,
+ FrontendFeatureLoader,
+} from '@backstage/frontend-plugin-api';
+import { createApp, CreateAppOptions } from '@backstage/frontend-defaults';
+import appPlugin from '@backstage/plugin-app';
+import { Suspense, lazy } from 'react';
+import 'react-dom';
+
+type AppPluginWithSimpleOverrides = {
+ withOverrides(options: { extensions: unknown[] }): FrontendFeature;
+};
+
+// Collapse the deeply nested override types to avoid excessive instantiation.
+const appPluginOverride = (
+ appPlugin as unknown as AppPluginWithSimpleOverrides
+).withOverrides({
+ extensions: [
+ appPlugin.getExtension('sign-in-page:app').override({
+ disabled: true,
+ }),
+ ],
+});
+
+const BuiCss = lazy(() => import('./BuiCss'));
+
+let ReactDOMPromise: Promise<
+ typeof import('react-dom') | typeof import('react-dom/client')
+>;
+if (process.env.HAS_REACT_DOM_CLIENT) {
+ ReactDOMPromise = import('react-dom/client');
+} else {
+ ReactDOMPromise = import('react-dom');
+}
+
+/**
+ * Options for {@link createDevApp}.
+ *
+ * @public
+ */
+export interface CreateDevAppOptions {
+ /**
+ * The list of features to load in the dev app.
+ */
+ features: (FrontendFeature | FrontendFeatureLoader)[];
+
+ /**
+ * Allows for the binding of plugins' external route refs within the dev app.
+ */
+ bindRoutes?: CreateAppOptions['bindRoutes'];
+}
+
+function getRootElement(): HTMLElement {
+ const rootElement = document.getElementById('root');
+
+ if (!rootElement) {
+ throw new Error(
+ "Could not find the dev app root element '#root'; make sure your dev entry HTML contains a root element with that id.",
+ );
+ }
+
+ return rootElement;
+}
+
+/**
+ * Creates and renders a minimal development app for the new frontend system.
+ *
+ * @example
+ * ```tsx
+ * // dev/index.ts
+ * import { createDevApp } from '@backstage/frontend-dev-utils';
+ * import myPlugin from '../src';
+ *
+ * createDevApp({ features: [myPlugin] });
+ * ```
+ *
+ * @public
+ */
+export function createDevApp(options: CreateDevAppOptions): void {
+ const rootElement = getRootElement();
+ const { features, bindRoutes } = options;
+ const devFeatures: CreateAppOptions['features'] = [
+ appPluginOverride,
+ ...features,
+ ];
+ const appOptions: CreateAppOptions = {
+ bindRoutes,
+ features: devFeatures,
+ };
+ const app = createApp(appOptions);
+ const AppRoot = app.createRoot();
+
+ ReactDOMPromise.then(ReactDOM => {
+ const rootNode = (
+ <>
+
+
+
+ {AppRoot}
+ >
+ );
+
+ if ('createRoot' in ReactDOM) {
+ ReactDOM.createRoot(rootElement).render(rootNode);
+ } else {
+ ReactDOM.render(rootNode, rootElement);
+ }
+ });
+}
diff --git a/packages/frontend-dev-utils/src/index.ts b/packages/frontend-dev-utils/src/index.ts
new file mode 100644
index 0000000000..d93fa3d473
--- /dev/null
+++ b/packages/frontend-dev-utils/src/index.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Utilities for developing Backstage frontend plugins using the new frontend system.
+ *
+ * @packageDocumentation
+ */
+
+export { createDevApp, type CreateDevAppOptions } from './createDevApp';
diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md
index 77a377bb8e..76678107ef 100644
--- a/packages/frontend-plugin-api/report-alpha.api.md
+++ b/packages/frontend-plugin-api/report-alpha.api.md
@@ -11,8 +11,11 @@ import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ReactNode } from 'react';
-// @alpha
+// @public
export type PluginWrapperApi = {
+ getRootWrapper(): ComponentType<{
+ children: ReactNode;
+ }>;
getPluginWrapper(pluginId: string):
| ComponentType<{
children: ReactNode;
@@ -20,31 +23,19 @@ export type PluginWrapperApi = {
| undefined;
};
-// @alpha
+// @public
export const pluginWrapperApiRef: ApiRef;
-// @alpha
+// @public
export const PluginWrapperBlueprint: ExtensionBlueprint<{
kind: 'plugin-wrapper';
- params: (params: {
- loader: () => Promise<{
- component: ComponentType<{
- children: ReactNode;
- }>;
- }>;
+ params: (params: {
+ loader: () => Promise>;
}) => ExtensionBlueprintParams<{
- loader: () => Promise<{
- component: ComponentType<{
- children: ReactNode;
- }>;
- }>;
+ loader: () => Promise;
}>;
output: ExtensionDataRef<
- () => Promise<{
- component: ComponentType<{
- children: ReactNode;
- }>;
- }>,
+ () => Promise,
'core.plugin-wrapper.loader',
{}
>;
@@ -53,16 +44,21 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{
configInput: {};
dataRefs: {
wrapper: ConfigurableExtensionDataRef<
- () => Promise<{
- component: ComponentType<{
- children: ReactNode;
- }>;
- }>,
+ () => Promise,
'core.plugin-wrapper.loader',
{}
>;
};
}>;
+// @public
+export type PluginWrapperDefinition = {
+ useWrapperValue?: () => TValue;
+ component: ComponentType<{
+ children: ReactNode;
+ value: TValue;
+ }>;
+};
+
// (No @packageDocumentation comment for this package)
```
diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md
index 4624e68dda..768f26c0eb 100644
--- a/packages/frontend-plugin-api/report.api.md
+++ b/packages/frontend-plugin-api/report.api.md
@@ -316,7 +316,7 @@ export const atlassianAuthApiRef: ApiRef<
export type AuthProviderInfo = {
id: string;
title: string;
- icon: IconComponent;
+ icon: IconComponent | IconElement;
message?: string;
};
@@ -1156,12 +1156,6 @@ export type ExtensionDataRef<
readonly config: TConfig;
};
-// @public (undocumented)
-export type ExtensionDataRefToValue =
- TDataRef extends ExtensionDataRef
- ? ExtensionDataValue
- : never;
-
// @public (undocumented)
export type ExtensionDataValue = {
readonly $$type: '@backstage/ExtensionDataValue';
@@ -1880,6 +1874,55 @@ export interface PluginOptions<
title?: string;
}
+// @public
+export type PluginWrapperApi = {
+ getRootWrapper(): ComponentType<{
+ children: ReactNode;
+ }>;
+ getPluginWrapper(pluginId: string):
+ | ComponentType<{
+ children: ReactNode;
+ }>
+ | undefined;
+};
+
+// @public
+export const pluginWrapperApiRef: ApiRef_2;
+
+// @public
+export const PluginWrapperBlueprint: ExtensionBlueprint_2<{
+ kind: 'plugin-wrapper';
+ params: (params: {
+ loader: () => Promise>;
+ }) => ExtensionBlueprintParams_2<{
+ loader: () => Promise;
+ }>;
+ output: ExtensionDataRef_2<
+ () => Promise,
+ 'core.plugin-wrapper.loader',
+ {}
+ >;
+ inputs: {};
+ config: {};
+ configInput: {};
+ dataRefs: {
+ wrapper: ConfigurableExtensionDataRef_2<
+ () => Promise,
+ 'core.plugin-wrapper.loader',
+ {}
+ >;
+ };
+}>;
+
+// @public
+export type PluginWrapperDefinition = {
+ useWrapperValue?: () => TValue;
+ component: ComponentType<{
+ children: ReactNode;
+ value: TValue;
+ }>;
+};
+
// @public (undocumented)
export type PortableSchema = {
parse: (input: TInput) => TOutput;
@@ -1907,14 +1950,6 @@ export const Progress: {
// @public (undocumented)
export type ProgressProps = {};
-// @public
-export type ResolvedExtensionInput =
- TExtensionInput['extensionData'] extends Array
- ? {
- node: AppNode;
- } & ExtensionDataContainer
- : never;
-
// @public
export type ResolvedExtensionInputs<
TInputs extends {
diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts
index 2251bfafb7..dfd7f1e2bf 100644
--- a/packages/frontend-plugin-api/src/alpha.ts
+++ b/packages/frontend-plugin-api/src/alpha.ts
@@ -14,7 +14,12 @@
* limitations under the License.
*/
-export { PluginWrapperBlueprint } from './blueprints/PluginWrapperBlueprint';
+// These exports are now available from the main entry point and are
+// re-exported here only for backwards compatibility.
+export {
+ PluginWrapperBlueprint,
+ type PluginWrapperDefinition,
+} from './blueprints/PluginWrapperBlueprint';
export {
type PluginWrapperApi,
pluginWrapperApiRef,
diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts
index a4b66d6128..8d9b224b1f 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts
@@ -15,21 +15,23 @@
*/
import { ComponentType, ReactNode } from 'react';
-import { createApiRef } from '@backstage/frontend-plugin-api';
+import { createApiRef } from '../system';
/**
- * The Plugin Wrapper API is used to wrap plugin extensions with providers,
- * plugins should generally use `ExtensionBoundary` instead.
+ * The Plugin Wrapper API allows plugins to wrap their extensions with
+ * providers. This API is only intended for internal use by the Backstage
+ * frontend system. To provide contexts to plugin components, use
+ * `ExtensionBoundary` instead.
*
- * @remarks
- *
- * This API is primarily intended for internal use by the Backstage frontend
- * system, but can be used for advanced use-cases. If you do override it, be
- * sure to include the default implementation as well.
- *
- * @alpha
+ * @public
*/
export type PluginWrapperApi = {
+ /**
+ * Returns the root wrapper that manages the global plugin state across
+ * plugin wrapper instances.
+ */
+ getRootWrapper(): ComponentType<{ children: ReactNode }>;
+
/**
* Returns a wrapper component for a specific plugin, or undefined if no
* wrappers exist. Do not use this API directly, instead use
@@ -43,8 +45,8 @@ export type PluginWrapperApi = {
/**
* The API reference of {@link PluginWrapperApi}.
*
- * @alpha
+ * @public
*/
export const pluginWrapperApiRef = createApiRef({
- id: 'core.plugin-wrapper.alpha',
+ id: 'core.plugin-wrapper',
});
diff --git a/packages/frontend-plugin-api/src/apis/definitions/auth.ts b/packages/frontend-plugin-api/src/apis/definitions/auth.ts
index 9a0ddcd958..76aa24e8f8 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/auth.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/auth.ts
@@ -17,7 +17,7 @@
/* eslint-disable @typescript-eslint/no-redeclare */
import { ApiRef, createApiRef } from '../system';
-import { IconComponent } from '../../icons/types';
+import { IconComponent, IconElement } from '../../icons/types';
import { Observable } from '@backstage/types';
/**
@@ -54,8 +54,13 @@ export type AuthProviderInfo = {
/**
* Icon for the auth provider.
+ *
+ * @remarks
+ *
+ * Accepts either an `IconElement` (e.g. ``) or an `IconComponent`
+ * (e.g. `MyIcon`). Prefer passing `IconElement`.
*/
- icon: IconComponent;
+ icon: IconComponent | IconElement;
/**
* Optional user friendly messaage to display for the auth provider.
diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts
index 06d96a50a3..582eacf345 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/index.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts
@@ -50,3 +50,4 @@ export * from './StorageApi';
export * from './AnalyticsApi';
export * from './TranslationApi';
export * from './PluginHeaderActionsApi';
+export * from './PluginWrapperApi';
diff --git a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx
index f6f673bd93..5119ae1617 100644
--- a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx
+++ b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx
@@ -21,14 +21,42 @@ import {
createExtensionDataRef,
} from '../wiring';
+/**
+ * Defines the structure of a plugin wrapper, optionally including a shared
+ * hook value.
+ *
+ * @remarks
+ *
+ * When `useWrapperValue` is provided, the hook is called in a single location
+ * in the app and the resulting value is forwarded as the `value` prop to the
+ * component. The hook obeys the rules of React hooks and is not called until a
+ * component from the plugin is rendered.
+ *
+ * @public
+ */
+export type PluginWrapperDefinition = {
+ /**
+ * Creates a shared value that is forwarded as the `value` prop to the
+ * component.
+ *
+ * @remarks
+ *
+ * This function obeys the rules of React hooks and is only invoked in a
+ * single location in the app. Note that the hook will not be called until a
+ * component from the plugin is rendered.
+ */
+ useWrapperValue?: () => TValue;
+ component: ComponentType<{ children: ReactNode; value: TValue }>;
+};
+
const wrapperDataRef = createExtensionDataRef<
- () => Promise<{ component: ComponentType<{ children: ReactNode }> }>
+ () => Promise
>().with({ id: 'core.plugin-wrapper.loader' });
/**
* Creates extensions that wrap plugin extensions with providers.
*
- * @alpha
+ * @public
*/
export const PluginWrapperBlueprint = createExtensionBlueprint({
kind: 'plugin-wrapper',
@@ -37,12 +65,12 @@ export const PluginWrapperBlueprint = createExtensionBlueprint({
dataRefs: {
wrapper: wrapperDataRef,
},
- defineParams(params: {
- loader: () => Promise<{
- component: ComponentType<{ children: ReactNode }>;
- }>;
+ defineParams(params: {
+ loader: () => Promise>;
}) {
- return createExtensionBlueprintParams(params);
+ return createExtensionBlueprintParams(
+ params as { loader: () => Promise },
+ );
},
*factory(params) {
yield wrapperDataRef(params.loader);
diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts
index d413776419..a571f6ef73 100644
--- a/packages/frontend-plugin-api/src/blueprints/index.ts
+++ b/packages/frontend-plugin-api/src/blueprints/index.ts
@@ -24,3 +24,7 @@ export { NavItemBlueprint } from './NavItemBlueprint';
export { PageBlueprint } from './PageBlueprint';
export { SubPageBlueprint } from './SubPageBlueprint';
export { PluginHeaderActionBlueprint } from './PluginHeaderActionBlueprint';
+export {
+ PluginWrapperBlueprint,
+ type PluginWrapperDefinition,
+} from './PluginWrapperBlueprint';
diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx
index 1215c7576f..d05e7c366f 100644
--- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx
+++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx
@@ -144,6 +144,10 @@ describe('ExtensionBoundary', () => {
};
const pluginWrapperApi: PluginWrapperApi = {
+ getRootWrapper:
+ () =>
+ ({ children }: { children: ReactNode }) =>
+ <>{children}>,
getPluginWrapper: jest.fn((pluginId: string) => {
if (pluginId === 'app') {
return WrapperComponent;
@@ -180,6 +184,10 @@ describe('ExtensionBoundary', () => {
};
const pluginWrapperApi: PluginWrapperApi = {
+ getRootWrapper:
+ () =>
+ ({ children }: { children: ReactNode }) =>
+ <>{children}>,
getPluginWrapper: jest.fn((pluginId: string) => {
if (pluginId === 'app') {
return ThrowingWrapper;
diff --git a/packages/frontend-plugin-api/src/icons/types.ts b/packages/frontend-plugin-api/src/icons/types.ts
index 1a45bcd8fc..180763ad09 100644
--- a/packages/frontend-plugin-api/src/icons/types.ts
+++ b/packages/frontend-plugin-api/src/icons/types.ts
@@ -42,7 +42,14 @@ export type IconComponent = ComponentType<{
*
* @remarks
*
- * Icons should be exactly 24x24 pixels in size.
+ * Icon elements should behave like rendering a plain icon directly, for example
+ * from `@remixicon/react`, and are expected to be sized by the surrounding UI.
+ * Icons should be exactly 24x24 pixels in size by default.
+ *
+ * Using icons from `@remixicon/react` is preferred. Using icons from
+ * `@material-ui/icons` or `AppIcon` and its variants from
+ * `@backstage/core-components` is supported while migrating, but deprecated.
+ * When using those icons, you must set `fontSize="inherit"` on the element.
*
* @public
*/
diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts
index 4d3eeece2c..329205ae76 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtension.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts
@@ -43,11 +43,8 @@ import { FrontendModule } from './createFrontendModule';
*/
export const ctxParamsSymbol = Symbol('params');
-/**
- * Convert a single extension input into a matching resolved input.
- * @public
- */
-export type ResolvedExtensionInput =
+/** @ignore */
+type ResolvedExtensionInput =
TExtensionInput['extensionData'] extends Array
? {
node: AppNode;
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts
index 27b12deb84..8ae176d249 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionDataRef.ts
@@ -33,7 +33,7 @@ export type ExtensionDataRef<
readonly config: TConfig;
};
-/** @public */
+/** @ignore */
export type ExtensionDataRefToValue =
TDataRef extends ExtensionDataRef
? ExtensionDataValue
diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts
index 044bf03b67..d3acef46b6 100644
--- a/packages/frontend-plugin-api/src/wiring/index.ts
+++ b/packages/frontend-plugin-api/src/wiring/index.ts
@@ -22,7 +22,6 @@ export {
type ExtensionDefinitionParameters,
type CreateExtensionOptions,
type OverridableExtensionDefinition,
- type ResolvedExtensionInput,
type ResolvedExtensionInputs,
} from './createExtension';
export {
@@ -32,7 +31,6 @@ export {
export {
createExtensionDataRef,
type ExtensionDataRef,
- type ExtensionDataRefToValue,
type ExtensionDataValue,
type ConfigurableExtensionDataRef,
} from './createExtensionDataRef';
diff --git a/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts b/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts
index b9f239842d..9a122c8288 100644
--- a/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts
+++ b/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts
@@ -16,7 +16,6 @@
import { AppNode } from '../apis';
import { Expand } from '@backstage/types';
-import { ResolvedExtensionInput } from './createExtension';
import { createExtensionDataContainer } from '@internal/frontend';
import {
ExtensionDataRefToValue,
@@ -124,7 +123,7 @@ export function resolveInputOverrides(
);
}
newInputs[name] = Object.assign(providedContainer, {
- node: (originalInput as ResolvedExtensionInput).node,
+ node: (originalInput as { node: AppNode }).node,
}) as any;
}
} else {
@@ -158,7 +157,7 @@ export function resolveInputOverrides(
declaredInput.extensionData,
);
return Object.assign(providedContainer, {
- node: (originalInput[i] as ResolvedExtensionInput).node,
+ node: (originalInput[i] as { node: AppNode }).node,
}) as any;
});
} else if (withNodesCount === providedData.length) {
diff --git a/packages/repo-tools/src/commands/api-reports/api-reports/runApiExtraction.ts b/packages/repo-tools/src/commands/api-reports/api-reports/runApiExtraction.ts
index 787a2cb3f4..6091237fae 100644
--- a/packages/repo-tools/src/commands/api-reports/api-reports/runApiExtraction.ts
+++ b/packages/repo-tools/src/commands/api-reports/api-reports/runApiExtraction.ts
@@ -174,6 +174,7 @@ export async function runApiExtraction({
filename =>
// https://regex101.com/r/QDZIV0/2
filename !== 'knip-report.md' &&
+ !filename.startsWith('cli-report') &&
!filename.endsWith('.sql.md') &&
// this has to temporarily match all old api report formats
filename.match(/^.*?(api-)?report(-[^.-]+)?(.*?)\.md$/),
diff --git a/packages/repo-tools/src/commands/api-reports/categorizePackageDirs.ts b/packages/repo-tools/src/commands/api-reports/categorizePackageDirs.ts
index 973c7b8acb..6fdb890100 100644
--- a/packages/repo-tools/src/commands/api-reports/categorizePackageDirs.ts
+++ b/packages/repo-tools/src/commands/api-reports/categorizePackageDirs.ts
@@ -54,9 +54,10 @@ export async function categorizePackageDirs(packageDirs: string[]) {
if (pkgJson?.backstage?.inline) {
return;
}
- if (role === 'cli') {
+ if (role === 'cli' || role === 'cli-module') {
cliPackageDirs.push(dir);
- } else if (role !== 'frontend' && role !== 'backend') {
+ }
+ if (role !== 'cli' && role !== 'frontend' && role !== 'backend') {
tsPackageDirs.push(dir);
}
}
diff --git a/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts b/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts
index ae49c95f39..ce207489a7 100644
--- a/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts
+++ b/packages/repo-tools/src/commands/api-reports/cli-reports/runCliExtraction.ts
@@ -132,7 +132,12 @@ export async function runCliExtraction({
const pkgJson = await fs.readJson(resolvePath(fullDir, 'package.json'));
if (!pkgJson.bin) {
- throw new Error(`CLI Package in ${packageDir} has no bin field`);
+ if (pkgJson.backstage?.role === 'cli') {
+ throw new Error(
+ `CLI package ${pkgJson.name} is missing a "bin" field in its package.json`,
+ );
+ }
+ continue;
}
const models = new Array();
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 27ad892ea8..a1df4bcf8a 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -60,7 +60,7 @@
"@types/use-sync-external-store": "^0.0.6",
"eslint-plugin-storybook": "^10.3.0-alpha.1",
"glob": "^11.0.1",
- "globals": "^15.11.0",
+ "globals": "^17.0.0",
"react": "^18.0.2",
"react-dom": "^18.0.2",
"react-router-dom": "^6.30.2",
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index 12159d7818..406ac5316e 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -766,7 +766,7 @@ export const Cell: {
displayName: string;
};
-// @public (undocumented)
+// @public
export type CellOwnProps = {
className?: string;
};
@@ -774,7 +774,7 @@ export type CellOwnProps = {
// @public (undocumented)
export const CellProfile: (props: CellProfileProps) => JSX_2.Element;
-// @public (undocumented)
+// @public
export type CellProfileOwnProps = {
src?: string;
name?: string;
@@ -784,12 +784,12 @@ export type CellProfileOwnProps = {
className?: string;
};
-// @public (undocumented)
+// @public
export interface CellProfileProps
extends CellProfileOwnProps,
Omit {}
-// @public (undocumented)
+// @public
export interface CellProps
extends CellOwnProps,
Omit {}
@@ -800,7 +800,7 @@ export const CellText: {
displayName: string;
};
-// @public (undocumented)
+// @public
export type CellTextOwnProps = {
title: string;
description?: string;
@@ -810,7 +810,7 @@ export type CellTextOwnProps = {
className?: string;
};
-// @public (undocumented)
+// @public
export interface CellTextProps
extends CellTextOwnProps,
Omit {}
@@ -830,12 +830,6 @@ export const CheckboxDefinition: {
readonly indicator: 'bui-CheckboxIndicator';
};
readonly propDefs: {
- readonly selected: {
- readonly dataAttribute: true;
- };
- readonly indeterminate: {
- readonly dataAttribute: true;
- };
readonly children: {};
readonly className: {};
};
@@ -843,8 +837,6 @@ export const CheckboxDefinition: {
// @public (undocumented)
export type CheckboxOwnProps = {
- selected?: boolean;
- indeterminate?: boolean;
children: React.ReactNode;
className?: string;
};
@@ -857,9 +849,8 @@ export interface CheckboxProps
// @public (undocumented)
export const Column: (props: ColumnProps) => JSX_2.Element;
-// @public (undocumented)
+// @public
export interface ColumnConfig {
- // (undocumented)
cell: (item: T) => ReactElement;
// (undocumented)
defaultWidth?: ColumnSize | null;
@@ -1417,11 +1408,11 @@ export const HeaderDefinition: {
readonly [key: string]: string;
};
readonly classNames: {
- readonly root: 'bui-HeaderPage';
- readonly content: 'bui-HeaderPageContent';
- readonly breadcrumbs: 'bui-HeaderPageBreadcrumbs';
- readonly tabsWrapper: 'bui-HeaderPageTabsWrapper';
- readonly controls: 'bui-HeaderPageControls';
+ readonly root: 'bui-Header';
+ readonly content: 'bui-HeaderContent';
+ readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
+ readonly tabsWrapper: 'bui-HeaderTabsWrapper';
+ readonly controls: 'bui-HeaderControls';
};
readonly propDefs: {
readonly title: {};
@@ -1458,11 +1449,11 @@ export const HeaderPageDefinition: {
readonly [key: string]: string;
};
readonly classNames: {
- readonly root: 'bui-HeaderPage';
- readonly content: 'bui-HeaderPageContent';
- readonly breadcrumbs: 'bui-HeaderPageBreadcrumbs';
- readonly tabsWrapper: 'bui-HeaderPageTabsWrapper';
- readonly controls: 'bui-HeaderPageControls';
+ readonly root: 'bui-Header';
+ readonly content: 'bui-HeaderContent';
+ readonly breadcrumbs: 'bui-HeaderBreadcrumbs';
+ readonly tabsWrapper: 'bui-HeaderTabsWrapper';
+ readonly controls: 'bui-HeaderControls';
};
readonly propDefs: {
readonly title: {};
@@ -2076,12 +2067,91 @@ export interface RowProps
extends RowOwnProps,
Omit, keyof RowOwnProps> {}
-// @public (undocumented)
+// @public
export type RowRenderFn = (params: {
item: T;
index: number;
}) => ReactNode;
+// @public (undocumented)
+export function SearchAutocomplete(
+ props: SearchAutocompleteProps,
+): JSX_2.Element;
+
+// @public
+export const SearchAutocompleteDefinition: {
+ readonly styles: {
+ readonly [key: string]: string;
+ };
+ readonly classNames: {
+ readonly root: 'bui-SearchAutocomplete';
+ readonly searchField: 'bui-SearchAutocompleteSearchField';
+ readonly searchFieldInput: 'bui-SearchAutocompleteInput';
+ readonly searchFieldClear: 'bui-SearchAutocompleteClear';
+ readonly popover: 'bui-SearchAutocompletePopover';
+ readonly inner: 'bui-SearchAutocompleteInner';
+ readonly listBox: 'bui-SearchAutocompleteListBox';
+ readonly loadingState: 'bui-SearchAutocompleteLoadingState';
+ readonly emptyState: 'bui-SearchAutocompleteEmptyState';
+ };
+ readonly propDefs: {
+ readonly 'aria-label': {};
+ readonly 'aria-labelledby': {};
+ readonly size: {
+ readonly dataAttribute: true;
+ readonly default: 'small';
+ };
+ readonly placeholder: {
+ readonly default: 'Search';
+ };
+ readonly inputValue: {};
+ readonly onInputChange: {};
+ readonly popoverWidth: {};
+ readonly popoverPlacement: {};
+ readonly children: {};
+ readonly isLoading: {};
+ readonly defaultOpen: {};
+ readonly className: {};
+ readonly style: {};
+ };
+};
+
+// @public (undocumented)
+export function SearchAutocompleteItem(
+ props: SearchAutocompleteItemProps,
+): JSX_2.Element;
+
+// @public (undocumented)
+export type SearchAutocompleteItemOwnProps = {
+ children: ReactNode;
+ className?: string;
+};
+
+// @public (undocumented)
+export interface SearchAutocompleteItemProps
+ extends SearchAutocompleteItemOwnProps,
+ Omit {}
+
+// @public (undocumented)
+export type SearchAutocompleteOwnProps = {
+ inputValue?: string;
+ onInputChange?: (value: string) => void;
+ size?: 'small' | 'medium' | Partial>;
+ 'aria-label'?: string;
+ 'aria-labelledby'?: string;
+ placeholder?: string;
+ popoverWidth?: string;
+ popoverPlacement?: PopoverProps_2['placement'];
+ children?: ReactNode;
+ isLoading?: boolean;
+ defaultOpen?: boolean;
+ className?: string;
+ style?: React.CSSProperties;
+};
+
+// @public (undocumented)
+export interface SearchAutocompleteProps extends SearchAutocompleteOwnProps {}
+
// @public (undocumented)
export const SearchField: ForwardRefExoticComponent<
SearchFieldProps & RefAttributes
@@ -2340,6 +2410,9 @@ export const TableDefinition: {
readonly stale: {
readonly dataAttribute: true;
};
+ readonly loading: {
+ readonly dataAttribute: true;
+ };
};
};
@@ -2449,6 +2522,8 @@ export interface TableProps {
sort?: SortState;
// (undocumented)
style?: React.CSSProperties;
+ // (undocumented)
+ virtualized?: VirtualizedProp;
}
// @public (undocumented)
@@ -2457,6 +2532,7 @@ export const TableRoot: (props: TableRootProps) => JSX_2.Element;
// @public (undocumented)
export type TableRootOwnProps = {
stale?: boolean;
+ loading?: boolean;
};
// @public (undocumented)
@@ -2971,6 +3047,16 @@ export interface UtilityProps extends SpaceProps {
rowSpan?: Responsive;
}
+// @public (undocumented)
+export type VirtualizedProp =
+ | boolean
+ | {
+ rowHeight: number;
+ }
+ | {
+ estimatedRowHeight: number;
+ };
+
// @public
export const VisuallyHidden: (props: VisuallyHiddenProps) => JSX_2.Element;
diff --git a/packages/ui/src/analytics/index.ts b/packages/ui/src/analytics/index.ts
index a9b073afa7..de42ed3181 100644
--- a/packages/ui/src/analytics/index.ts
+++ b/packages/ui/src/analytics/index.ts
@@ -15,8 +15,6 @@
*/
export { useAnalytics } from './useAnalytics';
-export { BUIProvider } from './BUIProvider';
-export type { BUIProviderProps } from './BUIProvider';
export { getNodeText } from './getNodeText';
export type {
AnalyticsTracker,
diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx
index 4b9b9b747c..5be8a7edca 100644
--- a/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx
+++ b/packages/ui/src/components/ButtonLink/ButtonLink.stories.tsx
@@ -19,6 +19,7 @@ import type { StoryFn } from '@storybook/react-vite';
import { ButtonLink } from './ButtonLink';
import { Flex } from '../Flex';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
import { RiArrowRightSLine, RiCloudLine } from '@remixicon/react';
const meta = preview.meta({
@@ -27,7 +28,9 @@ const meta = preview.meta({
decorators: [
(Story: StoryFn) => (
-
+
+
+
),
],
diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.tsx
index 96f477a4e8..4d6906f32f 100644
--- a/packages/ui/src/components/ButtonLink/ButtonLink.tsx
+++ b/packages/ui/src/components/ButtonLink/ButtonLink.tsx
@@ -19,7 +19,6 @@ import { Link as RALink } from 'react-aria-components';
import type { ButtonLinkProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { ButtonLinkDefinition } from './definition';
-import { InternalLinkProvider } from '../InternalLinkProvider';
import { getNodeText } from '../../analytics/getNodeText';
/** @public */
@@ -43,21 +42,19 @@ export const ButtonLink = forwardRef(
};
return (
-
-
-
- {iconStart}
- {children}
- {iconEnd}
-
-
-
+
+
+ {iconStart}
+ {children}
+ {iconEnd}
+
+
);
},
);
diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx
index 70a2438a49..aecca09512 100644
--- a/packages/ui/src/components/Card/Card.tsx
+++ b/packages/ui/src/components/Card/Card.tsx
@@ -77,7 +77,7 @@ export const Card = forwardRef((props, ref) => {
triggerRef.current.dispatchEvent(
new MouseEvent('click', {
- bubbles: false,
+ bubbles: true,
cancelable: true,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
diff --git a/packages/ui/src/components/Checkbox/Checkbox.module.css b/packages/ui/src/components/Checkbox/Checkbox.module.css
index deadc96358..54705d95e9 100644
--- a/packages/ui/src/components/Checkbox/Checkbox.module.css
+++ b/packages/ui/src/components/Checkbox/Checkbox.module.css
@@ -20,7 +20,7 @@
.bui-Checkbox {
display: flex;
flex-direction: row;
- align-items: center;
+ align-items: flex-start;
gap: var(--bui-space-2);
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
index e8f7859785..94811d5cb6 100644
--- a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
+++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx
@@ -16,6 +16,8 @@
import preview from '../../../../../.storybook/preview';
import { Checkbox } from './Checkbox';
import { Flex } from '../Flex';
+import { Link } from '../Link';
+import { MemoryRouter } from 'react-router-dom';
const meta = preview.meta({
title: 'Backstage UI/Checkbox',
@@ -28,6 +30,12 @@ export const Default = meta.story({
},
});
+export const Selected = Default.extend({
+ args: {
+ isSelected: true,
+ },
+});
+
export const Indeterminate = meta.story({
args: {
children: 'Select all',
@@ -35,6 +43,25 @@ export const Indeterminate = meta.story({
},
});
+export const WithLongText = Default.extend({
+ args: {
+ children: (
+ <>
+ I agree to receive future communication from Spotify. You may
+ unsubscribe from these communications at any time. Please review our{' '}
+ Privacy Policy
+ >
+ ),
+ },
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+});
+
export const AllVariants = meta.story({
...Default.input,
render: () => (
diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx
index a825ccfb8a..d7b0bd6f76 100644
--- a/packages/ui/src/components/Checkbox/Checkbox.tsx
+++ b/packages/ui/src/components/Checkbox/Checkbox.tsx
@@ -46,7 +46,7 @@ export const Checkbox = forwardRef(
)}
- {children}
+
{children}
>
)}
diff --git a/packages/ui/src/components/Checkbox/definition.ts b/packages/ui/src/components/Checkbox/definition.ts
index 5aa2473eac..b6666924ee 100644
--- a/packages/ui/src/components/Checkbox/definition.ts
+++ b/packages/ui/src/components/Checkbox/definition.ts
@@ -29,8 +29,6 @@ export const CheckboxDefinition = defineComponent()({
indicator: 'bui-CheckboxIndicator',
},
propDefs: {
- selected: { dataAttribute: true },
- indeterminate: { dataAttribute: true },
children: {},
className: {},
},
diff --git a/packages/ui/src/components/Checkbox/types.ts b/packages/ui/src/components/Checkbox/types.ts
index 8507df1e5b..d9b220eb2a 100644
--- a/packages/ui/src/components/Checkbox/types.ts
+++ b/packages/ui/src/components/Checkbox/types.ts
@@ -17,8 +17,6 @@ import type { CheckboxProps as RACheckboxProps } from 'react-aria-components';
/** @public */
export type CheckboxOwnProps = {
- selected?: boolean;
- indeterminate?: boolean;
children: React.ReactNode;
className?: string;
};
diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css
index b93665525f..1ebf52ccf3 100644
--- a/packages/ui/src/components/Container/Container.module.css
+++ b/packages/ui/src/components/Container/Container.module.css
@@ -21,7 +21,7 @@
max-width: 120rem;
padding-inline: var(--bui-space-4);
margin-inline: auto;
- padding-bottom: var(--bui-space-8);
+ margin-bottom: var(--bui-space-8);
}
@media (min-width: 640px) {
diff --git a/packages/ui/src/components/Dialog/Dialog.module.css b/packages/ui/src/components/Dialog/Dialog.module.css
index da82509134..194128ec4d 100644
--- a/packages/ui/src/components/Dialog/Dialog.module.css
+++ b/packages/ui/src/components/Dialog/Dialog.module.css
@@ -50,9 +50,11 @@
border: 1px solid var(--bui-border-1);
color: var(--bui-fg-primary);
position: relative;
+ display: flex;
+ flex-direction: column;
width: min(var(--bui-dialog-min-width, 400px), calc(100vw - 3rem));
max-width: calc(100vw - 3rem);
- height: min(var(--bui-dialog-min-height, auto), calc(100vh - 3rem));
+ height: var(--bui-dialog-height, auto);
max-height: calc(100vh - 3rem);
outline: none;
}
@@ -61,7 +63,9 @@
display: flex;
flex-direction: column;
border-radius: var(--dialog-border-radius);
- height: 100%;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
}
/* Dialog entering animation */
@@ -102,6 +106,7 @@
.bui-DialogBody {
padding: var(--bui-space-3);
flex: 1;
+ min-height: 0;
overflow-y: auto;
}
diff --git a/packages/ui/src/components/Dialog/Dialog.stories.tsx b/packages/ui/src/components/Dialog/Dialog.stories.tsx
index 9084bfa7e4..8892cb2314 100644
--- a/packages/ui/src/components/Dialog/Dialog.stories.tsx
+++ b/packages/ui/src/components/Dialog/Dialog.stories.tsx
@@ -247,6 +247,39 @@ export const WithForm = meta.story({
),
});
+export const OverflowWithoutHeight = meta.story({
+ args: {
+ defaultOpen: true,
+ },
+ render: args => (
+
+
+
+
+ ),
+});
+
export const PreviewFixedWidthAndHeight = FixedWidth.extend({
args: {
defaultOpen: undefined,
diff --git a/packages/ui/src/components/Dialog/Dialog.tsx b/packages/ui/src/components/Dialog/Dialog.tsx
index fad5e10ea2..3a23ee27c9 100644
--- a/packages/ui/src/components/Dialog/Dialog.tsx
+++ b/packages/ui/src/components/Dialog/Dialog.tsx
@@ -67,11 +67,12 @@ export const Dialog = forwardRef, DialogProps>(
style={{
['--bui-dialog-min-width' as keyof React.CSSProperties]:
typeof width === 'number' ? `${width}px` : width || '400px',
- ['--bui-dialog-min-height' as keyof React.CSSProperties]: height
- ? typeof height === 'number'
- ? `${height}px`
- : height
- : 'auto',
+ ...(height
+ ? {
+ ['--bui-dialog-height' as keyof React.CSSProperties]:
+ typeof height === 'number' ? `${height}px` : height,
+ }
+ : {}),
...style,
}}
>
diff --git a/packages/ui/src/components/FullPage/FullPage.stories.tsx b/packages/ui/src/components/FullPage/FullPage.stories.tsx
index 05169ca8ea..6adfaa7efb 100644
--- a/packages/ui/src/components/FullPage/FullPage.stories.tsx
+++ b/packages/ui/src/components/FullPage/FullPage.stories.tsx
@@ -22,6 +22,7 @@ import { Container } from '../Container';
import { Text } from '../Text';
import type { HeaderTab } from '../PluginHeader/types';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/FullPage',
@@ -33,7 +34,9 @@ const meta = preview.meta({
const withRouter = (Story: StoryFn) => (
-
+
+
+
);
diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css
index 4a81c6dcb0..d74d3eeb50 100644
--- a/packages/ui/src/components/Header/Header.module.css
+++ b/packages/ui/src/components/Header/Header.module.css
@@ -17,32 +17,31 @@
@layer tokens, base, components, utilities;
@layer components {
- .bui-HeaderPage {
+ .bui-Header {
display: flex;
flex-direction: column;
gap: var(--bui-space-1);
margin-top: var(--bui-space-6);
- margin-bottom: var(--bui-space-6);
}
- .bui-HeaderPageContent {
+ .bui-HeaderContent {
display: flex;
flex-direction: row;
justify-content: space-between;
}
- .bui-HeaderPageTabsWrapper {
+ .bui-HeaderTabsWrapper {
margin-left: -8px;
}
- .bui-HeaderPageControls {
+ .bui-HeaderControls {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
}
- .bui-HeaderPageBreadcrumbs {
+ .bui-HeaderBreadcrumbs {
display: flex;
flex-direction: row;
align-items: center;
diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx
index e605f2ed58..0adfb43d55 100644
--- a/packages/ui/src/components/Header/Header.stories.tsx
+++ b/packages/ui/src/components/Header/Header.stories.tsx
@@ -19,6 +19,7 @@ import type { StoryFn } from '@storybook/react-vite';
import { Header } from './Header';
import type { HeaderTab } from '../PluginHeader/types';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
import {
Button,
Container,
@@ -88,7 +89,9 @@ const menuItems = [
const withRouter = (Story: StoryFn) => (
-
+
+
+
);
@@ -239,32 +242,34 @@ export const WithTabsMatchingStrategies = meta.story({
},
render: args => (
-
-
-
- Current URL: /mentorship/events
-
-
-
- Notice how the "Mentorship" tab is active even though we're on a
- nested route. This is because it uses{' '}
- matchStrategy="prefix".
-
-
-
- • Home: exact matching (default) - not active
-
-
- • Mentorship: prefix matching - IS active (URL starts
- with /mentorship)
-
-
- • Catalog: prefix matching - not active
-
-
- • Settings: exact matching (default) - not active
-
-
+
+
+
+
+ Current URL: /mentorship/events
+
+
+
+ Notice how the "Mentorship" tab is active even though we're on a
+ nested route. This is because it uses{' '}
+ matchStrategy="prefix".
+
+
+
+ • Home: exact matching (default) - not active
+
+
+ • Mentorship: prefix matching - IS active (URL
+ starts with /mentorship)
+
+
+ • Catalog: prefix matching - not active
+
+
+ • Settings: exact matching (default) - not active
+
+
+
),
});
@@ -292,18 +297,20 @@ export const WithTabsExactMatching = meta.story({
},
render: args => (
-
-
-
- Current URL: /mentorship/events
-
-
-
- With default exact matching, only the "Events" tab is active because
- it exactly matches the current URL. The "Mentorship" tab is not active
- even though the URL is under /mentorship.
-
-
+
+
+
+
+ Current URL: /mentorship/events
+
+
+
+ With default exact matching, only the "Events" tab is active because
+ it exactly matches the current URL. The "Mentorship" tab is not
+ active even though the URL is under /mentorship.
+
+
+
),
});
@@ -334,33 +341,36 @@ export const WithTabsPrefixMatchingDeep = meta.story({
},
render: args => (
-
-
-
- Current URL: /catalog/users/john/details
-
-
-
- Active tab is Users because:
-
-
-
- Catalog: Matches since URL starts with /catalog
-
-
- Users: Is active since URL starts with
- /catalog/users, and is more specific (has more url segments) than
- "Catalog"
-
-
- Components: not active (URL doesn't start with
- /catalog/components)
-
-
-
- This demonstrates how prefix matching works with deeply nested routes.
-
-
+
+
+
+
+ Current URL: /catalog/users/john/details
+
+
+
+ Active tab is Users because:
+
+
+
+ Catalog: Matches since URL starts with /catalog
+
+
+ Users: Is active since URL starts with
+ /catalog/users, and is more specific (has more url segments) than
+ "Catalog"
+
+
+ Components: not active (URL doesn't start with
+ /catalog/components)
+
+
+
+ This demonstrates how prefix matching works with deeply nested
+ routes.
+
+
+
),
});
diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts
index 0eb3ad33a3..66fccf2f49 100644
--- a/packages/ui/src/components/Header/definition.ts
+++ b/packages/ui/src/components/Header/definition.ts
@@ -25,11 +25,11 @@ import styles from './Header.module.css';
export const HeaderDefinition = defineComponent()({
styles,
classNames: {
- root: 'bui-HeaderPage',
- content: 'bui-HeaderPageContent',
- breadcrumbs: 'bui-HeaderPageBreadcrumbs',
- tabsWrapper: 'bui-HeaderPageTabsWrapper',
- controls: 'bui-HeaderPageControls',
+ root: 'bui-Header',
+ content: 'bui-HeaderContent',
+ breadcrumbs: 'bui-HeaderBreadcrumbs',
+ tabsWrapper: 'bui-HeaderTabsWrapper',
+ controls: 'bui-HeaderControls',
},
propDefs: {
title: {},
diff --git a/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx b/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx
deleted file mode 100644
index 3c8a2ac456..0000000000
--- a/packages/ui/src/components/InternalLinkProvider/InternalLinkProvider.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-/*
- * Copyright 2025 The Backstage Authors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {
- ReactNode,
- createContext,
- useCallback,
- useContext,
- useEffect,
- useState,
-} from 'react';
-import { RouterProvider } from 'react-aria-components';
-import { useNavigate, useHref } from 'react-router-dom';
-import { isExternalLink } from '../../utils/isExternalLink';
-
-/**
- * Checks if an href is an internal link (not external and not empty).
- *
- * @internal
- */
-export function isInternalLink(href: string | undefined): href is string {
- return !!href && !isExternalLink(href);
-}
-
-/**
- * Context value type for routing registration.
- * Used by container components to track children that need RouterProvider.
- *
- * @internal
- */
-export type RoutingContextValue = {
- register: () => () => void;
-};
-
-/**
- * Wraps children in a RouterProvider for client-side navigation.
- * Must be rendered within a React Router context.
- *
- * @internal
- */
-export function RoutedContainer({ children }: { children: ReactNode }) {
- const navigate = useNavigate();
- return (
-
- {children}
-
- );
-}
-
-/**
- * Hook for container components that need to conditionally provide routing.
- *
- * Usage:
- * 1. Call this hook in the container component
- * 2. Pass `contextValue` to a RoutingContextValue context provider
- * 3. Children call `register()` via context when they have internal hrefs
- * 4. If `hasRoutedChildren` is true, wrap content in RoutedContainer
- *
- * @internal
- */
-export function useRoutingRegistration(): {
- hasRoutedChildren: boolean;
- contextValue: RoutingContextValue;
-} {
- const [count, setCount] = useState(0);
-
- const register = useCallback(() => {
- setCount(c => c + 1);
- return () => setCount(c => c - 1);
- }, []);
-
- return { hasRoutedChildren: count > 0, contextValue: { register } };
-}
-
-/**
- * Creates a routing registration context and provider for container components.
- *
- * Usage:
- * ```tsx
- * // At module level
- * const { RoutingProvider, useRoutingRegistrationEffect } = createRoutingRegistration();
- *
- * // Container component wraps content with provider
- * {content}
- *
- * // Child items register when they have internal hrefs
- * useRoutingRegistrationEffect(href);
- * ```
- *
- * @internal
- */
-export function createRoutingRegistration() {
- const RoutingContext = createContext(null);
-
- function RoutingProvider({ children }: { children: ReactNode }) {
- const { hasRoutedChildren, contextValue } = useRoutingRegistration();
-
- const content = (
-
- {children}
-
- );
-
- if (hasRoutedChildren) {
- return {content};
- }
-
- return content;
- }
-
- function useRoutingRegistrationEffect(href: string | undefined) {
- const routingCtx = useContext(RoutingContext);
- const hasInternalHref = isInternalLink(href);
-
- useEffect(() => {
- if (hasInternalHref && routingCtx) {
- return routingCtx.register();
- }
- return undefined;
- }, [hasInternalHref, routingCtx]);
- }
-
- return { RoutingContext, RoutingProvider, useRoutingRegistrationEffect };
-}
-
-/**
- * Conditionally wraps children in a RouterProvider for internal link navigation.
- * Only mounts the router hooks when `href` is an internal link, avoiding the
- * requirement for a Router context when rendering components without internal hrefs.
- *
- * @internal
- */
-export function InternalLinkProvider({
- href,
- children,
-}: {
- href: string | undefined;
- children: ReactNode;
-}) {
- if (!isInternalLink(href)) {
- return <>{children}>;
- }
- return {children};
-}
diff --git a/packages/ui/src/components/Link/Link.stories.tsx b/packages/ui/src/components/Link/Link.stories.tsx
index fafb5948d9..cf52cdde23 100644
--- a/packages/ui/src/components/Link/Link.stories.tsx
+++ b/packages/ui/src/components/Link/Link.stories.tsx
@@ -20,6 +20,7 @@ import { Link } from './Link';
import { Flex } from '../Flex';
import { Text } from '../Text';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/Link',
@@ -30,7 +31,9 @@ const meta = preview.meta({
decorators: [
(Story: StoryFn) => (
-
+
+
+
),
],
diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx
index c0de9c3087..a4509576c9 100644
--- a/packages/ui/src/components/Link/Link.tsx
+++ b/packages/ui/src/components/Link/Link.tsx
@@ -19,7 +19,6 @@ import { useLink } from 'react-aria';
import type { LinkProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { LinkDefinition } from './definition';
-import { InternalLinkProvider } from '../InternalLinkProvider';
import { getNodeText } from '../../analytics/getNodeText';
const LinkInternal = forwardRef((props, ref) => {
@@ -64,11 +63,7 @@ LinkInternal.displayName = 'LinkInternal';
/** @public */
export const Link = forwardRef((props, ref) => {
- return (
-
-
-
- );
+ return ;
});
Link.displayName = 'Link';
diff --git a/packages/ui/src/components/Menu/Menu.stories.tsx b/packages/ui/src/components/Menu/Menu.stories.tsx
index 982055ade1..26164f3752 100644
--- a/packages/ui/src/components/Menu/Menu.stories.tsx
+++ b/packages/ui/src/components/Menu/Menu.stories.tsx
@@ -36,6 +36,7 @@ import {
RiShareBoxLine,
} from '@remixicon/react';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
import { useEffect, useState } from 'react';
const meta = preview.meta({
@@ -44,7 +45,9 @@ const meta = preview.meta({
decorators: [
Story => (
-
+
+
+
),
],
diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx
index 5ab55cf7d8..cb6028c45a 100644
--- a/packages/ui/src/components/Menu/Menu.tsx
+++ b/packages/ui/src/components/Menu/Menu.tsx
@@ -62,17 +62,11 @@ import {
RiCheckLine,
RiCloseCircleLine,
} from '@remixicon/react';
-import {
- isInternalLink,
- createRoutingRegistration,
-} from '../InternalLinkProvider';
+import { isInternalLink } from '../../utils/linkUtils';
import { getNodeText } from '../../analytics/getNodeText';
import { Box } from '../Box';
import { BgReset } from '../../hooks/useBg';
-const { RoutingProvider, useRoutingRegistrationEffect } =
- createRoutingRegistration();
-
// The height will be used for virtualized menus. It should match the size set in CSS for each menu item.
const rowHeight = 32;
@@ -110,26 +104,24 @@ export const Menu = (props: MenuProps
+
+
);
};
@@ -318,8 +308,6 @@ export const MenuItem = (props: MenuItemProps) => {
);
const { classes, iconStart, children, href } = ownProps;
- useRoutingRegistrationEffect(href);
-
const handleAction = () => {
if (href) {
const text =
diff --git a/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx
index af68ec9415..7ebd25a063 100644
--- a/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx
+++ b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx
@@ -25,6 +25,7 @@ import {
import { Button } from '../..';
import { useState, useEffect } from 'react';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/MenuAutocomplete',
@@ -32,7 +33,9 @@ const meta = preview.meta({
decorators: [
Story => (
-
+
+
+
),
],
diff --git a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx
index e1c22fbd9e..f3e001c165 100644
--- a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx
+++ b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx
@@ -27,6 +27,7 @@ import { Button, Flex, Text } from '../..';
import { useEffect, useState } from 'react';
import { Selection } from 'react-aria-components';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/MenuAutocompleteListBox',
@@ -34,7 +35,9 @@ const meta = preview.meta({
decorators: [
Story => (
-
+
+
+
),
],
diff --git a/packages/ui/src/components/Menu/MenuListBox.stories.tsx b/packages/ui/src/components/Menu/MenuListBox.stories.tsx
index 2297598d0c..469a106280 100644
--- a/packages/ui/src/components/Menu/MenuListBox.stories.tsx
+++ b/packages/ui/src/components/Menu/MenuListBox.stories.tsx
@@ -20,6 +20,7 @@ import { Button, Flex, Text } from '../..';
import { useEffect, useState } from 'react';
import { Selection } from 'react-aria-components';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
const meta = preview.meta({
title: 'Backstage UI/MenuListBox',
@@ -27,7 +28,9 @@ const meta = preview.meta({
decorators: [
Story => (
-
+
+
+
),
],
diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx b/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx
index 151c48aab6..d0d3475dbb 100644
--- a/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx
+++ b/packages/ui/src/components/PluginHeader/PluginHeader.stories.tsx
@@ -29,6 +29,7 @@ import {
MenuItem,
} from '../../';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
import {
RiHeartLine,
RiEmotionHappyLine,
@@ -47,7 +48,9 @@ const meta = preview.meta({
const withRouter = (Story: StoryFn) => (
-
+
+
+
);
@@ -336,16 +339,18 @@ export const WithMockedURLCampaigns = meta.story({
},
render: args => (
-
-
-
- Current URL is mocked to be: /campaigns
-
-
- Notice how the "Campaigns" tab is selected (highlighted) because it
- matches the current path.
-
-
+
+
+
+
+ Current URL is mocked to be: /campaigns
+
+
+ Notice how the "Campaigns" tab is selected (highlighted) because it
+ matches the current path.
+
+
+
),
});
@@ -356,16 +361,18 @@ export const WithMockedURLIntegrations = meta.story({
},
render: args => (
-
-
-
- Current URL is mocked to be: /integrations
-
-
- Notice how the "Integrations" tab is selected (highlighted) because it
- matches the current path.
-
-
+
+
+
+
+ Current URL is mocked to be: /integrations
+
+
+ Notice how the "Integrations" tab is selected (highlighted) because
+ it matches the current path.
+
+
+
),
});
@@ -376,20 +383,22 @@ export const WithMockedURLNoMatch = meta.story({
},
render: args => (
-
-
-
- Current URL is mocked to be: /some-other-page
-
-
- No tab is selected because the current path doesn't match any tab's
- href.
-
-
- Tabs without href (like "Overview", "Checks", "Tracks") fall back to
- React Aria's internal state.
-
-
+
+
+
+
+ Current URL is mocked to be: /some-other-page
+
+
+ No tab is selected because the current path doesn't match any tab's
+ href.
+
+
+ Tabs without href (like "Overview", "Checks", "Tracks") fall back to
+ React Aria's internal state.
+
+
+
),
});
@@ -424,32 +433,34 @@ export const WithTabsMatchingStrategies = meta.story({
},
render: args => (
-
-
-
- Current URL: /mentorship/events
-
-
-
- Notice how the "Mentorship" tab is active even though we're on a
- nested route. This is because it uses{' '}
- matchStrategy="prefix".
-
-
-
- • Home: exact matching (default) - not active
-
-
- • Mentorship: prefix matching - IS active (URL starts
- with /mentorship)
-
-
- • Catalog: prefix matching - not active
-
-
- • Settings: exact matching (default) - not active
-
-
+
+
+
+
+ Current URL: /mentorship/events
+
+
+
+ Notice how the "Mentorship" tab is active even though we're on a
+ nested route. This is because it uses{' '}
+ matchStrategy="prefix".
+
+
+
+ • Home: exact matching (default) - not active
+
+
+ • Mentorship: prefix matching - IS active (URL
+ starts with /mentorship)
+
+
+ • Catalog: prefix matching - not active
+
+
+ • Settings: exact matching (default) - not active
+
+
+
),
});
@@ -477,18 +488,20 @@ export const WithTabsExactMatching = meta.story({
},
render: args => (
-
-
-
- Current URL: /mentorship/events
-
-
-
- With default exact matching, only the "Events" tab is active because
- it exactly matches the current URL. The "Mentorship" tab is not active
- even though the URL is under /mentorship.
-
-
+
+
+
+
+ Current URL: /mentorship/events
+
+
+
+ With default exact matching, only the "Events" tab is active because
+ it exactly matches the current URL. The "Mentorship" tab is not
+ active even though the URL is under /mentorship.
+
+
+
),
});
@@ -519,33 +532,36 @@ export const WithTabsPrefixMatchingDeep = meta.story({
},
render: args => (
-
-
-
- Current URL: /catalog/users/john/details
-
-
-
- Active tab is Users because:
-
-
-
- Catalog: Matches since URL starts with /catalog
-
-
- Users: Is active since URL starts with
- /catalog/users, and is more specific (has more url segments) than
- "Catalog"
-
-
- Components: not active (URL doesn't start with
- /catalog/components)
-
-
-
- This demonstrates how prefix matching works with deeply nested routes.
-
-
+
+
+
+
+ Current URL: /catalog/users/john/details
+
+
+
+ Active tab is Users because:
+
+
+
+ Catalog: Matches since URL starts with /catalog
+
+
+ Users: Is active since URL starts with
+ /catalog/users, and is more specific (has more url segments) than
+ "Catalog"
+
+
+ Components: not active (URL doesn't start with
+ /catalog/components)
+
+
+
+ This demonstrates how prefix matching works with deeply nested
+ routes.
+
+
+
),
});
diff --git a/packages/ui/src/components/PluginHeader/PluginHeader.tsx b/packages/ui/src/components/PluginHeader/PluginHeader.tsx
index ac5f04a011..d153a3af18 100644
--- a/packages/ui/src/components/PluginHeader/PluginHeader.tsx
+++ b/packages/ui/src/components/PluginHeader/PluginHeader.tsx
@@ -19,7 +19,7 @@ import { Tabs, TabList, Tab } from '../Tabs';
import { useDefinition } from '../../hooks/useDefinition';
import { PluginHeaderDefinition } from './definition';
import { type NavigateOptions } from 'react-router-dom';
-import { useRef } from 'react';
+import { Children, useMemo, useRef } from 'react';
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
import { Box } from '../Box';
import { Link } from 'react-aria-components';
@@ -55,35 +55,70 @@ export const PluginHeader = (props: PluginHeaderProps) => {
const toolbarWrapperRef = useRef(null);
const toolbarContentRef = useRef(null);
const toolbarControlsRef = useRef(null);
+ const animationFrameRef = useRef(undefined);
+ const lastAppliedHeightRef = useRef(undefined);
+
+ const actionChildren = useMemo(() => {
+ return Children.toArray(customActions);
+ }, [customActions]);
useIsomorphicLayoutEffect(() => {
const el = headerRef.current;
- if (!el) return undefined;
+ if (!el) {
+ return undefined;
+ }
- const updateHeight = () => {
- const height = el.offsetHeight;
+ const cancelScheduledUpdate = () => {
+ if (animationFrameRef.current === undefined) {
+ return;
+ }
+
+ cancelAnimationFrame(animationFrameRef.current);
+ animationFrameRef.current = undefined;
+ };
+
+ const applyHeight = (height: number) => {
+ if (lastAppliedHeightRef.current === height) {
+ return;
+ }
+
+ lastAppliedHeightRef.current = height;
document.documentElement.style.setProperty(
'--bui-header-height',
`${height}px`,
);
};
- // Set height once immediately
- updateHeight();
+ const scheduleHeightUpdate = () => {
+ cancelScheduledUpdate();
+ animationFrameRef.current = requestAnimationFrame(() => {
+ animationFrameRef.current = undefined;
+ applyHeight(el.offsetHeight);
+ });
+ };
+
+ // Set height once immediately so the initial layout is correct.
+ applyHeight(el.offsetHeight);
// Observe for resize changes if ResizeObserver is available
// (not present in Jest/jsdom by default)
if (typeof ResizeObserver === 'undefined') {
return () => {
+ cancelScheduledUpdate();
+ lastAppliedHeightRef.current = undefined;
document.documentElement.style.removeProperty('--bui-header-height');
};
}
- const observer = new ResizeObserver(updateHeight);
+ const observer = new ResizeObserver(() => {
+ scheduleHeightUpdate();
+ });
observer.observe(el);
return () => {
observer.disconnect();
+ cancelScheduledUpdate();
+ lastAppliedHeightRef.current = undefined;
document.documentElement.style.removeProperty('--bui-header-height');
};
}, []);
@@ -111,7 +146,7 @@ export const PluginHeader = (props: PluginHeaderProps) => {
+
+ {/* isNonModal keeps the page interactive while the popover is open,
+ required for virtual focus (aria-activedescendant) to work correctly */}
+
+
+
+
+ isLoading ? (
+
+ );
+ },
+};
+
// Type filter interface for ComprehensiveServerSide story
interface TypeFilter {
type: string | null;
diff --git a/packages/ui/src/components/Table/stories/utils.tsx b/packages/ui/src/components/Table/stories/utils.tsx
index 45bc6341ad..130469e5d7 100644
--- a/packages/ui/src/components/Table/stories/utils.tsx
+++ b/packages/ui/src/components/Table/stories/utils.tsx
@@ -17,6 +17,7 @@
import type { Meta } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../../provider';
import { CellText, type ColumnConfig } from '..';
// Selection demo data
@@ -47,7 +48,9 @@ export const tableStoriesMeta = {
decorators: [
(Story: () => JSX.Element) => (
-
+
+
+
),
],
diff --git a/packages/ui/src/components/Table/types.ts b/packages/ui/src/components/Table/types.ts
index 099a855d33..3cfbdff34e 100644
--- a/packages/ui/src/components/Table/types.ts
+++ b/packages/ui/src/components/Table/types.ts
@@ -42,6 +42,7 @@ export interface SortState {
/** @public */
export type TableRootOwnProps = {
stale?: boolean;
+ loading?: boolean;
};
/** @public */
@@ -92,17 +93,35 @@ export interface ColumnProps
extends ColumnOwnProps,
Omit {}
-/** @public */
+/**
+ * Own props for the {@link Cell} component.
+ *
+ * @public
+ */
export type CellOwnProps = {
className?: string;
};
-/** @public */
+/**
+ * Props for the {@link Cell} component.
+ *
+ * `Cell` is a generic cell wrapper for custom cell content. When rendering
+ * cells via {@link ColumnConfig.cell} or a custom {@link RowRenderFn}, the
+ * returned element **must** be a cell component (`Cell`, `CellText`, or
+ * `CellProfile`) at the top level. Returning bare text or other elements
+ * without a cell wrapper will break the table layout.
+ *
+ * @public
+ */
export interface CellProps
extends CellOwnProps,
Omit {}
-/** @public */
+/**
+ * Own props for the {@link CellText} component.
+ *
+ * @public
+ */
export type CellTextOwnProps = {
title: string;
description?: string;
@@ -112,12 +131,25 @@ export type CellTextOwnProps = {
className?: string;
};
-/** @public */
+/**
+ * Props for the {@link CellText} component.
+ *
+ * `CellText` renders a table cell with a title and optional description. It
+ * is one of the cell components (`Cell`, `CellText`, `CellProfile`) that
+ * **must** be used as the top-level element returned from
+ * {@link ColumnConfig.cell} or a custom {@link RowRenderFn}.
+ *
+ * @public
+ */
export interface CellTextProps
extends CellTextOwnProps,
Omit {}
-/** @public */
+/**
+ * Own props for the {@link CellProfile} component.
+ *
+ * @public
+ */
export type CellProfileOwnProps = {
src?: string;
name?: string;
@@ -127,7 +159,16 @@ export type CellProfileOwnProps = {
className?: string;
};
-/** @public */
+/**
+ * Props for the {@link CellProfile} component.
+ *
+ * `CellProfile` renders a table cell with an avatar, name, and optional
+ * description. It is one of the cell components (`Cell`, `CellText`,
+ * `CellProfile`) that **must** be used as the top-level element returned
+ * from {@link ColumnConfig.cell} or a custom {@link RowRenderFn}.
+ *
+ * @public
+ */
export interface CellProfileProps
extends CellProfileOwnProps,
Omit {}
@@ -150,10 +191,27 @@ export interface PagePagination extends TablePaginationProps {
/** @public */
export type TablePaginationType = NoPagination | PagePagination;
-/** @public */
+/**
+ * Configuration for a single table column.
+ *
+ * @public
+ */
export interface ColumnConfig {
id: string;
label: string;
+ /**
+ * Renders the cell content for this column.
+ *
+ * **Important:** The returned element **must** be a cell component at the
+ * top level — either `Cell`, `CellText`, or `CellProfile`. Returning bare
+ * text, fragments, or other elements without a cell wrapper will break the
+ * table layout.
+ *
+ * @example
+ * ```tsx
+ * cell: item =>
+ * ```
+ */
cell: (item: T) => ReactElement;
header?: () => ReactElement;
isSortable?: boolean;
@@ -172,7 +230,16 @@ export interface RowConfig {
getIsDisabled?: (item: T) => boolean;
}
-/** @public */
+/**
+ * Custom render function for table rows.
+ *
+ * When using a custom row render function, each cell rendered inside the row
+ * **must** use a cell component (`Cell`, `CellText`, or `CellProfile`) as
+ * the top-level element. Returning bare text or other elements without a
+ * cell wrapper will break the table layout.
+ *
+ * @public
+ */
export type RowRenderFn = (params: {
item: T;
index: number;
@@ -186,6 +253,12 @@ export interface TableSelection {
onSelectionChange?: ReactAriaTableProps['onSelectionChange'];
}
+/** @public */
+export type VirtualizedProp =
+ | boolean
+ | { rowHeight: number }
+ | { estimatedRowHeight: number };
+
/** @public */
export interface TableProps {
columnConfig: readonly ColumnConfig[];
@@ -200,4 +273,5 @@ export interface TableProps {
emptyState?: ReactNode;
className?: string;
style?: React.CSSProperties;
+ virtualized?: VirtualizedProp;
}
diff --git a/packages/ui/src/components/Tabs/Tabs.stories.tsx b/packages/ui/src/components/Tabs/Tabs.stories.tsx
index 55cdf7c446..99bf75a072 100644
--- a/packages/ui/src/components/Tabs/Tabs.stories.tsx
+++ b/packages/ui/src/components/Tabs/Tabs.stories.tsx
@@ -18,6 +18,7 @@ import preview from '../../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { Tabs, TabList, Tab, TabPanel } from './Tabs';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
import { Box } from '../Box';
import { Text } from '../Text';
@@ -28,7 +29,9 @@ const meta = preview.meta({
const withRouter = (Story: StoryFn) => (
-
+
+
+
);
@@ -79,28 +82,30 @@ export const WithMockedURLTab2 = meta.story({
},
render: () => (
-
-
-
- Tab 1
-
-
- Tab 2
-
-
- Tab 3 With long title
-
-
-
-
-
- Current URL is mocked to be: /tab2
-
-
- Notice how the "Tab 2" tab is selected (highlighted) because it
- matches the current path.
-
-
+
+
+
+
+ Tab 1
+
+
+ Tab 2
+
+
+ Tab 3 With long title
+
+
+
+
+
+ Current URL is mocked to be: /tab2
+
+
+ Notice how the "Tab 2" tab is selected (highlighted) because it
+ matches the current path.
+
+
+
),
});
@@ -111,28 +116,30 @@ export const WithMockedURLTab3 = meta.story({
},
render: () => (
-
-
-
- Tab 1
-
-
- Tab 2
-
-
- Tab 3 With long title
-
-
-
-
-
- Current URL is mocked to be: /tab3
-
-
- Notice how the "Tab 3 With long title" tab is selected (highlighted)
- because it matches the current path.
-
-
+
+
+
+
+ Tab 1
+
+
+ Tab 2
+
+
+ Tab 3 With long title
+
+
+
+
+
+ Current URL is mocked to be: /tab3
+
+
+ Notice how the "Tab 3 With long title" tab is selected (highlighted)
+ because it matches the current path.
+
+
+
),
});
@@ -143,32 +150,34 @@ export const WithMockedURLNoMatch = meta.story({
},
render: () => (
-
-
-
- Tab 1
-
-
- Tab 2
-
-
- Tab 3 With long title
-
-
-
-
-
- Current URL is mocked to be: /some-other-page
-
-
- No tab is selected because the current path doesn't match any tab's
- href.
-
-
- Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title")
- fall back to React Aria's internal state.
-
-
+
+
+
+
+ Tab 1
+
+
+ Tab 2
+
+
+ Tab 3 With long title
+
+
+
+
+
+ Current URL is mocked to be: /some-other-page
+
+
+ No tab is selected because the current path doesn't match any tab's
+ href.
+
+
+ Tabs without href (like "Tab 1", "Tab 2", "Tab 3 With long title")
+ fall back to React Aria's internal state.
+
+
+
),
});
@@ -181,32 +190,34 @@ export const ExactMatchingDefault = meta.story({
},
render: () => (
-
-
-
- Mentorship
-
-
- Events
-
-
- Catalog
-
-
-
-
-
- Current URL: /mentorship/events
-
-
- Using default exact matching, only the "Events" tab is active because
- it exactly matches the URL.
-
-
- The "Mentorship" tab is NOT active even though the URL contains
- "/mentorship".
-
-
+
+
+
+
+ Mentorship
+
+
+ Events
+
+
+ Catalog
+
+
+
+
+
+ Current URL: /mentorship/events
+
+
+ Using default exact matching, only the "Events" tab is active
+ because it exactly matches the URL.
+
+
+ The "Mentorship" tab is NOT active even though the URL contains
+ "/mentorship".
+
+
+
),
});
@@ -217,36 +228,38 @@ export const PrefixMatchingForNestedRoutes = meta.story({
},
render: () => (
-
-
-
- Mentorship
-
-
- Events
-
-
- Catalog
-
-
-
-
-
- Current URL: /mentorship/events
-
-
- The "Mentorship" tab uses prefix matching and IS active because
- "/mentorship/events" starts with "/mentorship".
-
-
- The "Events" tab uses exact matching and is also active because it
- exactly matches.
-
-
- The "Catalog" tab uses prefix matching but is NOT active because the
- URL doesn't start with "/catalog".
-
-
+
+
+
+
+ Mentorship
+
+
+ Events
+
+
+ Catalog
+
+
+
+
+
+ Current URL: /mentorship/events
+
+
+ The "Mentorship" tab uses prefix matching and IS active because
+ "/mentorship/events" starts with "/mentorship".
+
+
+ The "Events" tab uses exact matching and is also active because it
+ exactly matches.
+
+
+ The "Catalog" tab uses prefix matching but is NOT active because the
+ URL doesn't start with "/catalog".
+
+
+
),
});
@@ -257,31 +270,33 @@ export const PrefixMatchingDeepNesting = meta.story({
},
render: () => (
-
-
-
- Home
-
-
- Catalog
-
-
- Mentorship
-
-
-
-
-
- Current URL: /catalog/users/john/details
-
-
- The "Catalog" tab is active because it uses prefix matching and the
- URL starts with "/catalog".
-
-
- This works for any level of nesting under "/catalog".
-
-
+
+
+
+
+ Home
+
+
+ Catalog
+
+
+ Mentorship
+
+
+
+
+
+ Current URL: /catalog/users/john/details
+
+
+ The "Catalog" tab is active because it uses prefix matching and the
+ URL starts with "/catalog".
+
+
+ This works for any level of nesting under "/catalog".
+
+
+
),
});
@@ -292,47 +307,53 @@ export const MixedMatchingStrategies = meta.story({
},
render: () => (
-
-
-
- Overview
-
-
- Analytics
-
-
- Settings
-
-
- Help
-
-
-
-
-
- Current URL: /dashboard/analytics/reports
-
-
- • "Overview" tab: exact matching, NOT active (doesn't exactly match
- "/dashboard")
-
-
- • "Analytics" tab: prefix matching, IS active (URL starts with
- "/dashboard/analytics")
-
-
- • "Settings" tab: prefix matching, NOT active (URL doesn't start with
- "/dashboard/settings")
-
-
- • "Help" tab: exact matching, NOT active (doesn't exactly match
- "/help")
-
-
+
+
+
+
+ Overview
+
+
+ Analytics
+
+
+ Settings
+
+
+ Help
+
+
+
+
+
+ Current URL: /dashboard/analytics/reports
+
+
+ • "Overview" tab: exact matching, NOT active (doesn't exactly match
+ "/dashboard")
+
+
+ • "Analytics" tab: prefix matching, IS active (URL starts with
+ "/dashboard/analytics")
+
+
+ • "Settings" tab: prefix matching, NOT active (URL doesn't start
+ with "/dashboard/settings")
+
+
+ • "Help" tab: exact matching, NOT active (doesn't exactly match
+ "/help")
+
+
+
),
});
@@ -343,38 +364,40 @@ export const PrefixMatchingEdgeCases = meta.story({
},
render: () => (
-
-
-
- Foo
-
-
- Foobar
-
-
- Foo (exact)
-
-
-
-
-
- Current URL: /foobar
-
-
- • "Foo" tab (prefix): NOT active - prevents "/foo" from matching
- "/foobar"
-
-
- • "Foobar" tab (exact): IS active - exactly matches "/foobar"
-
-
- • "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar"
-
-
- This shows that prefix matching properly requires a "/" separator to
- prevent false matches.
-
-
+
+
+
+
+ Foo
+
+
+ Foobar
+
+
+ Foo (exact)
+
+
+
+
+
+ Current URL: /foobar
+
+
+ • "Foo" tab (prefix): NOT active - prevents "/foo" from matching
+ "/foobar"
+
+
+ • "Foobar" tab (exact): IS active - exactly matches "/foobar"
+
+
+ • "Foo (exact)" tab: NOT active - doesn't exactly match "/foobar"
+
+
+ This shows that prefix matching properly requires a "/" separator to
+ prevent false matches.
+
+
+
),
});
@@ -385,37 +408,39 @@ export const PrefixMatchingWithSlash = meta.story({
},
render: () => (
-
-
-
- Foo
-
-
- Foobar
-
-
- Bar
-
-
-
-
-
- Current URL: /foo/bar
-
-
- • "Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/"
-
-
- • "Foobar" tab (exact): NOT active - doesn't exactly match "/foobar"
-
-
- • "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with
- "/bar"
-
-
- This demonstrates proper prefix matching with the "/" separator.
-
-
+
+
+
+
+ Foo
+
+
+ Foobar
+
+
+ Bar
+
+
+
+
+
+ Current URL: /foo/bar
+
+
+ • "Foo" tab (prefix): IS active - "/foo/bar" starts with "/foo/"
+
+
+ • "Foobar" tab (exact): NOT active - doesn't exactly match "/foobar"
+
+
+ • "Bar" tab (prefix): NOT active - "/foo/bar" doesn't start with
+ "/bar"
+
+
+ This demonstrates proper prefix matching with the "/" separator.
+
+
+
),
});
@@ -426,32 +451,34 @@ export const RootPathMatching = meta.story({
},
render: () => (
-
-
-
- Home
-
-
- Home (prefix)
-
-
- Catalog
-
-
-
-
-
- Current URL: /
-
-
- • "Home" tab (exact): IS active - exactly matches "/"
-
- • "Home (prefix)" tab: IS active - "/" matches "/"
-
- • "Catalog" tab (prefix): NOT active - "/" doesn't start with
- "/catalog"
-
-
+
+
+
+
+ Home
+
+
+ Home (prefix)
+
+
+ Catalog
+
+
+
+
+
+ Current URL: /
+
+
+ • "Home" tab (exact): IS active - exactly matches "/"
+
+ • "Home (prefix)" tab: IS active - "/" matches "/"
+
+ • "Catalog" tab (prefix): NOT active - "/" doesn't start with
+ "/catalog"
+
+
+
),
});
@@ -462,41 +489,43 @@ export const HrefWithQueryParams = meta.story({
},
render: () => (
-
-
-
- Dashboard
-
-
- Alerts
-
-
-
-
-
- Current URL: /cost-insights/dashboard?group=bar
-
-
- Tab hrefs include query params (e.g., ?group=foo) but the current URL
- has different query params (?group=bar).
-
-
- • "Dashboard" tab: IS active — matching ignores query params and
- compares only the pathname.
-
-
- • "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't
- match /cost-insights/dashboard.
-
-
+
+
+
+
+ Dashboard
+
+
+ Alerts
+
+
+
+
+
+ Current URL: /cost-insights/dashboard?group=bar
+
+
+ Tab hrefs include query params (e.g., ?group=foo) but the current
+ URL has different query params (?group=bar).
+
+
+ • "Dashboard" tab: IS active — matching ignores query params and
+ compares only the pathname.
+
+
+ • "Alerts" tab: NOT active — pathname /cost-insights/alerts doesn't
+ match /cost-insights/dashboard.
+
+
+
),
});
@@ -507,55 +536,57 @@ export const AutoSelectionOfTabs = meta.story({
},
render: () => (
-
-
- Current URL: /random-page
-
+
+
+
+ Current URL: /random-page
+
- {/* Without hrefs */}
-
- {' '}
- Case 1: Without hrefs
-
-
-
- Settings
- Preferences
- Advanced
-
-
- Settings content - React Aria manages this selection
-
-
- Preferences content - works normally
-
-
- Advanced content - local state only
-
-
+ {/* Without hrefs */}
+
+ {' '}
+ Case 1: Without hrefs
+
+
+
+ Settings
+ Preferences
+ Advanced
+
+
+ Settings content - React Aria manages this selection
+
+
+ Preferences content - works normally
+
+
+ Advanced content - local state only
+
+
- {/* With hrefs */}
-
- Case 2: With hrefs
-
-
- By default no selection is shown because the URL doesn't match any
- tab's href.
-
-
-
-
- Catalog
-
-
- Create
-
-
- Docs
-
-
-
-
+ {/* With hrefs */}
+
+ Case 2: With hrefs
+
+
+ By default no selection is shown because the URL doesn't match any
+ tab's href.
+
+
+
+
+ Catalog
+
+
+ Create
+
+
+ Docs
+
+
+
+
+
),
});
diff --git a/packages/ui/src/components/Tabs/Tabs.tsx b/packages/ui/src/components/Tabs/Tabs.tsx
index fcab2c71b9..c489a2ab67 100644
--- a/packages/ui/src/components/Tabs/Tabs.tsx
+++ b/packages/ui/src/components/Tabs/Tabs.tsx
@@ -50,15 +50,9 @@ import {
TabDefinition,
TabPanelDefinition,
} from './definition';
-import {
- isInternalLink,
- createRoutingRegistration,
-} from '../InternalLinkProvider';
+import { isInternalLink } from '../../utils/linkUtils';
import { getNodeText } from '../../analytics/getNodeText';
-const { RoutingProvider, useRoutingRegistrationEffect } =
- createRoutingRegistration();
-
const TabsContext = createContext(undefined);
const useTabsContext = () => {
@@ -218,21 +212,19 @@ export const Tabs = (props: TabsProps) => {
);
return (
-
-
-
-
- {children as ReactNode}
-
-
-
-
+
+
+
+ {children as ReactNode}
+
+
+
);
};
@@ -299,9 +291,6 @@ function RoutedTabEffects({
const selectionCtx = useContext(TabSelectionContext);
const location = useLocation();
- // Register with RoutingProvider for conditional RouterProvider wrapping
- useRoutingRegistrationEffect(href);
-
// Register as a routed tab (for controlled vs uncontrolled mode)
useEffect(() => {
if (selectionCtx) {
diff --git a/packages/ui/src/components/TagGroup/TagGroup.stories.tsx b/packages/ui/src/components/TagGroup/TagGroup.stories.tsx
index 7d4ee4173e..00c778757f 100644
--- a/packages/ui/src/components/TagGroup/TagGroup.stories.tsx
+++ b/packages/ui/src/components/TagGroup/TagGroup.stories.tsx
@@ -21,6 +21,7 @@ import type { Selection } from 'react-aria-components';
import { Flex } from '../../';
import { useListData } from 'react-stately';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../../provider';
import {
RiAccountCircleLine,
RiBugLine,
@@ -50,7 +51,9 @@ const meta = preview.meta({
decorators: [
Story => (
-
+
+
+
),
],
diff --git a/packages/ui/src/components/TagGroup/TagGroup.tsx b/packages/ui/src/components/TagGroup/TagGroup.tsx
index 8258ab8554..9fd17c6d5c 100644
--- a/packages/ui/src/components/TagGroup/TagGroup.tsx
+++ b/packages/ui/src/components/TagGroup/TagGroup.tsx
@@ -25,12 +25,8 @@ import { forwardRef, type ReactNode } from 'react';
import { RiCloseCircleLine } from '@remixicon/react';
import { useDefinition } from '../../hooks/useDefinition';
import { TagGroupDefinition, TagDefinition } from './definition';
-import { createRoutingRegistration } from '../InternalLinkProvider';
import { getNodeText } from '../../analytics/getNodeText';
-const { RoutingProvider, useRoutingRegistrationEffect } =
- createRoutingRegistration();
-
/**
* A component that renders a list of tags.
*
@@ -41,17 +37,15 @@ export const TagGroup = (props: TagGroupProps) => {
const { classes, items, children, renderEmptyState } = ownProps;
return (
-
-
-
- {children}
-
-
-
+
+
+ {children}
+
+
);
};
@@ -68,8 +62,6 @@ export const Tag = forwardRef((props, ref) => {
const { classes, children, icon, href } = ownProps;
const textValue = typeof children === 'string' ? children : undefined;
- useRoutingRegistrationEffect(href);
-
const handlePress = () => {
if (href) {
const text =
diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts
index a07fff7462..79c5a8b884 100644
--- a/packages/ui/src/definitions.ts
+++ b/packages/ui/src/definitions.ts
@@ -52,6 +52,10 @@ export { MenuDefinition } from './components/Menu/definition';
export { PasswordFieldDefinition } from './components/PasswordField/definition';
export { PopoverDefinition } from './components/Popover/definition';
export { RadioGroupDefinition } from './components/RadioGroup/definition';
+export {
+ SearchAutocompleteDefinition,
+ SearchAutocompleteItemDefinition,
+} from './components/SearchAutocomplete/definition';
export { SearchFieldDefinition } from './components/SearchField/definition';
export { SelectDefinition } from './components/Select/definition';
export { SkeletonDefinition } from './components/Skeleton/definition';
diff --git a/packages/ui/src/guidelines/CardsWithTable.stories.tsx b/packages/ui/src/guidelines/CardsWithTable.stories.tsx
index a8ecaf0bef..23e5923327 100644
--- a/packages/ui/src/guidelines/CardsWithTable.stories.tsx
+++ b/packages/ui/src/guidelines/CardsWithTable.stories.tsx
@@ -18,6 +18,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { MemoryRouter } from 'react-router-dom';
+import { BUIProvider } from '../provider';
import {
Card,
CardHeader,
@@ -265,7 +266,9 @@ const meta = {
decorators: [
(Story: () => JSX.Element) => (
-
+
+
+
),
],
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 290ae2a80f..8122fd5fde 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -51,6 +51,7 @@ export * from './components/PasswordField';
export * from './components/Tooltip';
export * from './components/Menu';
export * from './components/Popover';
+export * from './components/SearchAutocomplete';
export * from './components/SearchField';
export * from './components/Link';
export * from './components/Select';
@@ -68,11 +69,14 @@ export { useBreakpoint } from './hooks/useBreakpoint';
export { useBgProvider, useBgConsumer, BgProvider } from './hooks/useBg';
export type { BgContextValue, BgProviderProps } from './hooks/useBg';
+// Provider
+export { BUIProvider } from './provider';
+export type { BUIProviderProps } from './provider';
+
// Analytics
-export { useAnalytics, BUIProvider, getNodeText } from './analytics';
+export { useAnalytics, getNodeText } from './analytics';
export type {
AnalyticsTracker,
AnalyticsEventAttributes,
UseAnalyticsFn,
- BUIProviderProps,
} from './analytics';
diff --git a/packages/ui/src/analytics/BUIProvider.tsx b/packages/ui/src/provider/BUIProvider.tsx
similarity index 68%
rename from packages/ui/src/analytics/BUIProvider.tsx
rename to packages/ui/src/provider/BUIProvider.tsx
index 3fcbfc7698..6576a9eabb 100644
--- a/packages/ui/src/analytics/BUIProvider.tsx
+++ b/packages/ui/src/provider/BUIProvider.tsx
@@ -15,9 +15,11 @@
*/
import { useMemo, type ReactNode } from 'react';
+import { RouterProvider } from 'react-aria-components';
+import { useInRouterContext, useNavigate, useHref } from 'react-router-dom';
import { createVersionedValueMap } from '@backstage/version-bridge';
-import { BUIContext } from './useAnalytics';
-import type { UseAnalyticsFn } from './types';
+import { BUIContext } from '../analytics/useAnalytics';
+import type { UseAnalyticsFn } from '../analytics/types';
/** @public */
export type BUIProviderProps = {
@@ -53,5 +55,23 @@ export function BUIProvider(props: BUIProviderProps) {
}),
[useAnalytics],
);
- return {children};
+
+ const content = (
+ {children}
+ );
+
+ if (useInRouterContext()) {
+ return {content};
+ }
+
+ return content;
+}
+
+function RoutedContent({ children }: { children: ReactNode }) {
+ const navigate = useNavigate();
+ return (
+
+ {children}
+
+ );
}
diff --git a/packages/ui/src/provider/index.ts b/packages/ui/src/provider/index.ts
new file mode 100644
index 0000000000..7a99794d47
--- /dev/null
+++ b/packages/ui/src/provider/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2026 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { BUIProvider } from './BUIProvider';
+export type { BUIProviderProps } from './BUIProvider';
diff --git a/packages/ui/src/utils/isExternalLink.ts b/packages/ui/src/utils/linkUtils.ts
similarity index 85%
rename from packages/ui/src/utils/isExternalLink.ts
rename to packages/ui/src/utils/linkUtils.ts
index a874579ab5..8b2ded55c4 100644
--- a/packages/ui/src/utils/isExternalLink.ts
+++ b/packages/ui/src/utils/linkUtils.ts
@@ -40,3 +40,12 @@ export function isExternalLink(href?: string): boolean {
return false;
}
+
+/**
+ * Checks if an href is an internal link (not external and not empty).
+ *
+ * @internal
+ */
+export function isInternalLink(href: string | undefined): href is string {
+ return !!href && !isExternalLink(href);
+}
diff --git a/packages/yarn-plugin/package.json b/packages/yarn-plugin/package.json
index 0326e3d4e9..b54b422312 100644
--- a/packages/yarn-plugin/package.json
+++ b/packages/yarn-plugin/package.json
@@ -46,7 +46,7 @@
"@yarnpkg/builder": "^4.2.1",
"fs-extra": "^11.2.0",
"nodemon": "^3.0.1",
- "snyk-nodejs-lockfile-parser": "^1.58.14",
+ "snyk-nodejs-lockfile-parser": "^2.0.0",
"yaml": "^2.0.0"
}
}
diff --git a/plugins/api-docs/README-alpha.md b/plugins/api-docs/README-alpha.md
index 0f19f51b23..a4c3979787 100644
--- a/plugins/api-docs/README-alpha.md
+++ b/plugins/api-docs/README-alpha.md
@@ -1,8 +1,6 @@
-# Api Docs
+# Api Docs - Extension Reference
-> [!WARNING]
-> This documentation is made for those using the experimental new Frontend system.
-> If you are not using the new frontend system, please go [here](./README.md).
+This page contains detailed documentation for all extensions provided by the `@backstage/plugin-api-docs` plugin. For general information about the plugin, see the [README](./README.md).
This is an extension for the catalog plugin that provides components to discover and display API entities.
APIs define the interface between components, see the [system model](https://backstage.io/docs/features/software-catalog/system-model) for details.
diff --git a/plugins/api-docs/README.md b/plugins/api-docs/README.md
index 139a97d4c8..f71b796ec4 100644
--- a/plugins/api-docs/README.md
+++ b/plugins/api-docs/README.md
@@ -1,8 +1,5 @@
# API Documentation
-> Disclaimer:
-> If you are looking for documentation on the experimental new frontend system support, please go [here](./README-alpha.md).
-
This is an extension for the catalog plugin that provides components to discover and display API entities.
APIs define the interface between components, see the [system model](https://backstage.io/docs/features/software-catalog/system-model) for details.
They are defined in machine readable formats and provide a human readable documentation.
@@ -28,77 +25,32 @@ To link that a component provides or consumes an API, see the [`providesApis`](h
> The plugin is already added when using `npx @backstage/create-app` so you can skip these steps.
-1. Install the API docs plugin
-
```bash
# From your Backstage root directory
yarn --cwd packages/app add @backstage/plugin-api-docs
```
-2. Add the `ApiExplorerPage` extension to the app:
+Once installed, the plugin is automatically available in your app through the default feature discovery. For more details and alternative installation methods, see [installing plugins](https://backstage.io/docs/frontend-system/building-apps/installing-plugins).
-```tsx
-// In packages/app/src/App.tsx
+You can enable entity cards and tabs on the catalog entity page through configuration:
-import { ApiExplorerPage } from '@backstage/plugin-api-docs';
-
-} />;
+```yaml
+# app-config.yaml
+app:
+ extensions:
+ - entity-card:api-docs/providing-components:
+ config:
+ filter:
+ kind: api
+ - entity-card:api-docs/consuming-components:
+ config:
+ filter:
+ kind: api
+ - entity-content:api-docs/definition
+ - entity-content:api-docs/apis
```
-3. Add one of the provided widgets to the EntityPage:
-
-```tsx
-// packages/app/src/components/catalog/EntityPage.tsx
-
-import {
- EntityAboutCard,
- EntityApiDefinitionCard,
- EntityConsumingComponentsCard,
- EntityProvidingComponentsCard,
-} from '@backstage/plugin-api-docs';
-
-const apiPage = (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-);
-
-// ...
-
-export const entityPage = (
-
- // ...
-
- // ...
-
-);
-```
-
-There are other components to discover in [`./src/components`](./src/components) that are also added by the default app.
+For the full list of available extensions and their configuration options, see the [README-alpha.md](./README-alpha.md).
## Customizations
@@ -388,6 +340,75 @@ import { ApiExplorerPage } from '@backstage/plugin-api-docs';
/>;
```
+## Old Frontend System
+
+If your Backstage app uses the old frontend system, you need to manually wire the plugin into your app as outlined in this section. If you are on the new frontend system, you can skip this.
+
+1. Add the `ApiExplorerPage` extension to the app:
+
+```tsx
+// In packages/app/src/App.tsx
+
+import { ApiExplorerPage } from '@backstage/plugin-api-docs';
+
+} />;
+```
+
+2. Add one of the provided widgets to the EntityPage:
+
+```tsx
+// packages/app/src/components/catalog/EntityPage.tsx
+
+import {
+ EntityAboutCard,
+ EntityApiDefinitionCard,
+ EntityConsumingComponentsCard,
+ EntityProvidingComponentsCard,
+} from '@backstage/plugin-api-docs';
+
+const apiPage = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+// ...
+
+export const entityPage = (
+
+ // ...
+
+ // ...
+
+);
+```
+
+There are other components to discover in [`./src/components`](./src/components) that are also added by the default app.
+
## Links
- [The Backstage homepage](https://backstage.io)
diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx
index 4afe97dee9..2724619a68 100644
--- a/plugins/api-docs/src/alpha.tsx
+++ b/plugins/api-docs/src/alpha.tsx
@@ -43,7 +43,7 @@ const apiDocsNavItem = NavItemBlueprint.make({
params: {
title: 'APIs',
routeRef: rootRoute,
- icon: () => ,
+ icon: () => ,
},
});
@@ -211,7 +211,7 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({
export default createFrontendPlugin({
pluginId: 'api-docs',
title: 'APIs',
- icon: ,
+ icon: ,
info: { packageJson: () => import('../package.json') },
routes: {
root: rootRoute,
diff --git a/plugins/app-visualizer/dev/index.ts b/plugins/app-visualizer/dev/index.ts
index fa49e3360b..5a5222ffbf 100644
--- a/plugins/app-visualizer/dev/index.ts
+++ b/plugins/app-visualizer/dev/index.ts
@@ -14,12 +14,7 @@
* limitations under the License.
*/
-import ReactDOM from 'react-dom/client';
-import { createApp } from '@backstage/frontend-defaults';
+import { createDevApp } from '@backstage/frontend-dev-utils';
import { default as plugin } from '../src';
-const app = createApp({
- features: [plugin],
-});
-
-ReactDOM.createRoot(document.getElementById('root')!).render(app.createRoot());
+createDevApp({ features: [plugin] });
diff --git a/plugins/app-visualizer/package.json b/plugins/app-visualizer/package.json
index 4c2a456f5b..bf71701775 100644
--- a/plugins/app-visualizer/package.json
+++ b/plugins/app-visualizer/package.json
@@ -43,7 +43,7 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
- "@backstage/frontend-defaults": "workspace:^",
+ "@backstage/frontend-dev-utils": "workspace:^",
"@types/react": "^18.0.0",
"react": "^18.0.2",
"react-dom": "^18.0.2",
diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md
index b1d851dab9..0262c4c1ef 100644
--- a/plugins/app/report.api.md
+++ b/plugins/app/report.api.md
@@ -19,6 +19,7 @@ import { JSX as JSX_2 } from 'react';
import { NavContentComponent } from '@backstage/plugin-app-react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
+import { PluginWrapperDefinition } from '@backstage/frontend-plugin-api';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SignInPageProps } from '@backstage/plugin-app-react';
@@ -620,11 +621,7 @@ const appPlugin: OverridableFrontendPlugin<
inputs: {
wrappers: ExtensionInput<
ConfigurableExtensionDataRef<
- () => Promise<{
- component: ComponentType<{
- children: ReactNode;
- }>;
- }>,
+ () => Promise,
'core.plugin-wrapper.loader',
{}
>,
diff --git a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx
index c723222f2f..bbf9ce6294 100644
--- a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx
+++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx
@@ -16,6 +16,34 @@
import { render, screen } from '@testing-library/react';
import { DefaultPluginWrapperApi } from './DefaultPluginWrapperApi';
+import { PluginWrapperDefinition } from '@backstage/frontend-plugin-api';
+import { ReactNode, useState } from 'react';
+import userEvent from '@testing-library/user-event';
+
+type TestInc = { count: number; increment: () => void };
+
+function useTestInc(): TestInc {
+ const [value, setValue] = useState(0);
+ return {
+ count: value,
+ increment: () => setValue(val => val + 1),
+ };
+}
+
+function makeTestIncWrapper(
+ label: string = '',
+ renderSpy?: () => void,
+): (props: { children: ReactNode; value: TestInc }) => JSX.Element {
+ return ({ children, value }: { children: ReactNode; value: TestInc }) => {
+ renderSpy?.();
+ return (
+