components:{' '}
- {Object.entries(coreComponentRefs)
+ {Object.entries(defaultComponentRefs)
.map(
([name, ref]) =>
`${name}=${Boolean(components.getComponent(ref))}`,
@@ -118,7 +126,7 @@ describe('ForwardsCompatProvider', () => {
await renderInOldTestApp(compatWrapper(
));
expect(screen.getByTestId('ctx').textContent).toMatchInlineSnapshot(`
- "components: progress=true, notFoundErrorPage=true, errorBoundaryFallback=true
+ "components: progress=true, notFoundErrorPage=true, errorDisplay=true
icons: kind:api, kind:component, kind:domain, kind:group, kind:location, kind:system, kind:user, kind:resource, kind:template, brokenImage, catalog, scaffolder, techdocs, search, chat, dashboard, docs, email, github, group, help, user, warning, star, unstarred"
`);
});
diff --git a/packages/core-compat-api/src/convertLegacyApp.test.tsx b/packages/core-compat-api/src/convertLegacyApp.test.tsx
index 5742c80487..e80812d2da 100644
--- a/packages/core-compat-api/src/convertLegacyApp.test.tsx
+++ b/packages/core-compat-api/src/convertLegacyApp.test.tsx
@@ -232,8 +232,8 @@ describe('convertLegacyApp', () => {
const catalogOverride = catalogPlugin.withOverrides({
extensions: [
catalogPlugin.getExtension('api:catalog').override({
- params: define =>
- define({
+ params: defineParams =>
+ defineParams({
api: catalogApiRef,
deps: {},
factory: () =>
diff --git a/packages/core-compat-api/src/convertLegacyAppOptions.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.tsx
index c98875464c..650ea9cd58 100644
--- a/packages/core-compat-api/src/convertLegacyAppOptions.tsx
+++ b/packages/core-compat-api/src/convertLegacyAppOptions.tsx
@@ -16,10 +16,9 @@
import { ComponentType } from 'react';
import {
+ SwappableComponentBlueprint,
ApiBlueprint,
- coreComponentRefs,
- CoreErrorBoundaryFallbackProps,
- createComponentExtension,
+ ErrorDisplayProps,
createExtension,
createFrontendModule,
ExtensionDefinition,
@@ -28,6 +27,9 @@ import {
RouterBlueprint,
SignInPageBlueprint,
ThemeBlueprint,
+ ErrorDisplay as SwappableErrorDisplay,
+ NotFoundErrorPage as SwappableNotFoundErrorPage,
+ Progress as SwappableProgress,
} from '@backstage/frontend-plugin-api';
import {
AnyApiFactory,
@@ -76,7 +78,7 @@ export function convertLegacyAppOptions(
const extensions: ExtensionDefinition[] = deduplicatedApis.map(factory =>
ApiBlueprint.make({
name: factory.api.id,
- params: define => define(factory),
+ params: defineParams => defineParams(factory),
}),
);
@@ -139,7 +141,7 @@ export function convertLegacyAppOptions(
if (Router) {
extensions.push(
RouterBlueprint.make({
- params: { Component: componentCompatWrapper(Router) },
+ params: { component: componentCompatWrapper(Router) },
}),
);
}
@@ -154,36 +156,45 @@ export function convertLegacyAppOptions(
}
if (Progress) {
extensions.push(
- createComponentExtension({
- ref: coreComponentRefs.progress,
- loader: { sync: () => componentCompatWrapper(Progress) },
+ SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: SwappableProgress,
+ loader: () => componentCompatWrapper(Progress),
+ }),
}),
);
}
+
if (NotFoundErrorPage) {
extensions.push(
- createComponentExtension({
- ref: coreComponentRefs.notFoundErrorPage,
- loader: { sync: () => componentCompatWrapper(NotFoundErrorPage) },
+ SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: SwappableNotFoundErrorPage,
+ loader: () => componentCompatWrapper(NotFoundErrorPage),
+ }),
}),
);
}
+
if (ErrorBoundaryFallback) {
- const WrappedErrorBoundaryFallback = (
- props: CoreErrorBoundaryFallbackProps,
- ) =>
+ const WrappedErrorBoundaryFallback = (props: ErrorDisplayProps) =>
compatWrapper(
,
);
+
extensions.push(
- createComponentExtension({
- ref: coreComponentRefs.errorBoundaryFallback,
- loader: {
- sync: () => componentCompatWrapper(WrappedErrorBoundaryFallback),
- },
+ SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: SwappableErrorDisplay,
+ loader: () =>
+ componentCompatWrapper(WrappedErrorBoundaryFallback),
+ }),
}),
);
}
diff --git a/packages/core-compat-api/src/convertLegacyPageExtension.test.tsx b/packages/core-compat-api/src/convertLegacyPageExtension.test.tsx
index 4248cef098..18957d7532 100644
--- a/packages/core-compat-api/src/convertLegacyPageExtension.test.tsx
+++ b/packages/core-compat-api/src/convertLegacyPageExtension.test.tsx
@@ -75,7 +75,7 @@ describe('convertLegacyPageExtension', () => {
const converted = convertLegacyPageExtension(LegacyExtension, {
name: 'other',
- defaultPath: '/other',
+ path: '/other',
});
const tester = createExtensionTester(converted);
diff --git a/packages/core-compat-api/src/convertLegacyPageExtension.tsx b/packages/core-compat-api/src/convertLegacyPageExtension.tsx
index f14b297047..299fcab58f 100644
--- a/packages/core-compat-api/src/convertLegacyPageExtension.tsx
+++ b/packages/core-compat-api/src/convertLegacyPageExtension.tsx
@@ -32,7 +32,11 @@ export function convertLegacyPageExtension(
LegacyExtension: ComponentType<{}>,
overrides?: {
name?: string;
- defaultPath?: string;
+ path?: string;
+ /**
+ * @deprecated Use the `path` param instead.
+ */
+ defaultPath?: [Error: `Use the 'path' override instead`];
},
): ExtensionDefinition {
const element =
;
@@ -55,7 +59,7 @@ export function convertLegacyPageExtension(
return PageBlueprint.make({
name: overrides?.name ?? kebabName,
params: {
- defaultPath: overrides?.defaultPath ?? `/${kebabName}`,
+ path: overrides?.path ?? `/${kebabName}`,
routeRef: mountPoint && convertLegacyRouteRef(mountPoint),
loader: async () => compatWrapper(element),
},
diff --git a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx
index 3f85e07ca5..81684216eb 100644
--- a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx
+++ b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx
@@ -70,7 +70,7 @@ describe('convertLegacyPlugin', () => {
{
extensions: [
PageBlueprint.make({
- params: { defaultPath: '/test', loader: async () => ({} as any) },
+ params: { path: '/test', loader: async () => ({} as any) },
}),
],
},
diff --git a/packages/core-compat-api/src/convertLegacyPlugin.ts b/packages/core-compat-api/src/convertLegacyPlugin.ts
index 952b50a836..da6943cacb 100644
--- a/packages/core-compat-api/src/convertLegacyPlugin.ts
+++ b/packages/core-compat-api/src/convertLegacyPlugin.ts
@@ -31,7 +31,7 @@ export function convertLegacyPlugin(
const apiExtensions = Array.from(legacyPlugin.getApis()).map(factory =>
ApiBlueprint.make({
name: factory.api.id,
- params: define => define(factory),
+ params: defineParams => defineParams(factory),
}),
);
return createFrontendPlugin({
diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md
index 7ed9d1ddae..62288eb65c 100644
--- a/packages/core-components/CHANGELOG.md
+++ b/packages/core-components/CHANGELOG.md
@@ -1,5 +1,17 @@
# @backstage/core-components
+## 0.17.5-next.1
+
+### Patch Changes
+
+- 5563605: Added `FavoriteToggleProps`.
+- Updated dependencies
+ - @backstage/config@1.3.3
+ - @backstage/core-plugin-api@1.10.9
+ - @backstage/errors@1.2.7
+ - @backstage/theme@0.6.8-next.0
+ - @backstage/version-bridge@1.0.11
+
## 0.17.5-next.0
### Patch Changes
diff --git a/packages/core-components/package.json b/packages/core-components/package.json
index 2a6066a6ad..a502bad903 100644
--- a/packages/core-components/package.json
+++ b/packages/core-components/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/core-components",
- "version": "0.17.5-next.0",
+ "version": "0.17.5-next.1",
"description": "Core components used by Backstage plugins and apps",
"backstage": {
"role": "web-library"
@@ -74,8 +74,8 @@
"d3-shape": "^3.0.0",
"d3-zoom": "^3.0.0",
"js-yaml": "^4.1.0",
- "linkify-react": "4.1.3",
- "linkifyjs": "4.1.3",
+ "linkify-react": "4.3.2",
+ "linkifyjs": "4.3.2",
"lodash": "^4.17.21",
"pluralize": "^8.0.0",
"qs": "^6.9.4",
diff --git a/packages/core-components/report.api.md b/packages/core-components/report.api.md
index 90fa9ced22..fff1d0365a 100644
--- a/packages/core-components/report.api.md
+++ b/packages/core-components/report.api.md
@@ -399,14 +399,7 @@ export type ErrorPanelProps = {
};
// @public
-export function FavoriteToggle(
- props: ComponentProps
& {
- id: string;
- title: string;
- isFavorite: boolean;
- onToggle: (value: boolean) => void;
- },
-): JSX_2.Element;
+export function FavoriteToggle(props: FavoriteToggleProps): JSX_2.Element;
// @public
export function FavoriteToggleIcon(props: {
@@ -416,6 +409,14 @@ export function FavoriteToggleIcon(props: {
// @public (undocumented)
export type FavoriteToggleIconClassKey = 'icon' | 'iconBorder';
+// @public
+export type FavoriteToggleProps = ComponentProps & {
+ id: string;
+ title: string;
+ isFavorite: boolean;
+ onToggle: (value: boolean) => void;
+};
+
// @public (undocumented)
export type FeatureCalloutCircleClassKey =
| '@keyframes pulsateSlightly'
diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx
index 7cf6374e13..86c7270719 100644
--- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx
+++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx
@@ -17,12 +17,8 @@
import { DismissableBanner, Props } from './DismissableBanner';
import Typography from '@material-ui/core/Typography';
import { WebStorage } from '@backstage/core-app-api';
-import {
- ErrorApi,
- storageApiRef,
- StorageApi,
-} from '@backstage/core-plugin-api';
-import { TestApiProvider } from '@backstage/test-utils';
+import { storageApiRef, StorageApi } from '@backstage/core-plugin-api';
+import { TestApiProvider, MockErrorApi } from '@backstage/test-utils';
import { Link } from '../Link';
export default {
@@ -36,11 +32,10 @@ export default {
},
};
-let errorApi: ErrorApi;
const containerStyle = { width: '70%' };
const createWebStorage = (): StorageApi => {
- return WebStorage.create({ errorApi });
+ return WebStorage.create({ errorApi: new MockErrorApi() });
};
const apis = [[storageApiRef, createWebStorage()] as const];
diff --git a/packages/core-components/src/components/FavoriteToggle/FavoriteToggle.tsx b/packages/core-components/src/components/FavoriteToggle/FavoriteToggle.tsx
index df6b367b57..ceb60c5491 100644
--- a/packages/core-components/src/components/FavoriteToggle/FavoriteToggle.tsx
+++ b/packages/core-components/src/components/FavoriteToggle/FavoriteToggle.tsx
@@ -62,6 +62,18 @@ export function FavoriteToggleIcon(props: { isFavorite: boolean }) {
);
}
+/**
+ * Props for the {@link FavoriteToggle} component.
+ *
+ * @public
+ */
+export type FavoriteToggleProps = ComponentProps & {
+ id: string;
+ title: string;
+ isFavorite: boolean;
+ onToggle: (value: boolean) => void;
+};
+
/**
* Toggle encapsulating logic for marking something as favorite,
* primarily used in various instances of entity lists and cards but can be used elsewhere.
@@ -70,14 +82,7 @@ export function FavoriteToggleIcon(props: { isFavorite: boolean }) {
*
* @public
*/
-export function FavoriteToggle(
- props: ComponentProps & {
- id: string;
- title: string;
- isFavorite: boolean;
- onToggle: (value: boolean) => void;
- },
-) {
+export function FavoriteToggle(props: FavoriteToggleProps) {
const {
id,
title,
diff --git a/packages/core-components/src/components/FavoriteToggle/index.ts b/packages/core-components/src/components/FavoriteToggle/index.ts
index 460b162bdf..845ab22df2 100644
--- a/packages/core-components/src/components/FavoriteToggle/index.ts
+++ b/packages/core-components/src/components/FavoriteToggle/index.ts
@@ -15,4 +15,7 @@
*/
export { FavoriteToggle, FavoriteToggleIcon } from './FavoriteToggle';
-export type { FavoriteToggleIconClassKey } from './FavoriteToggle';
+export type {
+ FavoriteToggleIconClassKey,
+ FavoriteToggleProps,
+} from './FavoriteToggle';
diff --git a/packages/core-components/src/components/index.ts b/packages/core-components/src/components/index.ts
index 828df3ec03..905893597e 100644
--- a/packages/core-components/src/components/index.ts
+++ b/packages/core-components/src/components/index.ts
@@ -48,4 +48,3 @@ export * from './TabbedLayout';
export * from './Table';
export * from './TrendLine';
export * from './WarningPanel';
-export * from './FavoriteToggle';
diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md
index 851314ca03..9ae268b37c 100644
--- a/packages/create-app/CHANGELOG.md
+++ b/packages/create-app/CHANGELOG.md
@@ -1,5 +1,13 @@
# @backstage/create-app
+## 0.7.2-next.2
+
+### Patch Changes
+
+- Bumped create-app version.
+- Updated dependencies
+ - @backstage/cli-common@0.1.15
+
## 0.7.2-next.1
### Patch Changes
diff --git a/packages/create-app/package.json b/packages/create-app/package.json
index 873e8e7b87..af5eb8d12d 100644
--- a/packages/create-app/package.json
+++ b/packages/create-app/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/create-app",
- "version": "0.7.2-next.1",
+ "version": "0.7.2-next.2",
"description": "A CLI that helps you create your own Backstage app",
"backstage": {
"role": "cli"
diff --git a/packages/create-app/templates/next-app/app-config.yaml.hbs b/packages/create-app/templates/next-app/app-config.yaml.hbs
index 7688aee89b..b80b980d10 100644
--- a/packages/create-app/templates/next-app/app-config.yaml.hbs
+++ b/packages/create-app/templates/next-app/app-config.yaml.hbs
@@ -2,8 +2,7 @@ app:
title: Scaffolded Backstage App
baseUrl: http://localhost:3000
- experimental:
- packages: all
+ packages: all
extensions:
# Disable the nav items that we're manually rendering in packages/app/src/modules/nav/Sidebar.tsx
diff --git a/packages/create-app/templates/next-app/package.json.hbs b/packages/create-app/templates/next-app/package.json.hbs
index 8399c6505a..ae905dbd3f 100644
--- a/packages/create-app/templates/next-app/package.json.hbs
+++ b/packages/create-app/templates/next-app/package.json.hbs
@@ -22,6 +22,27 @@
"prettier:check": "prettier --check .",
"new": "backstage-cli new"
},
+ "backstage": {
+ "cli": {
+ "new": {
+ "globals": {
+ "license": "UNLICENSED"
+ },
+ "templates": [
+ "@backstage/cli/templates/new-frontend-plugin",
+ "@backstage/cli/templates/new-frontend-plugin-module",
+ "@backstage/cli/templates/backend-plugin",
+ "@backstage/cli/templates/backend-plugin-module",
+ "@backstage/cli/templates/plugin-web-library",
+ "@backstage/cli/templates/plugin-node-library",
+ "@backstage/cli/templates/plugin-common-library",
+ "@backstage/cli/templates/web-library",
+ "@backstage/cli/templates/node-library",
+ "@backstage/cli/templates/scaffolder-backend-module"
+ ]
+ }
+ }
+ },
"workspaces": {
"packages": [
"packages/*",
diff --git a/packages/create-app/templates/next-app/packages/app/package.json.hbs b/packages/create-app/templates/next-app/packages/app/package.json.hbs
index 01930826b1..51a8956263 100644
--- a/packages/create-app/templates/next-app/packages/app/package.json.hbs
+++ b/packages/create-app/templates/next-app/packages/app/package.json.hbs
@@ -15,6 +15,7 @@
},
"dependencies": {
"@backstage/core-compat-api": "^{{ version '@backstage/core-compat-api'}}",
+ "@backstage/core-components": "^{{ version '@backstage/core-components'}}",
"@backstage/frontend-defaults": "^{{ version '@backstage/frontend-defaults'}}",
"@backstage/frontend-plugin-api": "^{{ version '@backstage/frontend-plugin-api'}}",
"@backstage/plugin-catalog": "^{{ version '@backstage/plugin-catalog'}}",
@@ -22,7 +23,6 @@
"@backstage/plugin-scaffolder": "^{{ version '@backstage/plugin-scaffolder'}}",
"@backstage/plugin-search": "^{{ version '@backstage/plugin-search'}}",
"@backstage/plugin-user-settings": "^{{ version '@backstage/plugin-user-settings'}}",
- "@backstage/core-components": "^{{ version '@backstage/core-components'}}",
"@backstage/ui": "^{{ version '@backstage/ui'}}",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts
index c95b8294c7..796bd43fb0 100644
--- a/packages/e2e-test/src/commands/run.ts
+++ b/packages/e2e-test/src/commands/run.ts
@@ -61,7 +61,7 @@ export async function run(opts: OptionValues) {
print('Creating a Backstage Plugin');
const pluginId = 'test';
- await createPlugin({ appDir, pluginId, select: 'plugin' });
+ await createPlugin({ appDir, pluginId, select: 'frontend-plugin' });
print('Creating a Backstage Backend Plugin');
await createPlugin({ appDir, pluginId, select: 'backend-plugin' });
@@ -379,7 +379,7 @@ async function createPlugin(options: {
}) {
const { appDir, pluginId, select } = options;
const child = spawnPiped(
- ['yarn', 'new', '--select', select, '--option', `id=${pluginId}`],
+ ['yarn', 'new', '--select', select, '--option', `pluginId=${pluginId}`],
{
cwd: appDir,
},
diff --git a/packages/frontend-app-api/CHANGELOG.md b/packages/frontend-app-api/CHANGELOG.md
index 934c584e3a..9e857e53f2 100644
--- a/packages/frontend-app-api/CHANGELOG.md
+++ b/packages/frontend-app-api/CHANGELOG.md
@@ -1,5 +1,44 @@
# @backstage/frontend-app-api
+## 0.12.0-next.2
+
+### Minor Changes
+
+- df7bd3b: **BREAKING**: Removed the deprecated `FrontendFeature` type, import it from `@backstage/frontend-plugin-api` instead.
+
+### Patch Changes
+
+- d9e00e3: Add support for a new `aliasFor` option for `createRouteRef`. This allows for the creation of a new route ref that acts as an alias for an existing route ref that is installed in the app. This is particularly useful when creating modules that override existing plugin pages, without referring to the existing plugin. For example:
+
+ ```tsx
+ export default createFrontendModule({
+ pluginId: 'catalog',
+ extensions: [
+ PageBlueprint.make({
+ params: {
+ defaultPath: '/catalog',
+ routeRef: createRouteRef({ aliasFor: 'catalog.catalogIndex' }),
+ loader: () =>
+ import('./CustomCatalogIndexPage').then(m => (
+
+ )),
+ },
+ }),
+ ],
+ });
+ ```
+
+- 3d2499f: Moved `createSpecializedApp` options to a new `CreateSpecializedAppOptions` type.
+- Updated dependencies
+ - @backstage/frontend-defaults@0.3.0-next.2
+ - @backstage/frontend-plugin-api@0.11.0-next.1
+ - @backstage/config@1.3.3
+ - @backstage/core-app-api@1.18.0
+ - @backstage/core-plugin-api@1.10.9
+ - @backstage/errors@1.2.7
+ - @backstage/types@1.2.1
+ - @backstage/version-bridge@1.0.11
+
## 0.11.5-next.1
### Patch Changes
diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts
index 3b86b5c1d4..7032cd378d 100644
--- a/packages/frontend-app-api/config.d.ts
+++ b/packages/frontend-app-api/config.d.ts
@@ -20,10 +20,27 @@ export interface Config {
/**
* @visibility frontend
* @deepVisibility frontend
+ * @deprecated This is no longer experimental; use `app.packages` instead.
*/
packages?: 'all' | { include?: string[]; exclude?: string[] };
};
+ /**
+ * Controls what packages are loaded by the new frontend system.
+ *
+ * @remarks
+ *
+ * When using the 'all' option, all feature packages that were added as
+ * dependencies to the app will be loaded automatically.
+ *
+ * The `include` and `exclude` options can be used to more finely control
+ * which individual package names to include or exclude.
+ *
+ * @visibility frontend
+ * @deepVisibility frontend
+ */
+ packages?: 'all' | { include?: string[]; exclude?: string[] };
+
routes?: {
/**
* Maps external route references to regular route references. Both the
diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json
index 39f7fc3032..ff891a4b24 100644
--- a/packages/frontend-app-api/package.json
+++ b/packages/frontend-app-api/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/frontend-app-api",
- "version": "0.11.5-next.1",
+ "version": "0.12.0-next.2",
"backstage": {
"role": "web-library"
},
@@ -45,6 +45,7 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
+ "@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-app": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md
index 7228777561..039d0bb7b1 100644
--- a/packages/frontend-app-api/report.api.md
+++ b/packages/frontend-app-api/report.api.md
@@ -8,7 +8,7 @@ import { AppTree } from '@backstage/frontend-plugin-api';
import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
-import { FrontendFeature as FrontendFeature_2 } from '@backstage/frontend-plugin-api';
+import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfo } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { RouteRef } from '@backstage/frontend-plugin-api';
@@ -28,25 +28,25 @@ export type CreateAppRouteBinder = <
) => void;
// @public
-export function createSpecializedApp(options?: {
- features?: FrontendFeature_2[];
- config?: ConfigApi;
- bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
- apis?: ApiHolder;
- extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
- flags?: {
- allowUnknownExtensionConfig?: boolean;
- };
- pluginInfoResolver?: FrontendPluginInfoResolver;
-}): {
+export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
apis: ApiHolder;
tree: AppTree;
};
-// @public @deprecated (undocumented)
-export type FrontendFeature = FrontendFeature_2;
+// @public
+export type CreateSpecializedAppOptions = {
+ features?: FrontendFeature[];
+ config?: ConfigApi;
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+ advanced?: {
+ apis?: ApiHolder;
+ allowUnknownExtensionConfig?: boolean;
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware
+ | ExtensionFactoryMiddleware[];
+ pluginInfoResolver?: FrontendPluginInfoResolver;
+ };
+};
// @public
export type FrontendPluginInfoResolver = (ctx: {
diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx
deleted file mode 100644
index 7d06d55a92..0000000000
--- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright 2023 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 { createComponentRef } from '@backstage/frontend-plugin-api';
-import { DefaultComponentsApi } from './DefaultComponentsApi';
-import { render, screen } from '@testing-library/react';
-
-const testRefA = createComponentRef({ id: 'test.a' });
-const testRefB1 = createComponentRef({ id: 'test.b' });
-const testRefB2 = createComponentRef({ id: 'test.b' });
-
-describe('DefaultComponentsApi', () => {
- it('should provide components', () => {
- const api = DefaultComponentsApi.fromComponents([
- {
- ref: testRefA,
- impl: () => test.a
,
- },
- ]);
-
- const ComponentA = api.getComponent(testRefA);
- render();
-
- expect(screen.getByText('test.a')).toBeInTheDocument();
- });
-
- it('should key extension refs by ID', () => {
- const api = DefaultComponentsApi.fromComponents([
- {
- ref: testRefB1,
- impl: () => test.b
,
- },
- ]);
-
- const ComponentB1 = api.getComponent(testRefB1);
- const ComponentB2 = api.getComponent(testRefB2);
-
- expect(ComponentB1).toBe(ComponentB2);
-
- render();
-
- expect(screen.getByText('test.b')).toBeInTheDocument();
- });
-});
diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts
deleted file mode 100644
index 317233d797..0000000000
--- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright 2023 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 { ComponentType } from 'react';
-import {
- ComponentRef,
- ComponentsApi,
- createComponentExtension,
-} from '@backstage/frontend-plugin-api';
-
-/**
- * Implementation for the {@linkComponentApi}
- *
- * @internal
- */
-export class DefaultComponentsApi implements ComponentsApi {
- #components: Map>;
-
- static fromComponents(
- components: Array,
- ) {
- return new DefaultComponentsApi(
- new Map(components.map(entry => [entry.ref.id, entry.impl])),
- );
- }
-
- constructor(components: Map) {
- this.#components = components;
- }
-
- getComponent(ref: ComponentRef): ComponentType {
- const impl = this.#components.get(ref.id);
- if (!impl) {
- throw new Error(`No implementation found for component ref ${ref}`);
- }
- return impl;
- }
-}
diff --git a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx
new file mode 100644
index 0000000000..823d48e21c
--- /dev/null
+++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx
@@ -0,0 +1,264 @@
+/*
+ * Copyright 2023 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 {
+ ApiBlueprint,
+ createExtensionInput,
+ createSwappableComponent,
+ SwappableComponentBlueprint,
+ swappableComponentsApiRef,
+} from '@backstage/frontend-plugin-api';
+import { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi';
+import { render, screen } from '@testing-library/react';
+import { renderInTestApp } from '@backstage/frontend-test-utils';
+
+const { ref: testRefA } = createSwappableComponent({ id: 'test.a' });
+const { ref: testRefB1 } = createSwappableComponent({ id: 'test.b' });
+const { ref: testRefB2 } = createSwappableComponent({ id: 'test.b' });
+
+describe('DefaultSwappableComponentsApi', () => {
+ it('should provide components', async () => {
+ const api = DefaultSwappableComponentsApi.fromComponents([
+ {
+ ref: testRefA,
+ loader: () => () => test.a
,
+ },
+ ]);
+
+ const ComponentA = api.getComponent(testRefA);
+
+ render();
+
+ await expect(screen.findByText('test.a')).resolves.toBeInTheDocument();
+ });
+
+ it('should key extension refs by ID', async () => {
+ const mockLoader = jest.fn(() => test.b
);
+ const api = DefaultSwappableComponentsApi.fromComponents([
+ {
+ ref: testRefB1,
+ loader: () => mockLoader,
+ },
+ ]);
+
+ const ComponentB2 = api.getComponent(testRefB2);
+
+ render();
+
+ await expect(screen.findByText('test.b')).resolves.toBeInTheDocument();
+ });
+
+ describe('integration tests', () => {
+ const api = ApiBlueprint.makeWithOverrides({
+ name: 'swappable-components',
+ inputs: {
+ components: createExtensionInput([
+ SwappableComponentBlueprint.dataRefs.component,
+ ]),
+ },
+ factory: (originalFactory, { inputs }) => {
+ return originalFactory(defineParams =>
+ defineParams({
+ api: swappableComponentsApiRef,
+ deps: {},
+ factory: () =>
+ DefaultSwappableComponentsApi.fromComponents(
+ inputs.components.map(i =>
+ i.get(SwappableComponentBlueprint.dataRefs.component),
+ ),
+ ),
+ }),
+ );
+ },
+ });
+
+ describe('no api provided', () => {
+ it('should render the fallback if no other component is provided', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ });
+
+ renderInTestApp(, {});
+
+ await expect(
+ screen.findByTestId('test.mock'),
+ ).resolves.toBeInTheDocument();
+ });
+
+ it('should render the default compnoent if no other component is provided', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ loader: () => () => test.mock
,
+ });
+
+ renderInTestApp(, {});
+
+ await expect(
+ screen.findByText('test.mock'),
+ ).resolves.toBeInTheDocument();
+ });
+
+ it('should render async loader component if no other component is provided', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ loader: async () => () => test.mock
,
+ });
+
+ renderInTestApp(, {});
+
+ await expect(
+ screen.findByText('test.mock'),
+ ).resolves.toBeInTheDocument();
+ });
+
+ it('should transform props correctly', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ loader: () => (props: { inner: string }) =>
+ inner: {props.inner}
,
+ transformProps: (props: { external: string }) => ({
+ inner: props.external,
+ }),
+ });
+
+ renderInTestApp(, {});
+
+ await expect(
+ screen.findByText('inner: test'),
+ ).resolves.toBeInTheDocument();
+ });
+ });
+
+ describe('with overrides', () => {
+ it('should render the fallback if no other component is provided', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ });
+
+ renderInTestApp(, {
+ extensions: [api],
+ });
+
+ await expect(
+ screen.findByTestId('test.mock'),
+ ).resolves.toBeInTheDocument();
+ });
+
+ it('should render the default compnoent if no other component is provided', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ loader: () => () => test.mock
,
+ });
+
+ renderInTestApp(, {
+ extensions: [api],
+ });
+
+ await expect(
+ screen.findByText('test.mock'),
+ ).resolves.toBeInTheDocument();
+ });
+
+ it('should render async loader component if no other component is provided', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ loader: async () => () => test.mock
,
+ });
+
+ renderInTestApp(, {
+ extensions: [api],
+ });
+
+ await expect(
+ screen.findByText('test.mock'),
+ ).resolves.toBeInTheDocument();
+ });
+
+ it('should render the component provided by the blueprint', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ loader: () => () => test.mock
,
+ });
+
+ const override = SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: MockComponent,
+ loader: () => () => Overridden!
,
+ }),
+ });
+
+ renderInTestApp(, {
+ extensions: [api, override],
+ });
+
+ await expect(
+ screen.findByText('Overridden!'),
+ ).resolves.toBeInTheDocument();
+ });
+
+ it('should render the async component provided by the blueprint', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ loader: () => () => test.mock
,
+ });
+
+ const override = SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: MockComponent,
+ loader: async () => () => Overridden!
,
+ }),
+ });
+
+ renderInTestApp(, {
+ extensions: [api, override],
+ });
+
+ await expect(
+ screen.findByText('Overridden!'),
+ ).resolves.toBeInTheDocument();
+ });
+
+ it('should transform props correctly', async () => {
+ const MockComponent = createSwappableComponent({
+ id: 'test.mock',
+ loader: () => (props: { inner: string }) =>
+ inner: {props.inner}
,
+ transformProps: (props: { external: string }) => ({
+ inner: props.external,
+ }),
+ });
+
+ const override = SwappableComponentBlueprint.make({
+ params: define =>
+ define({
+ component: MockComponent,
+ loader: () => props => overridden: {props.inner}
,
+ }),
+ });
+
+ renderInTestApp(, {
+ extensions: [api, override],
+ });
+
+ await expect(
+ screen.findByText('overridden: test'),
+ ).resolves.toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx
new file mode 100644
index 0000000000..593fff24ff
--- /dev/null
+++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2023 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 {
+ SwappableComponentRef,
+ SwappableComponentsApi,
+ SwappableComponentBlueprint,
+} from '@backstage/frontend-plugin-api';
+import { OpaqueSwappableComponentRef } from '@internal/frontend';
+
+import { lazy } from 'react';
+
+/**
+ * Implementation for the {@link SwappableComponentsApi}
+ *
+ * @internal
+ */
+export class DefaultSwappableComponentsApi implements SwappableComponentsApi {
+ #components: Map JSX.Element | null) | undefined>;
+
+ static fromComponents(
+ components: Array,
+ ) {
+ return new DefaultSwappableComponentsApi(
+ new Map(
+ components.map(entry => {
+ return [
+ entry.ref.id,
+ entry.loader
+ ? lazy(async () => ({
+ default: await entry.loader!(),
+ }))
+ : undefined,
+ ];
+ }),
+ ),
+ );
+ }
+
+ constructor(components: Map) {
+ this.#components = components;
+ }
+
+ getComponent(
+ ref: SwappableComponentRef,
+ ): (props: object) => JSX.Element | null {
+ const OverrideComponent = this.#components.get(ref.id);
+ const { defaultComponent: DefaultComponent, transformProps } =
+ OpaqueSwappableComponentRef.toInternal(ref);
+
+ return (props: object) => {
+ const innerProps = transformProps?.(props) ?? props;
+
+ if (OverrideComponent) {
+ return ;
+ }
+
+ return ;
+ };
+ }
+}
diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/index.ts
similarity index 88%
rename from packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts
rename to packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/index.ts
index 18604f15de..20f90eb32d 100644
--- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts
+++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { DefaultComponentsApi } from './DefaultComponentsApi';
+export { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi';
diff --git a/packages/frontend-app-api/src/routing/RouteAliasResolver.ts b/packages/frontend-app-api/src/routing/RouteAliasResolver.ts
new file mode 100644
index 0000000000..64e5eb7ce8
--- /dev/null
+++ b/packages/frontend-app-api/src/routing/RouteAliasResolver.ts
@@ -0,0 +1,90 @@
+/*
+ * 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 { RouteRef } from '@backstage/frontend-plugin-api';
+import { RouteRefsById } from './collectRouteIds';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef';
+
+/**
+ * @internal
+ */
+export type RouteAliasResolver = {
+ (routeRef: RouteRef, pluginId?: string): RouteRef;
+ (routeRef?: RouteRef, pluginId?: string): RouteRef | undefined;
+};
+
+/**
+ * Creates a route alias resolver that resolves aliases based on the route IDs
+ * @internal
+ */
+export function createRouteAliasResolver(
+ routeRefsById: RouteRefsById,
+): RouteAliasResolver {
+ const resolver = (routeRef: RouteRef | undefined, pluginId?: string) => {
+ if (!routeRef) {
+ return undefined;
+ }
+
+ let currentRef = routeRef;
+ for (let i = 0; i < 100; i++) {
+ const alias = toInternalRouteRef(currentRef).alias;
+ if (alias) {
+ if (pluginId) {
+ const [aliasPluginId] = alias.split('.');
+ if (aliasPluginId !== pluginId) {
+ throw new Error(
+ `Refused to resolve alias '${alias}' for ${currentRef} as it points to a different plugin, the expected plugin is '${pluginId}' but the alias points to '${aliasPluginId}'`,
+ );
+ }
+ }
+ const aliasRef = routeRefsById.routes.get(alias);
+ if (!aliasRef) {
+ throw new Error(
+ `Unable to resolve RouteRef alias '${alias}' for ${currentRef}`,
+ );
+ }
+ if (aliasRef.$$type === '@backstage/SubRouteRef') {
+ throw new Error(
+ `RouteRef alias '${alias}' for ${currentRef} points to a SubRouteRef, which is not supported`,
+ );
+ }
+ currentRef = aliasRef;
+ } else {
+ return currentRef;
+ }
+ }
+ throw new Error(`Alias loop detected for ${routeRef}`);
+ };
+
+ return resolver as RouteAliasResolver;
+}
+
+/**
+ * Creates a route alias resolver that resolves aliases based on a map of route refs to their aliases
+ * @internal
+ */
+export function createExactRouteAliasResolver(
+ routeAliases: Map,
+): RouteAliasResolver {
+ const resolver = (routeRef?: RouteRef) => {
+ if (routeRef && routeAliases.has(routeRef)) {
+ return routeAliases.get(routeRef);
+ }
+ return routeRef;
+ };
+ return resolver as RouteAliasResolver;
+}
diff --git a/packages/frontend-app-api/src/routing/RouteResolver.test.ts b/packages/frontend-app-api/src/routing/RouteResolver.test.ts
index 497e7397cb..fb5d75decf 100644
--- a/packages/frontend-app-api/src/routing/RouteResolver.test.ts
+++ b/packages/frontend-app-api/src/routing/RouteResolver.test.ts
@@ -25,6 +25,10 @@ import {
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { RouteResolver } from './RouteResolver';
import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode';
+import {
+ createExactRouteAliasResolver,
+ createRouteAliasResolver,
+} from './RouteAliasResolver';
const rest = {
element: null,
@@ -47,9 +51,18 @@ function src(sourcePath: string) {
return { sourcePath };
}
+const emptyResolver = createExactRouteAliasResolver(new Map());
+
describe('RouteResolver', () => {
it('should not resolve anything with an empty resolver', () => {
- const r = new RouteResolver(new Map(), new Map(), [], new Map(), '');
+ const r = new RouteResolver(
+ new Map(),
+ new Map(),
+ [],
+ new Map(),
+ '',
+ emptyResolver,
+ );
expect(r.resolve(ref1, src('/'))?.()).toBe(undefined);
expect(r.resolve(ref2, src('/'))?.({ x: '1x' })).toBe(undefined);
@@ -70,6 +83,7 @@ describe('RouteResolver', () => {
[{ routeRefs: new Set([ref1]), path: 'my-route', ...rest }],
new Map(),
'',
+ emptyResolver,
);
expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route');
@@ -109,6 +123,7 @@ describe('RouteResolver', () => {
[externalRef2, subRef3],
]),
'',
+ emptyResolver,
);
expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route');
@@ -131,6 +146,32 @@ describe('RouteResolver', () => {
);
});
+ it('should resolve a route with an alias', () => {
+ const r = new RouteResolver(
+ new Map([[ref1, 'my-route']]),
+ new Map(),
+ [
+ {
+ routeRefs: new Set([ref1]),
+ path: 'my-route',
+ ...rest,
+ children: [],
+ },
+ ],
+ new Map(),
+ '',
+ createRouteAliasResolver({
+ routes: new Map([['test.ref1', ref1]]),
+ externalRoutes: new Map(),
+ }),
+ );
+
+ expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route');
+ expect(
+ r.resolve(createRouteRef({ aliasFor: 'test.ref1' }), src('/'))?.(),
+ ).toBe('/my-route');
+ });
+
it('should resolve the most specific match', () => {
const r = new RouteResolver(
new Map([
@@ -167,6 +208,7 @@ describe('RouteResolver', () => {
],
new Map(),
'',
+ emptyResolver,
);
expect(r.resolve(ref2, src('/'))?.({ x: 'x' })).toBe('/root/x');
@@ -222,6 +264,7 @@ describe('RouteResolver', () => {
[externalRef2, subRef3],
]),
'',
+ emptyResolver,
);
const l = '/my-grandparent/my-y/my-parent/my-x';
@@ -298,6 +341,7 @@ describe('RouteResolver', () => {
],
new Map(),
'/base',
+ emptyResolver,
);
expect(r.resolve(ref2, src('/'))?.({ x: 'a/#&?b' })).toBe(
diff --git a/packages/frontend-app-api/src/routing/RouteResolver.ts b/packages/frontend-app-api/src/routing/RouteResolver.ts
index ff7966c4db..f6f5e3805c 100644
--- a/packages/frontend-app-api/src/routing/RouteResolver.ts
+++ b/packages/frontend-app-api/src/routing/RouteResolver.ts
@@ -21,7 +21,6 @@ import {
SubRouteRef,
AnyRouteRefParams,
RouteFunc,
- RouteResolutionApiResolveOptions,
RouteResolutionApi,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
@@ -35,6 +34,7 @@ import {
} from '../../../frontend-plugin-api/src/routing/SubRouteRef';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { isExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef';
+import { RouteAliasResolver } from './RouteAliasResolver';
// Joins a list of paths together, avoiding trailing and duplicate slashes
export function joinPaths(...paths: string[]): string {
@@ -189,6 +189,7 @@ export class RouteResolver implements RouteResolutionApi {
RouteRef | SubRouteRef
>,
private readonly appBasePath: string, // base path without a trailing slash
+ private readonly routeAliasResolver: RouteAliasResolver,
) {}
resolve(
@@ -196,11 +197,13 @@ export class RouteResolver implements RouteResolutionApi {
| RouteRef
| SubRouteRef
| ExternalRouteRef,
- options?: RouteResolutionApiResolveOptions,
+ options?: { sourcePath?: string },
): RouteFunc | undefined {
// First figure out what our target absolute ref is, as well as our target path.
const [targetRef, targetPath] = resolveTargetRef(
- anyRouteRef,
+ anyRouteRef?.$$type === '@backstage/RouteRef'
+ ? this.routeAliasResolver(anyRouteRef)
+ : anyRouteRef,
this.routePaths,
this.routeBindings,
);
diff --git a/packages/frontend-app-api/src/routing/collectRouteIds.ts b/packages/frontend-app-api/src/routing/collectRouteIds.ts
index 6b0527f670..8f189bda81 100644
--- a/packages/frontend-app-api/src/routing/collectRouteIds.ts
+++ b/packages/frontend-app-api/src/routing/collectRouteIds.ts
@@ -18,6 +18,7 @@ import {
RouteRef,
SubRouteRef,
ExternalRouteRef,
+ FrontendFeature,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
@@ -29,7 +30,6 @@ import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/rou
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalSubRouteRef } from '../../../frontend-plugin-api/src/routing/SubRouteRef';
import { OpaqueFrontendPlugin } from '@internal/frontend';
-import { FrontendFeature } from '../wiring/types';
/** @internal */
export interface RouteRefsById {
diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
index 29e8767fb3..349e1db2e6 100644
--- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
+++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
@@ -38,6 +38,7 @@ import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
import { Root } from '../extensions/Root';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
+import { createRouteAliasResolver } from './RouteAliasResolver';
const ref1 = createRouteRef();
const ref2 = createRouteRef();
@@ -46,6 +47,11 @@ const ref4 = createRouteRef();
const ref5 = createRouteRef();
const refOrder: RouteRef[] = [ref1, ref2, ref3, ref4, ref5];
+const emptyRouteRefsById = {
+ routes: new Map(),
+ externalRoutes: new Map(),
+};
+
function createTestExtension(options: {
name: string;
parent?: string;
@@ -79,7 +85,10 @@ function createTestExtension(options: {
});
}
-function routeInfoFromExtensions(extensions: ExtensionDefinition[]) {
+function routeInfoFromExtensions(
+ extensions: ExtensionDefinition[],
+ routeRefsById?: Record,
+) {
const plugin = createFrontendPlugin({
pluginId: 'test',
extensions,
@@ -99,7 +108,13 @@ function routeInfoFromExtensions(extensions: ExtensionDefinition[]) {
instantiateAppNodeTree(tree.root, TestApiRegistry.from());
- return extractRouteInfoFromAppNode(tree.root);
+ return extractRouteInfoFromAppNode(
+ tree.root,
+ createRouteAliasResolver({
+ ...emptyRouteRefsById,
+ ...(routeRefsById && { routes: new Map(Object.entries(routeRefsById)) }),
+ }),
+ );
}
function sortedEntries(map: Map): [RouteRef, T][] {
@@ -618,4 +633,106 @@ describe('discovery', () => {
),
]);
});
+
+ describe('route aliases', () => {
+ it('should resolve route aliases', () => {
+ const r1 = createRouteRef();
+ const r2 = createRouteRef({ aliasFor: 'test.r3' });
+ const r3 = createRouteRef();
+
+ const info = routeInfoFromExtensions(
+ [
+ createTestExtension({
+ name: 'page1',
+ path: 'foo',
+ routeRef: createRouteRef({ aliasFor: 'test.r1' }),
+ }),
+ createTestExtension({
+ name: 'page3',
+ path: 'bar',
+ routeRef: createRouteRef({ aliasFor: 'test.r2' }),
+ }),
+ ],
+ {
+ 'test.r1': r1,
+ 'test.r2': r2,
+ 'test.r3': r3,
+ },
+ );
+
+ expect(sortedEntries(info.routePaths)).toEqual([
+ [r1, 'foo'],
+ [r3, 'bar'],
+ ]);
+ expect(sortedEntries(info.routeParents)).toEqual([
+ [r1, undefined],
+ [r3, undefined],
+ ]);
+ expect(info.routeObjects).toEqual([
+ routeObj(
+ 'foo',
+ [r1],
+ undefined,
+ undefined,
+ expect.any(Object),
+ expect.any(Object),
+ ),
+ routeObj(
+ 'bar',
+ [r3],
+ undefined,
+ undefined,
+ expect.any(Object),
+ expect.any(Object),
+ ),
+ ]);
+ });
+
+ it('should refuse to resolve aliases pointing to other plugins', () => {
+ expect(() =>
+ routeInfoFromExtensions(
+ [
+ // Source for this is the 'test' plugin
+ createTestExtension({
+ name: 'page1',
+ path: 'page1',
+ routeRef: createRouteRef({ aliasFor: 'other.root' }),
+ }),
+ ],
+
+ {
+ 'other.root': createRouteRef(),
+ },
+ ),
+ ).toThrow(
+ /Refused to resolve alias 'other.root' for RouteRef{created at 'at .*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'} as it points to a different plugin, the expected plugin is 'test' but the alias points to 'other'/,
+ );
+ });
+
+ it('should bail on infinite route alias loops', () => {
+ const loop1 = createRouteRef({ aliasFor: 'test.loop2' });
+ const loop2 = createRouteRef({ aliasFor: 'test.loop3' });
+ const loop3 = createRouteRef({ aliasFor: 'test.loop1' });
+
+ expect(() =>
+ routeInfoFromExtensions(
+ [
+ createTestExtension({
+ name: 'page1',
+ path: 'page1',
+ routeRef: createRouteRef({ aliasFor: 'test.loop1' }),
+ }),
+ ],
+
+ {
+ 'test.loop1': loop1,
+ 'test.loop2': loop2,
+ 'test.loop3': loop3,
+ },
+ ),
+ ).toThrow(
+ /Alias loop detected for RouteRef{created at 'at .*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'}/,
+ );
+ });
+ });
});
diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts
index a1abaf1599..74dfcf29a5 100644
--- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts
+++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts
@@ -18,6 +18,10 @@ import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api';
import { BackstageRouteObject } from './types';
import { AppNode } from '@backstage/frontend-plugin-api';
import { toLegacyPlugin } from './toLegacyPlugin';
+import {
+ createExactRouteAliasResolver,
+ RouteAliasResolver,
+} from './RouteAliasResolver';
// We always add a child that matches all subroutes but without any route refs. This makes
// sure that we're always able to match each route no matter how deep the navigation goes.
@@ -40,10 +44,14 @@ export function joinPaths(...paths: string[]): string {
return normalized;
}
-export function extractRouteInfoFromAppNode(node: AppNode): {
+export function extractRouteInfoFromAppNode(
+ node: AppNode,
+ routeAliasResolver: RouteAliasResolver,
+): {
routePaths: Map;
routeParents: Map;
routeObjects: BackstageRouteObject[];
+ routeAliasResolver: RouteAliasResolver;
} {
// This tracks the route path for each route ref, the value is the route path relative to the parent ref
const routePaths = new Map();
@@ -53,6 +61,9 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
// This route object tree is passed to react-router in order to be able to look up the current route
// ref or extension/source based on our current location.
const routeObjects = new Array();
+ // This tracks all resolved route aliases. By storing and re-using the resolutions here we make sure that it's not
+ // possible to pass an aliased route ref directly to the resolver, e.g. `useRouteRef(createRouteRef({ aliasFor: 'example.root' }))`
+ const routeAliases = new Map();
function visit(
current: AppNode,
@@ -65,7 +76,13 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
const routePath = current.instance
?.getData(coreExtensionData.routePath)
?.replace(/^\//, '');
- const routeRef = current.instance?.getData(coreExtensionData.routeRef);
+
+ const foundRouteRef = current.instance?.getData(coreExtensionData.routeRef);
+ const routeRef = routeAliasResolver(foundRouteRef, current.spec.plugin?.id);
+ if (foundRouteRef && routeRef !== foundRouteRef) {
+ routeAliases.set(foundRouteRef, routeRef);
+ }
+
const parentChildren = parentObj?.children ?? routeObjects;
let currentObj = parentObj;
@@ -145,5 +162,10 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
visit(node);
- return { routePaths, routeParents, routeObjects };
+ return {
+ routePaths,
+ routeParents,
+ routeObjects,
+ routeAliasResolver: createExactRouteAliasResolver(routeAliases),
+ };
}
diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
index 8801e2070e..57ec7b23f3 100644
--- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
+++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
@@ -15,15 +15,19 @@
*/
import {
- AnyExtensionDataRef,
AppNode,
Extension,
+ ExtensionDataRef,
+ ExtensionDefinition,
+ ExtensionFactoryMiddleware,
ExtensionInput,
PortableSchema,
ResolvedExtensionInput,
createExtension,
+ createExtensionBlueprint,
createExtensionDataRef,
createExtensionInput,
+ createFrontendPlugin,
} from '@backstage/frontend-plugin-api';
import {
createAppNodeInstance,
@@ -56,8 +60,7 @@ function makeSpec(
attachTo: extension.attachTo,
disabled: extension.disabled,
extension: extension as Extension,
- source: undefined,
- plugin: undefined,
+ plugin: createFrontendPlugin({ pluginId: 'app' }),
...spec,
};
}
@@ -90,7 +93,7 @@ function makeInstanceWithId(
}
function createV1ExtensionInput(
- extensionData: Record,
+ extensionData: Record,
options: { singleton?: boolean; optional?: boolean } = {},
) {
return {
@@ -108,7 +111,7 @@ function createV1Extension(opts: {
name?: string;
attachTo?: { id: string; input: string };
inputs?: Record>;
- output: Record;
+ output: Record;
configSchema?: PortableSchema;
factory: (ctx: { inputs: any; config: any }) => any;
}): Extension {
@@ -958,6 +961,127 @@ describe('instantiateAppNodeTree', () => {
);
});
+ it('should throw if extension factories do not provide an iterable object', () => {
+ function createInstance(
+ extension: ExtensionDefinition,
+ middleware?: ExtensionFactoryMiddleware,
+ ) {
+ return createAppNodeInstance({
+ extensionFactoryMiddleware: middleware,
+ apis: testApis,
+ node: makeNode(
+ resolveExtensionDefinition(extension, { namespace: 'test' }),
+ ),
+ attachments: new Map(),
+ });
+ }
+
+ const baseOpts = {
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: [testDataRef],
+ };
+
+ const badFactory = () => 'not-iterable' as any;
+ const goodFactory = () => [testDataRef('test')];
+
+ expect(() =>
+ createInstance(
+ createExtension({
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: [testDataRef],
+ factory: badFactory,
+ }),
+ ),
+ ).toThrow(
+ `Failed to instantiate extension 'test', extension factory did not provide an iterable object`,
+ );
+
+ expect(() =>
+ createInstance(
+ createExtension({
+ ...baseOpts,
+ factory: goodFactory,
+ }).override({
+ factory: badFactory,
+ }),
+ ),
+ ).toThrow(
+ `Failed to instantiate extension 'test', extension factory override did not provide an iterable object`,
+ );
+
+ // Bad middleware
+ expect(() =>
+ createInstance(
+ createExtension({
+ ...baseOpts,
+ factory: goodFactory,
+ }),
+ () => 'not-iterable' as any,
+ ),
+ ).toThrow(
+ `Failed to instantiate extension 'test', extension factory middleware did not provide an iterable object`,
+ );
+
+ expect(() =>
+ createInstance(
+ createExtensionBlueprint({
+ kind: 'test',
+ ...baseOpts,
+ factory: badFactory,
+ }).make({ params: {} }),
+ ),
+ ).toThrow(
+ `Failed to instantiate extension 'test:test', extension factory did not provide an iterable object`,
+ );
+
+ // Using makeWithOverrides
+ expect(() =>
+ createInstance(
+ createExtensionBlueprint({
+ kind: 'test',
+ ...baseOpts,
+ factory: goodFactory,
+ }).makeWithOverrides({
+ factory: badFactory,
+ }),
+ ),
+ ).toThrow(
+ `Failed to instantiate extension 'test:test', extension factory did not provide an iterable object`,
+ );
+
+ // Using makeWithOverrides and factory middleware
+ expect(() =>
+ createInstance(
+ createExtensionBlueprint({
+ kind: 'test',
+ ...baseOpts,
+ factory: goodFactory,
+ }).makeWithOverrides({
+ factory: badFactory,
+ }),
+ orig => orig(),
+ ),
+ ).toThrow(
+ `Failed to instantiate extension 'test:test', extension factory did not provide an iterable object`,
+ );
+
+ // Using makeWithOverrides and factory middleware
+ expect(() =>
+ createInstance(
+ createExtensionBlueprint({
+ kind: 'test',
+ ...baseOpts,
+ factory: badFactory,
+ }).makeWithOverrides({
+ factory: orig => orig({ params: {} }),
+ }),
+ orig => orig(),
+ ),
+ ).toThrow(
+ `Failed to instantiate extension 'test:test', original blueprint factory did not provide an iterable object`,
+ );
+ });
+
it('should forward extension factory errors', () => {
expect(() =>
createAppNodeInstance({
diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
index c6a2bae491..2c917ed5d4 100644
--- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
+++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
@@ -15,7 +15,6 @@
*/
import {
- AnyExtensionDataRef,
ApiHolder,
ExtensionDataContainer,
ExtensionDataRef,
@@ -35,7 +34,7 @@ type Mutable = {
function resolveV1InputDataMap(
dataMap: {
- [name in string]: AnyExtensionDataRef;
+ [name in string]: ExtensionDataRef;
},
attachment: AppNode,
inputName: string,
@@ -61,10 +60,10 @@ function resolveV1InputDataMap(
}
function resolveInputDataContainer(
- extensionData: Array,
+ extensionData: Array,
attachment: AppNode,
inputName: string,
-): { node: AppNode } & ExtensionDataContainer {
+): { node: AppNode } & ExtensionDataContainer {
const dataMap = new Map();
for (const ref of extensionData) {
@@ -105,7 +104,7 @@ function resolveInputDataContainer(
};
}
},
- } as { node: AppNode } & ExtensionDataContainer;
+ } as { node: AppNode } & ExtensionDataContainer;
}
function reportUndeclaredAttachments(
@@ -141,7 +140,7 @@ function resolveV1Inputs(
[inputName in string]: {
$$type: '@backstage/ExtensionInput';
extensionData: {
- [name in string]: AnyExtensionDataRef;
+ [name in string]: ExtensionDataRef;
};
config: { optional: boolean; singleton: boolean };
};
@@ -194,14 +193,14 @@ function resolveV1Inputs(
function resolveV2Inputs(
inputMap: {
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
attachments: ReadonlyMap,
): ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}> {
@@ -236,7 +235,7 @@ function resolveV2Inputs(
);
}) as ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
@@ -310,11 +309,20 @@ export function createAppNodeInstance(options: {
inputs: context.inputs,
config: overrideContext?.config ?? context.config,
}),
+ 'extension factory',
);
}, context),
+ 'extension factory middleware',
)
: internalExtension.factory(context);
+ if (
+ typeof outputDataValues !== 'object' ||
+ !outputDataValues?.[Symbol.iterator]
+ ) {
+ throw new Error('extension factory did not provide an iterable object');
+ }
+
const outputDataMap = new Map();
for (const value of outputDataValues) {
if (outputDataMap.has(value.id)) {
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
index 979c851a47..83abcf3dc4 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
@@ -68,6 +68,9 @@ describe('resolveAppNodeSpecs', () => {
extension: a,
attachTo: { id: 'root', input: 'default' },
disabled: true,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
]);
});
@@ -87,12 +90,18 @@ describe('resolveAppNodeSpecs', () => {
extension: a,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'b',
extension: b,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
]);
});
@@ -120,6 +129,9 @@ describe('resolveAppNodeSpecs', () => {
extension: b,
attachTo: { id: 'derp', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'test/a',
@@ -204,12 +216,18 @@ describe('resolveAppNodeSpecs', () => {
extension: b,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'a',
extension: a,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
]);
});
@@ -238,42 +256,63 @@ describe('resolveAppNodeSpecs', () => {
extension: e,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'd',
extension: d,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'c',
extension: c,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'a',
extension: a,
attachTo: { id: 'root', input: 'default' },
disabled: true,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'b',
extension: b,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'f',
extension: f,
attachTo: { id: 'root', input: 'default' },
disabled: false,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
{
id: 'g',
extension: g,
attachTo: { id: 'root', input: 'default' },
disabled: true,
+ config: undefined,
+ plugin: expect.any(Object),
+ source: expect.any(Object),
},
]);
});
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
index 5e27d571b5..f9ce7387c2 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
@@ -14,7 +14,11 @@
* limitations under the License.
*/
-import { Extension } from '@backstage/frontend-plugin-api';
+import {
+ createFrontendPlugin,
+ Extension,
+ FrontendFeature,
+} from '@backstage/frontend-plugin-api';
import { ExtensionParameters } from './readAppExtensionsConfig';
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
@@ -25,7 +29,6 @@ import {
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
-import { FrontendFeature } from '../wiring/types';
/** @internal */
export function resolveAppNodeSpecs(options: {
@@ -88,6 +91,12 @@ export function resolveAppNodeSpecs(options: {
);
}
+ const appPlugin =
+ plugins.find(plugin => plugin.id === 'app') ??
+ createFrontendPlugin({
+ pluginId: 'app',
+ });
+
const configuredExtensions = [
...pluginExtensions.map(({ plugin, ...extension }) => {
const internalExtension = toInternalExtension(extension);
@@ -107,8 +116,8 @@ export function resolveAppNodeSpecs(options: {
return {
extension: internalExtension,
params: {
- source: undefined,
- plugin: undefined,
+ source: appPlugin,
+ plugin: appPlugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
config: undefined as unknown,
diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
index e93d311f8d..f1161b202e 100644
--- a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts
@@ -18,6 +18,7 @@ import {
coreExtensionData,
createExtension,
createExtensionInput,
+ createFrontendPlugin,
Extension,
} from '@backstage/frontend-plugin-api';
import { resolveAppTree } from './resolveAppTree';
@@ -37,6 +38,7 @@ const baseSpec = {
extension,
attachTo: { id: 'nonexistent', input: 'nonexistent' },
disabled: false,
+ plugin: createFrontendPlugin({ pluginId: 'app' }),
};
describe('buildAppTree', () => {
@@ -284,8 +286,20 @@ describe('buildAppTree', () => {
) as Extension;
const tree = resolveAppTree('a', [
- { attachTo: e1.attachTo, id: 'a', extension: e1, disabled: false },
- { attachTo: e2.attachTo, id: 'b', extension: e2, disabled: false },
+ {
+ attachTo: e1.attachTo,
+ id: 'a',
+ extension: e1,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
+ {
+ attachTo: e2.attachTo,
+ id: 'b',
+ extension: e2,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
]);
expect(tree.root).toMatchInlineSnapshot(`
@@ -352,9 +366,27 @@ describe('buildAppTree', () => {
) as Extension;
const tree = resolveAppTree('test-2', [
- { attachTo: e1.attachTo, id: e1.id, extension: e1, disabled: false },
- { attachTo: e2.attachTo, id: e2.id, extension: e2, disabled: false },
- { attachTo: e3.attachTo, id: e3.id, extension: e3, disabled: false },
+ {
+ attachTo: e1.attachTo,
+ id: e1.id,
+ extension: e1,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
+ {
+ attachTo: e2.attachTo,
+ id: e2.id,
+ extension: e2,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
+ {
+ attachTo: e3.attachTo,
+ id: e3.id,
+ extension: e3,
+ disabled: false,
+ plugin: baseSpec.plugin,
+ },
]);
expect(tree.nodes.get('test-3')?.edges.attachedTo?.node).toBe(
diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
index c9ff9fabd5..5622af0448 100644
--- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx
@@ -144,8 +144,8 @@ describe('createSpecializedApp', () => {
],
}),
ApiBlueprint.make({
- params: define =>
- define({
+ params: defineParams =>
+ defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () =>
@@ -253,8 +253,8 @@ describe('createSpecializedApp', () => {
pluginId: 'first',
extensions: [
ApiBlueprint.make({
- params: define =>
- define({
+ params: defineParams =>
+ defineParams({
api: analyticsApiRef,
deps: {},
factory: () => {
@@ -294,8 +294,8 @@ describe('createSpecializedApp', () => {
},
}),
ApiBlueprint.make({
- params: define =>
- define({
+ params: defineParams =>
+ defineParams({
api: analyticsApiRef,
deps: {},
factory: mockAnalyticsApi,
@@ -313,10 +313,12 @@ describe('createSpecializedApp', () => {
it('should use provided apis', async () => {
const app = createSpecializedApp({
- apis: TestApiRegistry.from([
- configApiRef,
- new ConfigReader({ anything: 'config' }),
- ]),
+ advanced: {
+ apis: TestApiRegistry.from([
+ configApiRef,
+ new ConfigReader({ anything: 'config' }),
+ ]),
+ },
features: [
createFrontendPlugin({
pluginId: 'test',
@@ -639,28 +641,30 @@ describe('createSpecializedApp', () => {
],
}),
],
- extensionFactoryMiddleware: [
- function* middleware(originalFactory, { config }) {
- const result = originalFactory({
- config: config && { text: `1-${config.text}` },
- });
- yield* result;
- const el = result.get(textDataRef);
- if (el) {
- yield textDataRef(`${el}-1`);
- }
- },
- function* middleware(originalFactory, { config }) {
- const result = originalFactory({
- config: config && { text: `2-${config.text}` },
- });
- yield* result;
- const el = result.get(textDataRef);
- if (el) {
- yield textDataRef(`${el}-2`);
- }
- },
- ],
+ advanced: {
+ extensionFactoryMiddleware: [
+ function* middleware(originalFactory, { config }) {
+ const result = originalFactory({
+ config: config && { text: `1-${config.text}` },
+ });
+ yield* result;
+ const el = result.get(textDataRef);
+ if (el) {
+ yield textDataRef(`${el}-1`);
+ }
+ },
+ function* middleware(originalFactory, { config }) {
+ const result = originalFactory({
+ config: config && { text: `2-${config.text}` },
+ });
+ yield* result;
+ const el = result.get(textDataRef);
+ if (el) {
+ yield textDataRef(`${el}-2`);
+ }
+ },
+ ],
+ },
});
const root = app.tree.root.instance!.getData(
@@ -691,7 +695,7 @@ describe('createSpecializedApp', () => {
await expect(plugin.info()).rejects.toThrow(errorMsg);
- const installedPlugin = app.tree.nodes.get('test')?.spec.source;
+ const installedPlugin = app.tree.nodes.get('test')?.spec.plugin;
expect(installedPlugin).toBeDefined();
const info = await installedPlugin?.info();
expect(info).toEqual({});
@@ -707,7 +711,7 @@ describe('createSpecializedApp', () => {
});
const app = createSpecializedApp({ features: [plugin] });
- const info = await app.tree.nodes.get('test')?.spec.source?.info();
+ const info = await app.tree.nodes.get('test')?.spec.plugin?.info();
expect(info).toMatchObject({
packageName: '@backstage/frontend-app-api',
});
@@ -730,7 +734,7 @@ describe('createSpecializedApp', () => {
});
const app = createSpecializedApp({ features: [overriddenPlugin] });
- const info = await app.tree.nodes.get('test')?.spec.source?.info();
+ const info = await app.tree.nodes.get('test')?.spec.plugin?.info();
expect(info).toMatchObject({
packageName: 'test-override',
});
@@ -754,7 +758,7 @@ describe('createSpecializedApp', () => {
});
const app = createSpecializedApp({ features: [plugin] });
- const info = await app.tree.nodes.get('test')?.spec.source?.info();
+ const info = await app.tree.nodes.get('test')?.spec.plugin?.info();
expect(info).toEqual({
packageName: '@backstage/frontend-app-api',
version: expect.any(String),
@@ -774,15 +778,17 @@ describe('createSpecializedApp', () => {
const app = createSpecializedApp({
features: [plugin],
- async pluginInfoResolver(ctx) {
- const { info } = await ctx.defaultResolver({
- packageJson: await ctx.packageJson(),
- manifest: await ctx.manifest(),
- });
- return { info: { packageName: `decorated:${info.packageName}` } };
+ advanced: {
+ pluginInfoResolver: async ctx => {
+ const { info } = await ctx.defaultResolver({
+ packageJson: await ctx.packageJson(),
+ manifest: await ctx.manifest(),
+ });
+ return { info: { packageName: `decorated:${info.packageName}` } };
+ },
},
});
- const info = await app.tree.nodes.get('test')?.spec.source?.info();
+ const info = await app.tree.nodes.get('test')?.spec.plugin?.info();
expect(info).toEqual({
packageName: 'decorated:@backstage/frontend-app-api',
});
diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx
index 51b0f14250..7059140687 100644
--- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx
@@ -25,7 +25,6 @@ import {
SubRouteRef,
AnyRouteRefParams,
RouteFunc,
- RouteResolutionApiResolveOptions,
RouteResolutionApi,
createApiFactory,
routeResolutionApiRef,
@@ -81,6 +80,7 @@ import {
createPluginInfoAttacher,
FrontendPluginInfoResolver,
} from './createPluginInfoAttacher';
+import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
function deduplicateFeatures(
allFeatures: FrontendFeature[],
@@ -128,10 +128,10 @@ class AppTreeApiProxy implements AppTreeApi {
return { tree: this.tree };
}
- getNodesByRoutePath(sourcePath: string): { nodes: AppNode[] } {
+ getNodesByRoutePath(routePath: string): { nodes: AppNode[] } {
this.checkIfInitialized();
- let path = sourcePath;
+ let path = routePath;
if (path.startsWith(this.appBasePath)) {
path = path.slice(this.appBasePath.length);
}
@@ -169,7 +169,7 @@ class RouteResolutionApiProxy implements RouteResolutionApi {
| RouteRef
| SubRouteRef
| ExternalRouteRef,
- options?: RouteResolutionApiResolveOptions,
+ options?: { sourcePath?: string },
): RouteFunc | undefined {
if (!this.#delegate) {
throw new Error(
@@ -187,6 +187,7 @@ class RouteResolutionApiProxy implements RouteResolutionApi {
routeInfo.routeObjects,
this.routeBindings,
this.appBasePath,
+ routeInfo.routeAliasResolver,
);
this.#routeObjects = routeInfo.routeObjects;
@@ -198,6 +199,75 @@ class RouteResolutionApiProxy implements RouteResolutionApi {
}
}
+/**
+ * Options for {@link createSpecializedApp}.
+ *
+ * @public
+ */
+export type CreateSpecializedAppOptions = {
+ /**
+ * The list of features to load.
+ */
+ features?: FrontendFeature[];
+
+ /**
+ * The config API implementation to use. For most normal apps, this should be
+ * specified.
+ *
+ * If none is given, a new _empty_ config will be used during startup. In
+ * later stages of the app lifecycle, the config API in the API holder will be
+ * used.
+ */
+ config?: ConfigApi;
+
+ /**
+ * Allows for the binding of plugins' external route refs within the app.
+ */
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+
+ /**
+ * Advanced, more rarely used options.
+ */
+ advanced?: {
+ /**
+ * A replacement API holder implementation to use.
+ *
+ * By default, a new API holder will be constructed automatically based on
+ * the other inputs. If you pass in a custom one here, none of that
+ * automation will take place - so you will have to take care to supply all
+ * those APIs yourself.
+ */
+ apis?: ApiHolder;
+
+ /**
+ * If set to true, the system will silently accept and move on if
+ * encountering config for extensions that do not exist. The default is to
+ * reject such config to help catch simple mistakes.
+ *
+ * This flag can be useful in some scenarios where you have a dynamic set of
+ * extensions enabled at different times, but also increases the risk of
+ * accidentally missing e.g. simple typos in your config.
+ */
+ allowUnknownExtensionConfig?: boolean;
+
+ /**
+ * Applies one or more middleware on every extension, as they are added to
+ * the application.
+ *
+ * This is an advanced use case for modifying extension data on the fly as
+ * it gets emitted by extensions being instantiated.
+ */
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware
+ | ExtensionFactoryMiddleware[];
+
+ /**
+ * Allows for customizing how plugin info is retrieved.
+ */
+ pluginInfoResolver?: FrontendPluginInfoResolver;
+ };
+};
+
/**
* Creates an empty app without any default features. This is a low-level API is
* intended for use in tests or specialized setups. Typically you want to use
@@ -205,20 +275,13 @@ class RouteResolutionApiProxy implements RouteResolutionApi {
*
* @public
*/
-export function createSpecializedApp(options?: {
- features?: FrontendFeature[];
- config?: ConfigApi;
- bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
- apis?: ApiHolder;
- extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
- flags?: { allowUnknownExtensionConfig?: boolean };
- pluginInfoResolver?: FrontendPluginInfoResolver;
-}): { apis: ApiHolder; tree: AppTree } {
+export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
+ apis: ApiHolder;
+ tree: AppTree;
+} {
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []).map(
- createPluginInfoAttacher(config, options?.pluginInfoResolver),
+ createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
);
const tree = resolveAppTree(
@@ -230,25 +293,24 @@ export function createSpecializedApp(options?: {
],
parameters: readAppExtensionsConfig(config),
forbidden: new Set(['root']),
- allowUnknownExtensionConfig: options?.flags?.allowUnknownExtensionConfig,
+ allowUnknownExtensionConfig:
+ options?.advanced?.allowUnknownExtensionConfig,
}),
);
const factories = createApiFactories({ tree });
const appBasePath = getBasePath(config);
const appTreeApi = new AppTreeApiProxy(tree, appBasePath);
+
+ const routeRefsById = collectRouteIds(features);
const routeResolutionApi = new RouteResolutionApiProxy(
- resolveRouteBindings(
- options?.bindRoutes,
- config,
- collectRouteIds(features),
- ),
+ resolveRouteBindings(options?.bindRoutes, config, routeRefsById),
appBasePath,
);
const appIdentityProxy = new AppIdentityProxy();
const apis =
- options?.apis ??
+ options?.advanced?.apis ??
createApiHolder({
factories,
staticFactories: [
@@ -285,10 +347,15 @@ export function createSpecializedApp(options?: {
instantiateAppNodeTree(
tree.root,
apis,
- mergeExtensionFactoryMiddleware(options?.extensionFactoryMiddleware),
+ mergeExtensionFactoryMiddleware(
+ options?.advanced?.extensionFactoryMiddleware,
+ ),
);
- const routeInfo = extractRouteInfoFromAppNode(tree.root);
+ const routeInfo = extractRouteInfoFromAppNode(
+ tree.root,
+ createRouteAliasResolver(routeRefsById),
+ );
routeResolutionApi.initialize(routeInfo);
appTreeApi.initialize(routeInfo);
@@ -361,6 +428,7 @@ function mergeExtensionFactoryMiddleware(
apis: ctx.apis,
config: ctxOverrides?.config ?? ctx.config,
}),
+ 'extension factory middleware',
);
}, ctx);
};
diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts
index fc52dcb7a1..782569a3ff 100644
--- a/packages/frontend-app-api/src/wiring/index.ts
+++ b/packages/frontend-app-api/src/wiring/index.ts
@@ -14,6 +14,9 @@
* limitations under the License.
*/
-export { createSpecializedApp } from './createSpecializedApp';
+export {
+ createSpecializedApp,
+ type CreateSpecializedAppOptions,
+} from './createSpecializedApp';
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
export * from './types';
diff --git a/packages/frontend-app-api/src/wiring/types.ts b/packages/frontend-app-api/src/wiring/types.ts
index 2bf08f327f..4b47c7f250 100644
--- a/packages/frontend-app-api/src/wiring/types.ts
+++ b/packages/frontend-app-api/src/wiring/types.ts
@@ -14,17 +14,13 @@
* limitations under the License.
*/
import { RouteRef } from '@backstage/frontend-plugin-api';
-import { FrontendFeature as PluginApiFrontendFeature } from '@backstage/frontend-plugin-api';
import { BackstageRouteObject } from '../routing/types';
-
-/** @public
- * @deprecated Use {@link @backstage/frontend-plugin-api#FrontendFeature} instead.
- */
-export type FrontendFeature = PluginApiFrontendFeature;
+import { RouteAliasResolver } from '../routing/RouteAliasResolver';
/** @internal */
export type RouteInfo = {
routePaths: Map;
routeParents: Map;
routeObjects: BackstageRouteObject[];
+ routeAliasResolver: RouteAliasResolver;
};
diff --git a/packages/frontend-defaults/CHANGELOG.md b/packages/frontend-defaults/CHANGELOG.md
index 21fed6ad81..27a0e8aed6 100644
--- a/packages/frontend-defaults/CHANGELOG.md
+++ b/packages/frontend-defaults/CHANGELOG.md
@@ -1,5 +1,22 @@
# @backstage/frontend-defaults
+## 0.3.0-next.2
+
+### Minor Changes
+
+- 76832a9: **BREAKING**: Removed the deprecated `CreateAppFeatureLoader` and support for it in other APIs. Switch existing usage to use the newer `createFrontendFeatureLoader` from `@backstage/frontend-plugin-api` instead.
+
+### Patch Changes
+
+- 22de964: Deprecated `createPublicSignInApp`, which has been replaced by the new `appModulePublicSignIn` from `@backstage/plugin-app/alpha` instead.
+- e4ddf22: Internal update to align with new blueprint parameter naming in the new frontend system.
+- Updated dependencies
+ - @backstage/frontend-plugin-api@0.11.0-next.1
+ - @backstage/frontend-app-api@0.12.0-next.2
+ - @backstage/plugin-app@0.2.0-next.1
+ - @backstage/config@1.3.3
+ - @backstage/errors@1.2.7
+
## 0.2.5-next.1
### Patch Changes
diff --git a/packages/frontend-defaults/knip-report.md b/packages/frontend-defaults/knip-report.md
index 2661c35327..e6696c0d8d 100644
--- a/packages/frontend-defaults/knip-report.md
+++ b/packages/frontend-defaults/knip-report.md
@@ -1,2 +1,8 @@
# Knip report
+## Unused dependencies (1)
+
+| Name | Location | Severity |
+| :--------------- | :----------- | :------- |
+| @react-hookz/web | package.json | error |
+
diff --git a/packages/frontend-defaults/package.json b/packages/frontend-defaults/package.json
index 5a2cfb6eb3..485f0f3497 100644
--- a/packages/frontend-defaults/package.json
+++ b/packages/frontend-defaults/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/frontend-defaults",
- "version": "0.2.5-next.1",
+ "version": "0.3.0-next.2",
"backstage": {
"role": "web-library"
},
diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md
index a5fe3e5f07..8042345d35 100644
--- a/packages/frontend-defaults/report.api.md
+++ b/packages/frontend-defaults/report.api.md
@@ -18,42 +18,24 @@ export function createApp(options?: CreateAppOptions): {
createRoot(): JSX_2.Element;
};
-// @public @deprecated
-export interface CreateAppFeatureLoader {
- getLoaderName(): string;
- load(options: { config: ConfigApi }): Promise<{
- features: FrontendFeature[];
- }>;
-}
-
// @public
export interface CreateAppOptions {
- // (undocumented)
- bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
- // (undocumented)
- configLoader?: () => Promise<{
- config: ConfigApi;
- }>;
- // (undocumented)
- extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
- // (undocumented)
- features?: (
- | FrontendFeature
- | FrontendFeatureLoader
- | CreateAppFeatureLoader
- )[];
- // (undocumented)
- flags?: {
+ advanced?: {
allowUnknownExtensionConfig?: boolean;
+ configLoader?: () => Promise<{
+ config: ConfigApi;
+ }>;
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware
+ | ExtensionFactoryMiddleware[];
+ loadingComponent?: ReactNode;
+ pluginInfoResolver?: FrontendPluginInfoResolver;
};
- loadingComponent?: ReactNode;
- // (undocumented)
- pluginInfoResolver?: FrontendPluginInfoResolver;
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+ features?: (FrontendFeature | FrontendFeatureLoader)[];
}
-// @public
+// @public @deprecated (undocumented)
export function createPublicSignInApp(options?: CreateAppOptions): {
createRoot(): JSX_2;
};
@@ -66,11 +48,7 @@ export function discoverAvailableFeatures(config: Config): {
// @public (undocumented)
export function resolveAsyncFeatures(options: {
config: Config;
- features?: (
- | FrontendFeature
- | FrontendFeatureLoader
- | CreateAppFeatureLoader
- )[];
+ features?: (FrontendFeature | FrontendFeatureLoader)[];
}): Promise<{
features: FrontendFeature[];
}>;
diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx
index 1d96c22ad0..4200cf92b8 100644
--- a/packages/frontend-defaults/src/createApp.test.tsx
+++ b/packages/frontend-defaults/src/createApp.test.tsx
@@ -21,13 +21,14 @@ import {
createExtension,
PageBlueprint,
createFrontendPlugin,
+ createFrontendFeatureLoader,
ThemeBlueprint,
createFrontendModule,
useAppNode,
FrontendPluginInfo,
} from '@backstage/frontend-plugin-api';
import { screen, waitFor } from '@testing-library/react';
-import { CreateAppFeatureLoader, createApp } from './createApp';
+import { createApp } from './createApp';
import { mockApis, renderWithEffects } from '@backstage/test-utils';
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
import { default as appPluginOriginal } from '@backstage/plugin-app';
@@ -44,18 +45,20 @@ describe('createApp', () => {
it('should allow themes to be installed', async () => {
const app = createApp({
- configLoader: async () => ({
- config: mockApis.config({
- data: {
- app: {
- extensions: [
- { 'theme:app/light': false },
- { 'theme:app/dark': false },
- ],
+ advanced: {
+ configLoader: async () => ({
+ config: mockApis.config({
+ data: {
+ app: {
+ extensions: [
+ { 'theme:app/light': false },
+ { 'theme:app/dark': false },
+ ],
+ },
},
- },
+ }),
}),
- }),
+ },
features: [
createFrontendPlugin({
pluginId: 'test',
@@ -84,14 +87,16 @@ describe('createApp', () => {
it('should deduplicate features keeping the last received one', async () => {
const duplicatedFeatureId = 'test';
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
createFrontendPlugin({
pluginId: duplicatedFeatureId,
extensions: [
PageBlueprint.make({
params: {
- defaultPath: '/',
+ path: '/',
loader: async () => First Page
,
},
}),
@@ -102,7 +107,7 @@ describe('createApp', () => {
extensions: [
PageBlueprint.make({
params: {
- defaultPath: '/',
+ path: '/',
loader: async () => Last Page
,
},
}),
@@ -130,14 +135,13 @@ describe('createApp', () => {
);
useEffect(() => {
- appNode?.spec.source?.info().then(setInfo);
+ appNode?.spec.plugin?.info().then(setInfo);
}, [appNode]);
return Package name: {info?.packageName}
;
}
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
features: [
appPlugin,
createFrontendPlugin({
@@ -145,19 +149,22 @@ describe('createApp', () => {
extensions: [
PageBlueprint.make({
params: {
- defaultPath: '/',
+ path: '/',
loader: async () => ,
},
}),
],
}),
],
- pluginInfoResolver: async () => {
- return {
- info: {
- packageName: '@test/test',
- },
- };
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ pluginInfoResolver: async () => {
+ return {
+ info: {
+ packageName: '@test/test',
+ },
+ };
+ },
},
});
@@ -169,33 +176,28 @@ describe('createApp', () => {
});
it('should support feature loaders', async () => {
- const loader: CreateAppFeatureLoader = {
- getLoaderName() {
- return 'test-loader';
- },
- async load({ config }) {
- return {
- features: [
- createFrontendPlugin({
- pluginId: 'test',
- extensions: [
- PageBlueprint.make({
- params: {
- defaultPath: '/',
- loader: async () => {config.getString('key')}
,
- },
- }),
- ],
+ const loader = createFrontendFeatureLoader({
+ async *loader({ config }) {
+ yield createFrontendPlugin({
+ pluginId: 'test',
+ extensions: [
+ PageBlueprint.make({
+ params: {
+ path: '/',
+ loader: async () => {config.getString('key')}
,
+ },
}),
],
- };
+ });
},
- };
+ });
const app = createApp({
- configLoader: async () => ({
- config: mockApis.config({ data: { key: 'config-value' } }),
- }),
+ advanced: {
+ configLoader: async () => ({
+ config: mockApis.config({ data: { key: 'config-value' } }),
+ }),
+ },
features: [appPlugin, loader],
});
@@ -207,32 +209,31 @@ describe('createApp', () => {
});
it('should propagate errors thrown by feature loaders', async () => {
- const loader: CreateAppFeatureLoader = {
- getLoaderName() {
- return 'test-loader';
- },
- async load() {
+ const loader = createFrontendFeatureLoader({
+ async loader() {
throw new TypeError('boom');
},
- };
+ });
const app = createApp({
- configLoader: async () => ({
- config: mockApis.config(),
- }),
+ advanced: {
+ configLoader: async () => ({
+ config: mockApis.config(),
+ }),
+ },
features: [loader],
});
- await expect(
- renderWithEffects(app.createRoot()),
- ).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Failed to read frontend features from loader 'test-loader', TypeError: boom"`,
+ await expect(renderWithEffects(app.createRoot())).rejects.toThrow(
+ /Failed to read frontend features from loader created at '.*\/createApp\.test\.tsx:\d+:\d+': TypeError: boom/,
);
});
it('should register feature flags', async () => {
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
appPlugin.withOverrides({
extensions: [
@@ -284,15 +285,6 @@ describe('createApp', () => {
it('should allow unknown extension config if the flag is set', async () => {
const app = createApp({
- configLoader: async () => ({
- config: mockApis.config({
- data: {
- app: {
- extensions: [{ 'unknown:lols/wut': false }],
- },
- },
- }),
- }),
features: [
appPlugin,
createFrontendPlugin({
@@ -300,14 +292,25 @@ describe('createApp', () => {
extensions: [
PageBlueprint.make({
params: {
- defaultPath: '/',
+ path: '/',
loader: async () => Derp
,
},
}),
],
}),
],
- flags: { allowUnknownExtensionConfig: true },
+ advanced: {
+ allowUnknownExtensionConfig: true,
+ configLoader: async () => ({
+ config: mockApis.config({
+ data: {
+ app: {
+ extensions: [{ 'unknown:lols/wut': false }],
+ },
+ },
+ }),
+ }),
+ },
});
await renderWithEffects(app.createRoot());
@@ -318,7 +321,9 @@ describe('createApp', () => {
let appTreeApi: AppTreeApi | undefined = undefined;
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
appPlugin,
createFrontendPlugin({
@@ -326,7 +331,7 @@ describe('createApp', () => {
extensions: [
PageBlueprint.make({
params: {
- defaultPath: '/',
+ path: '/',
loader: async () => {
const Component = () => {
appTreeApi = useApi(appTreeApiRef);
@@ -377,13 +382,13 @@ describe('createApp', () => {
]
-
+
components [
-
-
-
+
+
+
]
-
+
@@ -424,7 +429,9 @@ describe('createApp', () => {
it('should use "Loading..." as the default suspense fallback', async () => {
const app = createApp({
- configLoader: () => new Promise(() => {}),
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ },
});
await renderWithEffects(app.createRoot());
@@ -434,8 +441,10 @@ describe('createApp', () => {
it('should use no suspense fallback if the "loadingComponent" is null', async () => {
const app = createApp({
- configLoader: () => new Promise(() => {}),
- loadingComponent: null,
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ loadingComponent: null,
+ },
});
await renderWithEffects(app.createRoot());
@@ -445,8 +454,10 @@ describe('createApp', () => {
it('should use a custom "loadingComponent"', async () => {
const app = createApp({
- configLoader: () => new Promise(() => {}),
- loadingComponent: "Custom loading message",
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ loadingComponent: "Custom loading message",
+ },
});
await renderWithEffects(app.createRoot());
@@ -456,7 +467,9 @@ describe('createApp', () => {
it('should allow overriding the app plugin', async () => {
const app = createApp({
- configLoader: () => new Promise(() => {}),
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ },
features: [
appPlugin.withOverrides({
extensions: [
@@ -479,7 +492,6 @@ describe('createApp', () => {
it('should use a custom extensionFactoryMiddleware', async () => {
const app = createApp({
- configLoader: async () => ({ config: mockApis.config() }),
features: [
appPlugin,
createFrontendPlugin({
@@ -488,25 +500,28 @@ describe('createApp', () => {
PageBlueprint.make({
name: 'test-page',
params: {
- defaultPath: '/',
+ path: '/',
loader: async () => <>Test Page>,
},
}),
],
}),
],
- *extensionFactoryMiddleware(originalFactory, context) {
- const output = originalFactory();
- yield* output;
- const element = output.get(coreExtensionData.reactElement);
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ *extensionFactoryMiddleware(originalFactory, context) {
+ const output = originalFactory();
+ yield* output;
+ const element = output.get(coreExtensionData.reactElement);
- if (element) {
- yield coreExtensionData.reactElement(
-
- {element}
-
,
- );
- }
+ if (element) {
+ yield coreExtensionData.reactElement(
+
+ {element}
+
,
+ );
+ }
+ },
},
});
@@ -533,7 +548,9 @@ describe('createApp', () => {
});
const app = createApp({
- configLoader: () => new Promise(() => {}),
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ },
features: [mod],
});
@@ -560,7 +577,9 @@ describe('createApp', () => {
});
const app = createApp({
- configLoader: () => new Promise(() => {}),
+ advanced: {
+ configLoader: () => new Promise(() => {}),
+ },
features: [mod],
});
diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx
index ab0ddf85a7..828fbd4622 100644
--- a/packages/frontend-defaults/src/createApp.tsx
+++ b/packages/frontend-defaults/src/createApp.tsx
@@ -36,51 +36,70 @@ import appPlugin from '@backstage/plugin-app';
import { discoverAvailableFeatures } from './discovery';
import { resolveAsyncFeatures } from './resolution';
-/**
- * A source of dynamically loaded frontend features.
- *
- * @public
- * @deprecated Use the {@link @backstage/frontend-plugin-api#createFrontendFeatureLoader} function instead.
- */
-export interface CreateAppFeatureLoader {
- /**
- * Returns name of this loader. suitable for showing to users.
- */
- getLoaderName(): string;
-
- /**
- * Loads a number of features dynamically.
- */
- load(options: { config: ConfigApi }): Promise<{
- features: FrontendFeature[];
- }>;
-}
-
/**
* Options for {@link createApp}.
*
* @public
*/
export interface CreateAppOptions {
- features?: (
- | FrontendFeature
- | FrontendFeatureLoader
- | CreateAppFeatureLoader
- )[];
- configLoader?: () => Promise<{ config: ConfigApi }>;
- bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
/**
- * The component to render while loading the app (waiting for config, features, etc)
- *
- * Is the text "Loading..." by default.
- * If set to "null" then no loading fallback component is rendered. *
+ * The list of features to load.
*/
- loadingComponent?: ReactNode;
- extensionFactoryMiddleware?:
- | ExtensionFactoryMiddleware
- | ExtensionFactoryMiddleware[];
- pluginInfoResolver?: FrontendPluginInfoResolver;
- flags?: { allowUnknownExtensionConfig?: boolean };
+ features?: (FrontendFeature | FrontendFeatureLoader)[];
+
+ /**
+ * Allows for the binding of plugins' external route refs within the app.
+ */
+ bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
+
+ /**
+ * Advanced, more rarely used options.
+ */
+ advanced?: {
+ /**
+ * If set to true, the system will silently accept and move on if
+ * encountering config for extensions that do not exist. The default is to
+ * reject such config to help catch simple mistakes.
+ *
+ * This flag can be useful in some scenarios where you have a dynamic set of
+ * extensions enabled at different times, but also increases the risk of
+ * accidentally missing e.g. simple typos in your config.
+ */
+ allowUnknownExtensionConfig?: boolean;
+
+ /**
+ * Sets a custom config loader, replacing the builtin one.
+ *
+ * This can be used e.g. if you have the need to source config out of custom
+ * storages.
+ */
+ configLoader?: () => Promise<{ config: ConfigApi }>;
+
+ /**
+ * Applies one or more middleware on every extension, as they are added to
+ * the application.
+ *
+ * This is an advanced use case for modifying extension data on the fly as
+ * it gets emitted by extensions being instantiated.
+ */
+ extensionFactoryMiddleware?:
+ | ExtensionFactoryMiddleware
+ | ExtensionFactoryMiddleware[];
+
+ /**
+ * The component to render while loading the app (waiting for config,
+ * features, etc).
+ *
+ * This is the text "Loading..." by default. If set to "null" then no loading
+ * fallback component is rendered at all.
+ */
+ loadingComponent?: ReactNode;
+
+ /**
+ * Allows for customizing how plugin info is retrieved.
+ */
+ pluginInfoResolver?: FrontendPluginInfoResolver;
+ };
}
/**
@@ -91,14 +110,14 @@ export interface CreateAppOptions {
export function createApp(options?: CreateAppOptions): {
createRoot(): JSX.Element;
} {
- let suspenseFallback = options?.loadingComponent;
+ let suspenseFallback = options?.advanced?.loadingComponent;
if (suspenseFallback === undefined) {
suspenseFallback = 'Loading...';
}
async function appLoader() {
const config =
- (await options?.configLoader?.().then(c => c.config)) ??
+ (await options?.advanced?.configLoader?.().then(c => c.config)) ??
ConfigReader.fromConfigs(
overrideBaseUrlConfigs(defaultConfigLoaderSync()),
);
@@ -111,12 +130,10 @@ export function createApp(options?: CreateAppOptions): {
});
const app = createSpecializedApp({
- config,
features: [appPlugin, ...loadedFeatures],
+ config,
bindRoutes: options?.bindRoutes,
- extensionFactoryMiddleware: options?.extensionFactoryMiddleware,
- pluginInfoResolver: options?.pluginInfoResolver,
- flags: options?.flags,
+ advanced: options?.advanced,
});
const rootEl = app.tree.root.instance!.getData(
diff --git a/packages/frontend-defaults/src/createPublicSignInApp.test.tsx b/packages/frontend-defaults/src/createPublicSignInApp.test.tsx
index 58a264e81d..7e12499a1c 100644
--- a/packages/frontend-defaults/src/createPublicSignInApp.test.tsx
+++ b/packages/frontend-defaults/src/createPublicSignInApp.test.tsx
@@ -30,7 +30,9 @@ describe('createPublicSignInApp', () => {
it('should render a sign-in page', async () => {
const app = createPublicSignInApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
createFrontendModule({
pluginId: 'app',
@@ -58,7 +60,9 @@ describe('createPublicSignInApp', () => {
.mockReturnValue();
const app = createPublicSignInApp({
- configLoader: async () => ({ config: mockApis.config() }),
+ advanced: {
+ configLoader: async () => ({ config: mockApis.config() }),
+ },
features: [
createFrontendModule({
pluginId: 'app',
diff --git a/packages/frontend-defaults/src/createPublicSignInApp.tsx b/packages/frontend-defaults/src/createPublicSignInApp.tsx
index af7107681b..0015d9403f 100644
--- a/packages/frontend-defaults/src/createPublicSignInApp.tsx
+++ b/packages/frontend-defaults/src/createPublicSignInApp.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright 2024 The Backstage Authors
+ * 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.
@@ -14,94 +14,16 @@
* limitations under the License.
*/
-import {
- coreExtensionData,
- createFrontendModule,
- identityApiRef,
- useApi,
-} from '@backstage/frontend-plugin-api';
-import { useAsync, useMountEffect } from '@react-hookz/web';
+import { appModulePublicSignIn } from '@backstage/plugin-app/alpha';
import { CreateAppOptions, createApp } from './createApp';
-import appPlugin from '@backstage/plugin-app';
-
-// This is a copy of the CookieAuthRedirect component from the auth-react
-// plugin, to avoid a dependency on that package. Long-term we want this to be
-// the only implementation and remove the one in auth-react once the old frontend system is gone.
-
-// TODO(Rugvip): Should this be part of the app plugin instead? since it owns the backend part of it.
-
-/** @internal */
-export function InternalCookieAuthRedirect() {
- const identityApi = useApi(identityApiRef);
-
- const [state, actions] = useAsync(async () => {
- const { token } = await identityApi.getCredentials();
- if (!token) {
- throw new Error('Expected Backstage token in sign-in response');
- }
- return token;
- });
-
- useMountEffect(actions.execute);
-
- if (state.status === 'error' && state.error) {
- return <>An error occurred: {state.error.message}>;
- }
-
- if (state.status === 'success' && state.result) {
- return (
-
- );
- }
-
- return null;
-}
/**
- * Creates an app that is suitable for the public sign-in page, for use in the `index-public-experimental.tsx` file.
- *
- * @remarks
- *
- * This app has an override for the `app/layout` extension, which means that
- * most extension typically installed in an app will be ignored. However, you
- * can still for example install API and root element extensions.
- *
- * A typical setup of this app will only install a custom sign-in page.
- *
- * @example
- * ```ts
- * const app = createPublicSignInApp({
- * features: [signInPageModule],
- * });
- * ```
- *
* @public
+ * @deprecated Use {@link @backstage/plugin-app/alpha#appModulePublicSignIn} instead.
*/
export function createPublicSignInApp(options?: CreateAppOptions) {
return createApp({
...options,
- features: [
- ...(options?.features ?? []),
- // This is a rather than app plugin override in order for it to take precedence over any supplied app plugin override
- createFrontendModule({
- pluginId: 'app',
- extensions: [
- appPlugin.getExtension('app/layout').override({
- factory: () => [
- coreExtensionData.reactElement(),
- ],
- }),
- ],
- }),
- ],
+ features: [...(options?.features ?? []), appModulePublicSignIn],
});
}
diff --git a/packages/frontend-defaults/src/discovery.test.ts b/packages/frontend-defaults/src/discovery.test.ts
index d1738554a4..b4e595890a 100644
--- a/packages/frontend-defaults/src/discovery.test.ts
+++ b/packages/frontend-defaults/src/discovery.test.ts
@@ -27,7 +27,7 @@ Object.defineProperty(global, '__@backstage/discovered__', {
});
const config = new ConfigReader({
- app: { experimental: { packages: 'all' } },
+ app: { packages: 'all' },
});
describe('discoverAvailableFeatures', () => {
diff --git a/packages/frontend-defaults/src/discovery.ts b/packages/frontend-defaults/src/discovery.ts
index 6da6146389..71d831b58c 100644
--- a/packages/frontend-defaults/src/discovery.ts
+++ b/packages/frontend-defaults/src/discovery.ts
@@ -26,7 +26,10 @@ interface DiscoveryGlobal {
}
function readPackageDetectionConfig(config: Config) {
- const packages = config.getOptional('app.experimental.packages');
+ // The experimental key is deprecated, but supported still for backwards compatibility
+ const packages =
+ config.getOptional('app.packages') ??
+ config.getOptional('app.experimental.packages');
if (packages === undefined || packages === null) {
return undefined;
}
@@ -34,21 +37,16 @@ function readPackageDetectionConfig(config: Config) {
if (typeof packages === 'string') {
if (packages !== 'all') {
throw new Error(
- `Invalid app.experimental.packages mode, got '${packages}', expected 'all'`,
+ `Invalid app.packages mode, got '${packages}', expected 'all'`,
);
}
return {};
}
if (typeof packages !== 'object' || Array.isArray(packages)) {
- throw new Error(
- "Invalid config at 'app.experimental.packages', expected object",
- );
+ throw new Error("Invalid config at 'app.packages', expected object");
}
- const packagesConfig = new ConfigReader(
- packages,
- 'app.experimental.packages',
- );
+ const packagesConfig = new ConfigReader(packages, 'app.packages');
return {
include: packagesConfig.getOptionalStringArray('include'),
diff --git a/packages/frontend-defaults/src/index.ts b/packages/frontend-defaults/src/index.ts
index 573cc9b08c..68d4c72568 100644
--- a/packages/frontend-defaults/src/index.ts
+++ b/packages/frontend-defaults/src/index.ts
@@ -20,11 +20,7 @@
* @packageDocumentation
*/
-export {
- createApp,
- type CreateAppOptions,
- type CreateAppFeatureLoader,
-} from './createApp';
+export { createApp, type CreateAppOptions } from './createApp';
export { createPublicSignInApp } from './createPublicSignInApp';
export { discoverAvailableFeatures } from './discovery';
export { resolveAsyncFeatures } from './resolution';
diff --git a/packages/frontend-defaults/src/resolution.test.ts b/packages/frontend-defaults/src/resolution.test.ts
index b39e302b24..eb9e58f949 100644
--- a/packages/frontend-defaults/src/resolution.test.ts
+++ b/packages/frontend-defaults/src/resolution.test.ts
@@ -20,7 +20,6 @@ import {
FrontendFeatureLoader,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
-import { CreateAppFeatureLoader } from './createApp';
import { resolveAsyncFeatures } from './resolution';
import { mockApis } from '@backstage/test-utils';
@@ -42,7 +41,7 @@ describe('resolveAsyncFeatures', () => {
extensions: [
PageBlueprint.make({
params: {
- defaultPath: '/',
+ path: '/',
loader: () => new Promise(() => {}),
},
}),
@@ -71,75 +70,6 @@ describe('resolveAsyncFeatures', () => {
]);
});
- it('supports deprecated feature loaders', async () => {
- const loader: CreateAppFeatureLoader = {
- getLoaderName() {
- return 'test-loader';
- },
- async load() {
- return {
- features: [
- createFrontendPlugin({
- pluginId: 'test',
- extensions: [
- PageBlueprint.make({
- params: {
- defaultPath: '/',
- loader: () => new Promise(() => {}),
- },
- }),
- ],
- }),
- ],
- };
- },
- };
-
- const { features } = await resolveAsyncFeatures({
- config: mockApis.config(),
- features: [loader],
- });
-
- expect(features).toMatchObject([
- {
- $$type: '@backstage/FrontendPlugin',
- id: 'test',
- version: 'v1',
- extensions: [
- {
- $$type: '@backstage/Extension',
- id: 'page:test',
- version: 'v2',
- attachTo: {
- id: 'app/routes',
- input: 'routes',
- },
- },
- ],
- },
- ]);
- });
-
- it('should propagate errors thrown by deprecated feature loaders', async () => {
- const loader: CreateAppFeatureLoader = {
- getLoaderName() {
- return 'test-loader';
- },
- async load() {
- throw new TypeError('boom');
- },
- };
-
- await expect(() =>
- resolveAsyncFeatures({
- config: mockApis.config(),
- features: [loader],
- }),
- ).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Failed to read frontend features from loader 'test-loader', TypeError: boom"`,
- );
- });
-
it('supports feature loaders', async () => {
const loader: FrontendFeatureLoader = createFrontendFeatureLoader({
async loader({ config: _ }) {
@@ -149,7 +79,7 @@ describe('resolveAsyncFeatures', () => {
extensions: [
PageBlueprint.make({
params: {
- defaultPath: '/',
+ path: '/',
loader: () => new Promise(() => {}),
},
}),
diff --git a/packages/frontend-defaults/src/resolution.ts b/packages/frontend-defaults/src/resolution.ts
index 6ab26c3d1d..58f91c45be 100644
--- a/packages/frontend-defaults/src/resolution.ts
+++ b/packages/frontend-defaults/src/resolution.ts
@@ -20,40 +20,14 @@ import {
FrontendFeature,
FrontendFeatureLoader,
} from '@backstage/frontend-plugin-api';
-import { CreateAppFeatureLoader } from './createApp';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { isInternalFrontendFeatureLoader } from '../../frontend-plugin-api/src/wiring/createFrontendFeatureLoader';
/** @public */
export async function resolveAsyncFeatures(options: {
config: Config;
- features?: (
- | FrontendFeature
- | FrontendFeatureLoader
- | CreateAppFeatureLoader
- )[];
+ features?: (FrontendFeature | FrontendFeatureLoader)[];
}): Promise<{ features: FrontendFeature[] }> {
- const features: (FrontendFeature | FrontendFeatureLoader)[] = [];
-
- // Separate deprecated CreateAppFeatureLoader elements from the frontend features,
- // and manage the deprecated elements first.
- for (const item of options?.features ?? []) {
- if ('load' in item) {
- try {
- const result = await item.load({ config: options.config });
- features.push(...result.features);
- } catch (e) {
- throw new Error(
- `Failed to read frontend features from loader '${item.getLoaderName()}', ${stringifyError(
- e,
- )}`,
- );
- }
- } else {
- features.push(item);
- }
- }
-
const loadedFeatures: FrontendFeature[] = [];
const alreadyMetFeatureLoaders: FrontendFeatureLoader[] = [];
const maxRecursionDepth = 5;
@@ -96,7 +70,7 @@ export async function resolveAsyncFeatures(options: {
}
}
- await applyFeatureLoaders(features, 1);
+ await applyFeatureLoaders(options.features ?? [], 1);
return { features: loadedFeatures };
}
diff --git a/packages/frontend-dynamic-feature-loader/knip-report.md b/packages/frontend-dynamic-feature-loader/knip-report.md
index 2661c35327..3d019810db 100644
--- a/packages/frontend-dynamic-feature-loader/knip-report.md
+++ b/packages/frontend-dynamic-feature-loader/knip-report.md
@@ -1,2 +1,8 @@
# Knip report
+## Unused dependencies (1)
+
+| Name | Location | Severity |
+| :---------------- | :----------- | :------- |
+| @backstage/config | package.json | error |
+
diff --git a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx
index 4b872a5414..b2f6486766 100644
--- a/packages/frontend-dynamic-feature-loader/src/loader.test.tsx
+++ b/packages/frontend-dynamic-feature-loader/src/loader.test.tsx
@@ -127,10 +127,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -204,10 +202,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -330,10 +326,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -447,10 +441,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -540,10 +532,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -604,10 +594,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -653,10 +641,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -759,10 +745,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -889,10 +873,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -1002,10 +984,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
@@ -1112,10 +1092,8 @@ describe('dynamicFrontendFeaturesLoader', () => {
config: mockApis.config({
data: {
app: {
- experimental: {
- packages: {
- include: [],
- },
+ packages: {
+ include: [],
},
},
backend: {
diff --git a/packages/frontend-internal/knip-report.md b/packages/frontend-internal/knip-report.md
index d5272513d9..edbbb970ac 100644
--- a/packages/frontend-internal/knip-report.md
+++ b/packages/frontend-internal/knip-report.md
@@ -1,11 +1,10 @@
# Knip report
-## Unused dependencies (3)
+## Unused dependencies (2)
| Name | Location | Severity |
| :------------------------ | :----------- | :------- |
| @backstage/version-bridge | package.json | error |
-| @backstage/types | package.json | error |
| zod | package.json | error |
## Unused devDependencies (5)
diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts
index 44dfb002ad..83d037902b 100644
--- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts
+++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts
@@ -15,11 +15,11 @@
*/
import {
- AnyExtensionDataRef,
ApiHolder,
AppNode,
ExtensionAttachToSpec,
ExtensionDataValue,
+ ExtensionDataRef,
ExtensionDefinition,
ExtensionDefinitionParameters,
ExtensionInput,
@@ -43,13 +43,13 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
[inputName in string]: {
$$type: '@backstage/ExtensionInput';
extensionData: {
- [name in string]: AnyExtensionDataRef;
+ [name in string]: ExtensionDataRef;
};
config: { optional: boolean; singleton: boolean };
};
};
readonly output: {
- [name in string]: AnyExtensionDataRef;
+ [name in string]: ExtensionDataRef;
};
factory(context: {
node: AppNode;
@@ -72,18 +72,18 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
readonly configSchema?: PortableSchema;
readonly inputs: {
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
};
- readonly output: Array;
+ readonly output: Array;
factory(context: {
node: AppNode;
apis: ApiHolder;
config: object;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts
similarity index 53%
rename from packages/frontend-plugin-api/src/components/createComponentRef.test.tsx
rename to packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts
index 9a84fe08fa..2a5da3fa73 100644
--- a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx
+++ b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 The Backstage Authors
+ * 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.
@@ -14,12 +14,17 @@
* limitations under the License.
*/
-import { createComponentRef } from './createComponentRef';
+import { SwappableComponentRef } from '@backstage/frontend-plugin-api';
+import { OpaqueType } from '@internal/opaque';
-describe('createComponentRef', () => {
- it('can be created and read', () => {
- const ref = createComponentRef({ id: 'foo' });
- expect(ref.id).toBe('foo');
- expect(String(ref)).toBe('ComponentRef{id=foo}');
- });
+export const OpaqueSwappableComponentRef = OpaqueType.create<{
+ public: SwappableComponentRef;
+ versions: {
+ readonly version: 'v1';
+ readonly transformProps?: (props: object) => object;
+ readonly defaultComponent: (props: object) => JSX.Element | null;
+ };
+}>({
+ versions: ['v1'],
+ type: '@backstage/SwappableComponentRef',
});
diff --git a/packages/frontend-internal/src/wiring/createExtensionDataContainer.ts b/packages/frontend-internal/src/wiring/createExtensionDataContainer.ts
index e25846a643..34eeca75de 100644
--- a/packages/frontend-internal/src/wiring/createExtensionDataContainer.ts
+++ b/packages/frontend-internal/src/wiring/createExtensionDataContainer.ts
@@ -15,20 +15,24 @@
*/
import {
- AnyExtensionDataRef,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionDataValue,
} from '@backstage/frontend-plugin-api';
-export function createExtensionDataContainer(
+export function createExtensionDataContainer(
values: Iterable<
UData extends ExtensionDataRef
? ExtensionDataValue
: never
>,
+ contextName: string,
declaredRefs?: ExtensionDataRef[],
): ExtensionDataContainer {
+ if (typeof values !== 'object' || !values?.[Symbol.iterator]) {
+ throw new Error(`${contextName} did not provide an iterable object`);
+ }
+
const container = new Map>();
const verifyRefs =
declaredRefs && new Map(declaredRefs.map(ref => [ref.id, ref]));
diff --git a/packages/frontend-internal/src/wiring/index.ts b/packages/frontend-internal/src/wiring/index.ts
index 6a7cf35e27..b61294cb1f 100644
--- a/packages/frontend-internal/src/wiring/index.ts
+++ b/packages/frontend-internal/src/wiring/index.ts
@@ -15,5 +15,6 @@
*/
export { createExtensionDataContainer } from './createExtensionDataContainer';
+export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef';
export { OpaqueExtensionDefinition } from './InternalExtensionDefinition';
export { OpaqueFrontendPlugin } from './InternalFrontendPlugin';
diff --git a/packages/frontend-plugin-api/CHANGELOG.md b/packages/frontend-plugin-api/CHANGELOG.md
index 6e1f13fed5..4a4202ed81 100644
--- a/packages/frontend-plugin-api/CHANGELOG.md
+++ b/packages/frontend-plugin-api/CHANGELOG.md
@@ -1,5 +1,108 @@
# @backstage/frontend-plugin-api
+## 0.11.0-next.1
+
+### Minor Changes
+
+- c5f88b5: **BREAKING**: Remove deprecated `source` property from the `AppNodeSpec` type, use `AppNodeSpec.plugin` instead.
+- e4ddf22: **BREAKING**: The `defaultPath` param of `PageBlueprint` has been renamed to `path`. This change does not affect the compatibility of extensions created with older versions of this blueprint.
+- 37f2989: **BREAKING**: Removed the `routable` property from `ExtensionBoundary`. This property was never needed in practice and is instead inferred from whether or not the extension outputs a route reference. It can be safely removed.
+- 3243fa6: **BREAKING**: Removed the ability to define a default extension `name` in blueprints. This option had no practical purpose as blueprints already use the `kind` to identity the source of the extension.
+- a082429: **BREAKING**: The separate `RouteResolutionApiResolveOptions` type has been removed.
+- 5d31d66: **BREAKING**: In an attempt to align some of the API's around providing components to `Blueprints`, we've renamed the parameters for both the `RouterBlueprint` and `AppRootWrapperBlueprint` from `Component` to `component`.
+
+ ```tsx
+ // old
+ RouterBlueprint.make({
+ params: {
+ Component: ({ children }) => {children}
,
+ },
+ });
+
+ // new
+ RouterBlueprint.make({
+ params: {
+ component: ({ children }) => {children}
,
+ },
+ });
+ ```
+
+ ```tsx
+ // old
+ AppRootWrapperBlueprint.make({
+ params: {
+ Component: ({ children }) => {children}
,
+ },
+ });
+
+ // new
+ AppRootWrapperBlueprint.make({
+ params: {
+ component: ({ children }) => {children}
,
+ },
+ });
+ ```
+
+ As part of this change, the type for `component` has also changed from `ComponentType>` to `(props: { children: ReactNode }) => JSX.Element | null` which is not breaking, just a little more reflective of the actual expected component.
+
+- 45ead4a: **BREAKING**: The `AnyRoutes` and `AnyExternalRoutes` types have been removed and their usage has been inlined instead.
+
+ Existing usage can be replaced according to their previous definitions:
+
+ ```ts
+ type AnyRoutes = { [name in string]: RouteRef | SubRouteRef };
+ type AnyExternalRoutes = { [name in string]: ExternalRouteRef };
+ ```
+
+- 121899a: **BREAKING**: The `element` param for `AppRootElementBlueprint` no longer accepts a component. If you are currently passing a component such as `element: () => ` or `element: MyComponent`, simply switch to `element: `.
+- a321f3b: **BREAKING**: The `CommonAnalyticsContext` has been removed, and inlined into `AnalyticsContextValue` instead.
+
+### Patch Changes
+
+- d9e00e3: Add support for a new `aliasFor` option for `createRouteRef`. This allows for the creation of a new route ref that acts as an alias for an existing route ref that is installed in the app. This is particularly useful when creating modules that override existing plugin pages, without referring to the existing plugin. For example:
+
+ ```tsx
+ export default createFrontendModule({
+ pluginId: 'catalog',
+ extensions: [
+ PageBlueprint.make({
+ params: {
+ defaultPath: '/catalog',
+ routeRef: createRouteRef({ aliasFor: 'catalog.catalogIndex' }),
+ loader: () =>
+ import('./CustomCatalogIndexPage').then(m => (
+
+ )),
+ },
+ }),
+ ],
+ });
+ ```
+
+- 93b5e38: Plugins should now use the new `AnalyticsImplementationBlueprint` to define and provide concrete analytics implementations. For example:
+
+ ```ts
+ import { AnalyticsImplementationBlueprint } from '@backstage/frontend-plugin-api';
+
+ const AcmeAnalytics = AnalyticsImplementationBlueprint.make({
+ name: 'acme-analytics',
+ params: define =>
+ define({
+ deps: { config: configApiRef },
+ factory: ({ config }) => AcmeAnalyticsImpl.fromConfig(config),
+ }),
+ });
+ ```
+
+- 948de17: Tweaked the return types from `createExtension` and `createExtensionBlueprint` to avoid the forwarding of `ConfigurableExtensionDataRef` into exported types.
+- 147482b: Updated the recommended naming of the blueprint param callback from `define` to `defineParams`, making the syntax `defineParams => defineParams(...)`.
+- 3c3c882: Added added defaults for all type parameters of `ExtensionDataRef` and deprecated `AnyExtensionDataRef`, as it is now redundant.
+- Updated dependencies
+ - @backstage/core-components@0.17.5-next.1
+ - @backstage/core-plugin-api@1.10.9
+ - @backstage/types@1.2.1
+ - @backstage/version-bridge@1.0.11
+
## 0.11.0-next.0
### Minor Changes
diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json
index 226d2ec801..38ac11275e 100644
--- a/packages/frontend-plugin-api/package.json
+++ b/packages/frontend-plugin-api/package.json
@@ -1,6 +1,6 @@
{
"name": "@backstage/frontend-plugin-api",
- "version": "0.11.0-next.0",
+ "version": "0.11.0-next.1",
"backstage": {
"role": "web-library"
},
diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md
index 8ae1960221..469733987e 100644
--- a/packages/frontend-plugin-api/report.api.md
+++ b/packages/frontend-plugin-api/report.api.md
@@ -41,6 +41,7 @@ import { ErrorApiErrorContext } from '@backstage/core-plugin-api';
import { errorApiRef } from '@backstage/core-plugin-api';
import { Expand } from '@backstage/types';
import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-plugin-api';
+import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
import { FeatureFlag } from '@backstage/core-plugin-api';
import { FeatureFlagsApi } from '@backstage/core-plugin-api';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
@@ -71,7 +72,6 @@ import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { PendingOAuthRequest } from '@backstage/core-plugin-api';
import { ProfileInfo } from '@backstage/core-plugin-api';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
-import { PropsWithChildren } from 'react';
import { ReactNode } from 'react';
import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api';
import { SessionApi } from '@backstage/core-plugin-api';
@@ -115,9 +115,12 @@ export const AnalyticsContext: (options: {
}) => JSX_2.Element;
// @public
-export type AnalyticsContextValue = CommonAnalyticsContext & {
- [param in string]: string | boolean | number | undefined;
-};
+export interface AnalyticsContextValue {
+ // (undocumented)
+ [key: string]: string | boolean | number | undefined;
+ extensionId: string;
+ pluginId: string;
+}
// @public
export type AnalyticsEvent = {
@@ -133,6 +136,44 @@ export type AnalyticsEventAttributes = {
[attribute in string]: string | boolean | number;
};
+// @public
+export type AnalyticsImplementation = {
+ captureEvent(event: AnalyticsEvent): void;
+};
+
+// @public
+export const AnalyticsImplementationBlueprint: ExtensionBlueprint<{
+ kind: 'analytics';
+ params: (
+ params: AnalyticsImplementationFactory,
+ ) => ExtensionBlueprintParams>;
+ output: ExtensionDataRef<
+ AnalyticsImplementationFactory<{}>,
+ 'core.analytics.factory',
+ {}
+ >;
+ inputs: {};
+ config: {};
+ configInput: {};
+ dataRefs: {
+ factory: ConfigurableExtensionDataRef<
+ AnalyticsImplementationFactory<{}>,
+ 'core.analytics.factory',
+ {}
+ >;
+ };
+}>;
+
+// @public (undocumented)
+export type AnalyticsImplementationFactory<
+ Deps extends {
+ [name in string]: unknown;
+ } = {},
+> = {
+ deps: TypesToApiRefs;
+ factory(deps: Deps): AnalyticsImplementation;
+};
+
// @public
export type AnalyticsTracker = {
captureEvent: (
@@ -149,19 +190,8 @@ export { AnyApiFactory };
export { AnyApiRef };
-// @public (undocumented)
-export type AnyExtensionDataRef = ExtensionDataRef<
- unknown,
- string,
- {
- optional?: true;
- }
->;
-
-// @public (undocumented)
-export type AnyExternalRoutes = {
- [name in string]: ExternalRouteRef;
-};
+// @public @deprecated (undocumented)
+export type AnyExtensionDataRef = ExtensionDataRef;
// @public
export type AnyRouteRefParams =
@@ -170,15 +200,9 @@ export type AnyRouteRefParams =
}
| undefined;
-// @public (undocumented)
-export type AnyRoutes = {
- [name in string]: RouteRef | SubRouteRef;
-};
-
// @public
export const ApiBlueprint: ExtensionBlueprint<{
kind: 'api';
- name: undefined;
params: <
TApi,
TImpl extends TApi,
@@ -186,7 +210,7 @@ export const ApiBlueprint: ExtensionBlueprint<{
>(
params: ApiFactory,
) => ExtensionBlueprintParams;
- output: ConfigurableExtensionDataRef;
+ output: ExtensionDataRef;
inputs: {};
config: {};
configInput: {};
@@ -244,19 +268,16 @@ export interface AppNodeSpec {
// (undocumented)
readonly id: string;
// (undocumented)
- readonly plugin?: FrontendPlugin;
- // @deprecated (undocumented)
- readonly source?: FrontendPlugin;
+ readonly plugin: FrontendPlugin;
}
// @public
export const AppRootElementBlueprint: ExtensionBlueprint<{
kind: 'app-root-element';
- name: undefined;
params: {
- element: JSX.Element | (() => JSX.Element);
+ element: JSX.Element;
};
- output: ConfigurableExtensionDataRef;
+ output: ExtensionDataRef;
inputs: {};
config: {};
configInput: {};
@@ -266,14 +287,12 @@ export const AppRootElementBlueprint: ExtensionBlueprint<{
// @public
export const AppRootWrapperBlueprint: ExtensionBlueprint<{
kind: 'app-root-wrapper';
- name: undefined;
params: {
- Component: ComponentType>;
+ Component?: [error: 'Use the `component` parameter instead'];
+ component: (props: { children: ReactNode }) => JSX.Element | null;
};
- output: ConfigurableExtensionDataRef<
- ComponentType<{
- children?: ReactNode | undefined;
- }>,
+ output: ExtensionDataRef<
+ (props: { children: ReactNode }) => JSX.Element | null,
'app.root.wrapper',
{}
>;
@@ -282,9 +301,7 @@ export const AppRootWrapperBlueprint: ExtensionBlueprint<{
configInput: {};
dataRefs: {
component: ConfigurableExtensionDataRef<
- ComponentType<{
- children?: ReactNode | undefined;
- }>,
+ (props: { children: ReactNode }) => JSX.Element | null,
'app.root.wrapper',
{}
>;
@@ -306,7 +323,7 @@ export interface AppTree {
// @public
export interface AppTreeApi {
- getNodesByRoutePath(sourcePath: string): {
+ getNodesByRoutePath(routePath: string): {
nodes: AppNode[];
};
getTree(): {
@@ -333,27 +350,6 @@ export { bitbucketAuthApiRef };
export { bitbucketServerAuthApiRef };
-// @public
-export type CommonAnalyticsContext = {
- pluginId: string;
- extensionId: string;
-};
-
-// @public (undocumented)
-export type ComponentRef = {
- id: string;
- T: T;
-};
-
-// @public
-export interface ComponentsApi {
- // (undocumented)
- getComponent(ref: ComponentRef): ComponentType;
-}
-
-// @public
-export const componentsApiRef: ApiRef;
-
export { ConfigApi };
export { configApiRef };
@@ -378,20 +374,6 @@ export interface ConfigurableExtensionDataRef<
>;
}
-// @public (undocumented)
-export const coreComponentRefs: {
- progress: ComponentRef;
- notFoundErrorPage: ComponentRef;
- errorBoundaryFallback: ComponentRef;
-};
-
-// @public (undocumented)
-export type CoreErrorBoundaryFallbackProps = {
- plugin?: FrontendPlugin;
- error: Error;
- resetError: () => void;
-};
-
// @public (undocumented)
export const coreExtensionData: {
reactElement: ConfigurableExtensionDataRef<
@@ -407,79 +389,16 @@ export const coreExtensionData: {
>;
};
-// @public (undocumented)
-export type CoreNotFoundErrorPageProps = {
- children?: ReactNode;
-};
-
-// @public (undocumented)
-export type CoreProgressProps = {};
-
export { createApiFactory };
export { createApiRef };
-// @public (undocumented)
-export function createComponentExtension(options: {
- ref: ComponentRef;
- name?: string;
- disabled?: boolean;
- loader:
- | {
- lazy: () => Promise>;
- }
- | {
- sync: () => ComponentType;
- };
-}): ExtensionDefinition<{
- config: {};
- configInput: {};
- output: ConfigurableExtensionDataRef<
- {
- ref: ComponentRef;
- impl: ComponentType;
- },
- 'core.component.component',
- {}
- >;
- inputs: {
- [x: string]: ExtensionInput<
- AnyExtensionDataRef,
- {
- optional: boolean;
- singleton: boolean;
- }
- >;
- };
- params: never;
- kind: 'component';
- name: string;
-}>;
-
-// @public (undocumented)
-export namespace createComponentExtension {
- const // (undocumented)
- componentDataRef: ConfigurableExtensionDataRef<
- {
- ref: ComponentRef;
- impl: ComponentType;
- },
- 'core.component.component',
- {}
- >;
-}
-
-// @public (undocumented)
-export function createComponentRef(options: {
- id: string;
-}): ComponentRef;
-
-// @public (undocumented)
+// @public
export function createExtension<
- UOutput extends AnyExtensionDataRef,
+ UOutput extends ExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{
optional: boolean;
singleton: boolean;
@@ -514,7 +433,13 @@ export function createExtension<
[key in keyof TConfigSchema]: ReturnType;
}>
>;
- output: UOutput;
+ output: UOutput extends ExtensionDataRef<
+ infer IData,
+ infer IId,
+ infer IConfig
+ >
+ ? ExtensionDataRef
+ : never;
inputs: TInputs;
params: never;
kind: string | undefined extends TKind ? undefined : TKind;
@@ -523,11 +448,11 @@ export function createExtension<
// @public
export function createExtensionBlueprint<
- TParams extends object | ExtensionBlueprintParamsDefiner,
- UOutput extends AnyExtensionDataRef,
+ TParams extends object | ExtensionBlueprintDefineParams,
+ UOutput extends ExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{
optional: boolean;
singleton: boolean;
@@ -539,14 +464,12 @@ export function createExtensionBlueprint<
},
UFactoryOutput extends ExtensionDataValue,
TKind extends string,
- TName extends string | undefined = undefined,
TDataRefs extends {
- [name in string]: AnyExtensionDataRef;
+ [name in string]: ExtensionDataRef;
} = never,
>(
options: CreateExtensionBlueprintOptions<
TKind,
- TName,
TParams,
UOutput,
TInputs,
@@ -556,9 +479,14 @@ export function createExtensionBlueprint<
>,
): ExtensionBlueprint<{
kind: TKind;
- name: TName;
params: TParams;
- output: UOutput;
+ output: UOutput extends ExtensionDataRef<
+ infer IData,
+ infer IId,
+ infer IConfig
+ >
+ ? ExtensionDataRef
+ : never;
inputs: string extends keyof TInputs ? {} : TInputs;
config: string extends keyof TConfigSchema
? {}
@@ -578,12 +506,11 @@ export function createExtensionBlueprint<
// @public (undocumented)
export type CreateExtensionBlueprintOptions<
TKind extends string,
- TName extends string | undefined,
- TParams extends object | ExtensionBlueprintParamsDefiner,
- UOutput extends AnyExtensionDataRef,
+ TParams extends object | ExtensionBlueprintDefineParams,
+ UOutput extends ExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{
optional: boolean;
singleton: boolean;
@@ -595,7 +522,7 @@ export type CreateExtensionBlueprintOptions<
},
UFactoryOutput extends ExtensionDataValue,
TDataRefs extends {
- [name in string]: AnyExtensionDataRef;
+ [name in string]: ExtensionDataRef;
},
> = {
kind: TKind;
@@ -603,15 +530,14 @@ export type CreateExtensionBlueprintOptions<
disabled?: boolean;
inputs?: TInputs;
output: Array;
- name?: TName;
config?: {
schema: TConfigSchema;
};
- defineParams?: TParams extends ExtensionBlueprintParamsDefiner
+ defineParams?: TParams extends ExtensionBlueprintDefineParams
? TParams
: 'The defineParams option must be a function if provided, see the docs for details';
factory(
- params: TParams extends ExtensionBlueprintParamsDefiner
+ params: TParams extends ExtensionBlueprintDefineParams
? ReturnType['T']
: TParams,
context: {
@@ -671,10 +597,10 @@ export function createExtensionInput<
export type CreateExtensionOptions<
TKind extends string | undefined,
TName extends string | undefined,
- UOutput extends AnyExtensionDataRef,
+ UOutput extends ExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{
optional: boolean;
singleton: boolean;
@@ -762,7 +688,7 @@ export interface CreateFrontendFeatureLoaderOptions {
>;
}
-// @public (undocumented)
+// @public
export function createFrontendModule<
TId extends string,
TExtensions extends readonly ExtensionDefinition[] = [],
@@ -781,11 +707,15 @@ export interface CreateFrontendModuleOptions<
pluginId: TPluginId;
}
-// @public (undocumented)
+// @public
export function createFrontendPlugin<
TId extends string,
- TRoutes extends AnyRoutes = {},
- TExternalRoutes extends AnyExternalRoutes = {},
+ TRoutes extends {
+ [name in string]: RouteRef | SubRouteRef;
+ } = {},
+ TExternalRoutes extends {
+ [name in string]: ExternalRouteRef;
+ } = {},
TExtensions extends readonly ExtensionDefinition[] = [],
>(
options: PluginOptions,
@@ -795,25 +725,6 @@ export function createFrontendPlugin<
MakeSortedExtensionsMap
>;
-// @public @deprecated (undocumented)
-export function createFrontendPlugin<
- TId extends string,
- TRoutes extends AnyRoutes = {},
- TExternalRoutes extends AnyExternalRoutes = {},
- TExtensions extends readonly ExtensionDefinition[] = [],
->(
- options: Omit<
- PluginOptions,
- 'pluginId'
- > & {
- id: string;
- },
-): FrontendPlugin<
- TRoutes,
- TExternalRoutes,
- MakeSortedExtensionsMap
->;
-
// @public
export function createRouteRef<
TParams extends
@@ -823,7 +734,10 @@ export function createRouteRef<
| undefined = undefined,
TParamKeys extends string = string,
>(config?: {
- readonly params: string extends TParamKeys ? (keyof TParams)[] : TParamKeys[];
+ readonly params?: string extends TParamKeys
+ ? (keyof TParams)[]
+ : TParamKeys[];
+ aliasFor?: string;
}): RouteRef<
keyof TParams extends never
? undefined
@@ -843,6 +757,32 @@ export function createSubRouteRef<
parent: RouteRef;
}): MakeSubRouteRef, ParentParams>;
+// @public
+export function createSwappableComponent<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+>(
+ options: CreateSwappableComponentOptions<
+ TInnerComponentProps,
+ TExternalComponentProps
+ >,
+): {
+ (props: TExternalComponentProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+};
+
+// @public
+export type CreateSwappableComponentOptions<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+> = {
+ id: string;
+ loader?:
+ | (() => (props: TInnerComponentProps) => JSX.Element | null)
+ | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>);
+ transformProps?: (props: TExternalComponentProps) => TInnerComponentProps;
+};
+
export { createTranslationMessages };
export { createTranslationRef };
@@ -851,14 +791,14 @@ export { createTranslationResource };
// @public
export interface DialogApi {
- show(
+ show(
elementOrComponent:
| JSX.Element
| ((props: {
dialog: DialogApiDialog;
}) => JSX.Element),
): DialogApiDialog;
- showModal(
+ showModal(
elementOrComponent:
| JSX.Element
| ((props: { dialog: DialogApiDialog }) => JSX.Element),
@@ -866,7 +806,7 @@ export interface DialogApi {
}
// @public
-export interface DialogApiDialog {
+export interface DialogApiDialog {
close(
...args: undefined extends TResult ? [result?: TResult] : [result: TResult]
): void;
@@ -893,6 +833,19 @@ export { ErrorApiErrorContext };
export { errorApiRef };
+// @public (undocumented)
+export const ErrorDisplay: {
+ (props: ErrorDisplayProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+};
+
+// @public (undocumented)
+export type ErrorDisplayProps = {
+ plugin?: FrontendPlugin;
+ error: Error;
+ resetError: () => void;
+};
+
// @public (undocumented)
export interface Extension {
// (undocumented)
@@ -926,20 +879,20 @@ export interface ExtensionBlueprint<
dataRefs: T['dataRefs'];
// (undocumented)
make<
- TNewName extends string | undefined,
+ TName extends string | undefined,
TParamsInput extends AnyParamsInput_2>,
>(args: {
- name?: TNewName;
+ name?: TName;
attachTo?: ExtensionAttachToSpec;
disabled?: boolean;
- params: TParamsInput extends ExtensionBlueprintParamsDefiner
+ params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
- : T['params'] extends ExtensionBlueprintParamsDefiner
- ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `.make({ params: define => define() })`'
- : TParamsInput;
+ : T['params'] extends ExtensionBlueprintDefineParams
+ ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `.make({ params: defineParams => defineParams() })`'
+ : T['params'];
}): ExtensionDefinition<{
kind: T['kind'];
- name: string | undefined extends TNewName ? T['name'] : TNewName;
+ name: string | undefined extends TName ? undefined : TName;
config: T['config'];
configInput: T['configInput'];
output: T['output'];
@@ -947,15 +900,15 @@ export interface ExtensionBlueprint<
params: T['params'];
}>;
makeWithOverrides<
- TNewName extends string | undefined,
+ TName extends string | undefined,
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
UFactoryOutput extends ExtensionDataValue,
- UNewOutput extends AnyExtensionDataRef,
+ UNewOutput extends ExtensionDataRef,
TExtraInputs extends {
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{
optional: boolean;
singleton: boolean;
@@ -963,7 +916,7 @@ export interface ExtensionBlueprint<
>;
},
>(args: {
- name?: TNewName;
+ name?: TName;
attachTo?: ExtensionAttachToSpec;
disabled?: boolean;
inputs?: TExtraInputs & {
@@ -981,14 +934,14 @@ export interface ExtensionBlueprint<
originalFactory: <
TParamsInput extends AnyParamsInput_2>,
>(
- params: TParamsInput extends ExtensionBlueprintParamsDefiner
+ params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
- : T['params'] extends ExtensionBlueprintParamsDefiner
- ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(define => define())`'
- : TParamsInput,
+ : T['params'] extends ExtensionBlueprintDefineParams
+ ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams())`'
+ : T['params'],
context?: {
config?: T['config'];
- inputs?: ResolveInputValueOverrides>;
+ inputs?: ResolvedInputValueOverrides>;
},
) => ExtensionDataContainer>,
context: {
@@ -1003,7 +956,7 @@ export interface ExtensionBlueprint<
},
): Iterable &
VerifyExtensionFactoryOutput<
- AnyExtensionDataRef extends UNewOutput
+ ExtensionDataRef extends UNewOutput
? NonNullable
: UNewOutput,
UFactoryOutput
@@ -1027,29 +980,34 @@ export interface ExtensionBlueprint<
}>
>) &
T['configInput'];
- output: AnyExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
+ output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: T['inputs'] & TExtraInputs;
kind: T['kind'];
- name: string | undefined extends TNewName ? T['name'] : TNewName;
+ name: string | undefined extends TName ? undefined : TName;
params: T['params'];
}>;
}
+// @public
+export type ExtensionBlueprintDefineParams<
+ TParams extends object = object,
+ TInput = any,
+> = (params: TInput) => ExtensionBlueprintParams;
+
// @public (undocumented)
export type ExtensionBlueprintParameters = {
kind: string;
- name?: string;
- params?: object | ExtensionBlueprintParamsDefiner;
+ params?: object | ExtensionBlueprintDefineParams;
configInput?: {
[K in string]: any;
};
config?: {
[K in string]: any;
};
- output?: AnyExtensionDataRef;
+ output?: ExtensionDataRef;
inputs?: {
[KName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{
optional: boolean;
singleton: boolean;
@@ -1057,7 +1015,7 @@ export type ExtensionBlueprintParameters = {
>;
};
dataRefs?: {
- [name in string]: AnyExtensionDataRef;
+ [name in string]: ExtensionDataRef;
};
};
@@ -1067,12 +1025,6 @@ export type ExtensionBlueprintParams = {
T: T;
};
-// @public
-export type ExtensionBlueprintParamsDefiner<
- TParams extends object = object,
- TInput = any,
-> = (params: TInput) => ExtensionBlueprintParams;
-
// @public (undocumented)
export function ExtensionBoundary(props: ExtensionBoundaryProps): JSX_2.Element;
@@ -1096,11 +1048,10 @@ export interface ExtensionBoundaryProps {
children: ReactNode;
// (undocumented)
node: AppNode;
- routable?: boolean;
}
// @public (undocumented)
-export type ExtensionDataContainer =
+export type ExtensionDataContainer =
Iterable<
UExtensionData extends ExtensionDataRef<
infer IData,
@@ -1123,11 +1074,13 @@ export type ExtensionDataContainer =
// @public (undocumented)
export type ExtensionDataRef<
- TData,
+ TData = unknown,
TId extends string = string,
TConfig extends {
optional?: true;
- } = {},
+ } = {
+ optional?: true;
+ },
> = {
readonly $$type: '@backstage/ExtensionDataRef';
readonly id: TId;
@@ -1159,10 +1112,10 @@ export type ExtensionDefinition<
[key in string]: (zImpl: typeof z) => z.ZodType;
},
UFactoryOutput extends ExtensionDataValue,
- UNewOutput extends AnyExtensionDataRef,
+ UNewOutput extends ExtensionDataRef,
TExtraInputs extends {
[inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{
optional: boolean;
singleton: boolean;
@@ -1195,14 +1148,14 @@ export type ExtensionDefinition<
context?: Expand<
{
config?: T['config'];
- inputs?: ResolveInputValueOverrides>;
+ inputs?: ResolvedInputValueOverrides>;
} & ([T['params']] extends [never]
? {}
: {
- params?: TFactoryParamsReturn extends ExtensionBlueprintParamsDefiner
+ params?: TFactoryParamsReturn extends ExtensionBlueprintDefineParams
? TFactoryParamsReturn
- : T['params'] extends ExtensionBlueprintParamsDefiner
- ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(define => define())`'
+ : T['params'] extends ExtensionBlueprintDefineParams
+ ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams())`'
: Partial;
})
>,
@@ -1221,15 +1174,15 @@ export type ExtensionDefinition<
} & ([T['params']] extends [never]
? {}
: {
- params?: TParamsInput extends ExtensionBlueprintParamsDefiner
+ params?: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
- : T['params'] extends ExtensionBlueprintParamsDefiner
- ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(define => define())`'
+ : T['params'] extends ExtensionBlueprintDefineParams
+ ? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams())`'
: Partial;
})
> &
VerifyExtensionFactoryOutput<
- AnyExtensionDataRef extends UNewOutput
+ ExtensionDataRef extends UNewOutput
? NonNullable
: UNewOutput,
UFactoryOutput
@@ -1237,7 +1190,7 @@ export type ExtensionDefinition<
): ExtensionDefinition<{
kind: T['kind'];
name: T['name'];
- output: AnyExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
+ output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: T['inputs'] & TExtraInputs;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
@@ -1265,24 +1218,24 @@ export type ExtensionDefinitionParameters = {
config?: {
[K in string]: any;
};
- output?: AnyExtensionDataRef;
+ output?: ExtensionDataRef;
inputs?: {
[KName in string]: ExtensionInput<
- AnyExtensionDataRef,
+ ExtensionDataRef,
{
optional: boolean;
singleton: boolean;
}
>;
};
- params?: object | ExtensionBlueprintParamsDefiner;
+ params?: object | ExtensionBlueprintDefineParams;
};
// @public (undocumented)
export type ExtensionFactoryMiddleware = (
originalFactory: (contextOverrides?: {
config?: JsonObject;
- }) => ExtensionDataContainer,
+ }) => ExtensionDataContainer,
context: {
node: AppNode;
apis: ApiHolder;
@@ -1365,8 +1318,16 @@ export interface FrontendModule {
// @public (undocumented)
export interface FrontendPlugin<
- TRoutes extends AnyRoutes = AnyRoutes,
- TExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
+ TRoutes extends {
+ [name in string]: RouteRef | SubRouteRef;
+ } = {
+ [name in string]: RouteRef | SubRouteRef;
+ },
+ TExternalRoutes extends {
+ [name in string]: ExternalRouteRef;
+ } = {
+ [name in string]: ExternalRouteRef;
+ },
TExtensionMap extends {
[id in string]: ExtensionDefinition;
} = {
@@ -1422,11 +1383,10 @@ export { googleAuthApiRef };
// @public (undocumented)
export const IconBundleBlueprint: ExtensionBlueprint<{
kind: 'icon-bundle';
- name: undefined;
params: {
icons: { [key in string]: IconComponent };
};
- output: ConfigurableExtensionDataRef<
+ output: ExtensionDataRef<
{
[x: string]: IconComponent;
},
@@ -1472,11 +1432,10 @@ export { microsoftAuthApiRef };
// @public
export const NavContentBlueprint: ExtensionBlueprint_2<{
kind: 'nav-content';
- name: undefined;
params: {
component: NavContentComponent;
};
- output: ConfigurableExtensionDataRef_2<
+ output: ExtensionDataRef_2<
NavContentComponent,
'core.nav-content.component',
{}
@@ -1512,13 +1471,12 @@ export interface NavContentComponentProps {
// @public
export const NavItemBlueprint: ExtensionBlueprint<{
kind: 'nav-item';
- name: undefined;
params: {
title: string;
icon: IconComponent_3;
routeRef: RouteRef;
};
- output: ConfigurableExtensionDataRef<
+ output: ExtensionDataRef<
{
title: string;
icon: IconComponent_3;
@@ -1543,6 +1501,17 @@ export const NavItemBlueprint: ExtensionBlueprint<{
};
}>;
+// @public (undocumented)
+export const NotFoundErrorPage: {
+ (props: NotFoundErrorPageProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+};
+
+// @public (undocumented)
+export type NotFoundErrorPageProps = {
+ children?: ReactNode;
+};
+
export { OAuthApi };
export { OAuthRequestApi };
@@ -1564,16 +1533,16 @@ export { OpenIdConnectApi };
// @public
export const PageBlueprint: ExtensionBlueprint<{
kind: 'page';
- name: undefined;
params: {
- defaultPath: string;
+ defaultPath?: [Error: `Use the 'path' param instead`];
+ path: string;
loader: () => Promise;
routeRef?: RouteRef;
};
output:
- | ConfigurableExtensionDataRef
- | ConfigurableExtensionDataRef
- | ConfigurableExtensionDataRef<
+ | ExtensionDataRef
+ | ExtensionDataRef
+ | ExtensionDataRef<
RouteRef,
'core.routing.ref',
{
@@ -1595,8 +1564,12 @@ export { PendingOAuthRequest };
// @public (undocumented)
export interface PluginOptions<
TId extends string,
- TRoutes extends AnyRoutes,
- TExternalRoutes extends AnyExternalRoutes,
+ TRoutes extends {
+ [name in string]: RouteRef | SubRouteRef;
+ },
+ TExternalRoutes extends {
+ [name in string]: ExternalRouteRef;
+ },
TExtensions extends readonly ExtensionDefinition[],
> {
// (undocumented)
@@ -1623,10 +1596,19 @@ export { ProfileInfo };
export { ProfileInfoApi };
+// @public (undocumented)
+export const Progress: {
+ (props: ProgressProps): JSX.Element | null;
+ ref: SwappableComponentRef;
+};
+
+// @public (undocumented)
+export type ProgressProps = {};
+
// @public
export type ResolvedExtensionInput<
TExtensionInput extends ExtensionInput,
-> = TExtensionInput['extensionData'] extends Array
+> = TExtensionInput['extensionData'] extends Array
? {
node: AppNode;
} & ExtensionDataContainer
@@ -1645,73 +1627,6 @@ export type ResolvedExtensionInputs<
: Expand | undefined>;
};
-// @public (undocumented)
-export type ResolveInputValueOverrides<
- TInputs extends {
- [inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
- {
- optional: boolean;
- singleton: boolean;
- }
- >;
- } = {
- [inputName in string]: ExtensionInput<
- AnyExtensionDataRef,
- {
- optional: boolean;
- singleton: boolean;
- }
- >;
- },
-> = Expand<
- {
- [KName in keyof TInputs as TInputs[KName] extends ExtensionInput<
- any,
- {
- optional: infer IOptional extends boolean;
- singleton: boolean;
- }
- >
- ? IOptional extends true
- ? never
- : KName
- : never]: TInputs[KName] extends ExtensionInput<
- infer IDataRefs,
- {
- optional: boolean;
- singleton: infer ISingleton extends boolean;
- }
- >
- ? ISingleton extends true
- ? Iterable>
- : Array>>
- : never;
- } & {
- [KName in keyof TInputs as TInputs[KName] extends ExtensionInput<
- any,
- {
- optional: infer IOptional extends boolean;
- singleton: boolean;
- }
- >
- ? IOptional extends true
- ? KName
- : never
- : never]?: TInputs[KName] extends ExtensionInput<
- infer IDataRefs,
- {
- optional: boolean;
- singleton: infer ISingleton extends boolean;
- }
- >
- ? ISingleton extends true
- ? Iterable>
- : Array>>
- : never;
- }
->;
-
// @public
export type RouteFunc = (
...[params]: TParams extends undefined
@@ -1722,14 +1637,12 @@ export type RouteFunc = (
// @public (undocumented)
export const RouterBlueprint: ExtensionBlueprint<{
kind: 'app-router-component';
- name: undefined;
params: {
- Component: ComponentType>;
+ Component?: [error: 'Use the `component` parameter instead'];
+ component: (props: { children: ReactNode }) => JSX.Element | null;
};
- output: ConfigurableExtensionDataRef<
- ComponentType<{
- children?: ReactNode | undefined;
- }>,
+ output: ExtensionDataRef<
+ (props: { children: ReactNode }) => JSX.Element | null,
'app.router.wrapper',
{}
>;
@@ -1738,9 +1651,7 @@ export const RouterBlueprint: ExtensionBlueprint<{
configInput: {};
dataRefs: {
component: ConfigurableExtensionDataRef<
- ComponentType<{
- children?: ReactNode | undefined;
- }>,
+ (props: { children: ReactNode }) => JSX.Element | null,
'app.router.wrapper',
{}
>;
@@ -1765,18 +1676,15 @@ export interface RouteResolutionApi {
| RouteRef
| SubRouteRef
| ExternalRouteRef,
- options?: RouteResolutionApiResolveOptions,
+ options?: {
+ sourcePath?: string;
+ },
): RouteFunc | undefined;
}
// @public
export const routeResolutionApiRef: ApiRef;
-// @public (undocumented)
-export type RouteResolutionApiResolveOptions = {
- sourcePath?: string;
-};
-
export { SessionApi };
export { SessionState };
@@ -1784,11 +1692,10 @@ export { SessionState };
// @public
export const SignInPageBlueprint: ExtensionBlueprint<{
kind: 'sign-in-page';
- name: undefined;
params: {
loader: () => Promise>;
};
- output: ConfigurableExtensionDataRef<
+ output: ExtensionDataRef<
ComponentType,
'core.sign-in-page.component',
{}
@@ -1823,14 +1730,97 @@ export interface SubRouteRef<
readonly T: TParams;
}
+// @public
+export const SwappableComponentBlueprint: ExtensionBlueprint<{
+ kind: 'component';
+ params: [>(params: {
+ component: Ref extends SwappableComponentRef<
+ any,
+ infer IExternalComponentProps
+ >
+ ? {
+ ref: Ref;
+ } & ((props: IExternalComponentProps) => JSX.Element | null)
+ : never;
+ loader: Ref extends SwappableComponentRef
+ ?
+ | (() => (props: IInnerComponentProps) => JSX.Element | null)
+ | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
+ : never;
+ }) => ExtensionBlueprintParams<{
+ component: Ref extends SwappableComponentRef<
+ any,
+ infer IExternalComponentProps
+ >
+ ? {
+ ref: Ref;
+ } & ((props: IExternalComponentProps) => JSX.Element | null)
+ : never;
+ loader: Ref extends SwappableComponentRef
+ ?
+ | (() => (props: IInnerComponentProps) => JSX.Element | null)
+ | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
+ : never;
+ }>;
+ output: ExtensionDataRef<
+ {
+ ref: SwappableComponentRef;
+ loader:
+ | (() => (props: {}) => JSX.Element | null)
+ | (() => Promise<(props: {}) => JSX.Element | null>);
+ },
+ 'core.swappableComponent',
+ {}
+ >;
+ inputs: {};
+ config: {};
+ configInput: {};
+ dataRefs: {
+ component: ConfigurableExtensionDataRef<
+ {
+ ref: SwappableComponentRef;
+ loader:
+ | (() => (props: {}) => JSX.Element | null)
+ | (() => Promise<(props: {}) => JSX.Element | null>);
+ },
+ 'core.swappableComponent',
+ {}
+ >;
+ };
+}>;
+
+// @public (undocumented)
+export type SwappableComponentRef<
+ TInnerComponentProps extends {} = {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+> = {
+ id: string;
+ TProps: TInnerComponentProps;
+ TExternalProps: TExternalComponentProps;
+ $$type: '@backstage/SwappableComponentRef';
+};
+
+// @public
+export interface SwappableComponentsApi {
+ // (undocumented)
+ getComponent<
+ TInnerComponentProps extends {},
+ TExternalComponentProps extends {} = TInnerComponentProps,
+ >(
+ ref: SwappableComponentRef,
+ ): (props: TInnerComponentProps) => JSX.Element | null;
+}
+
+// @public
+export const swappableComponentsApiRef: ApiRef;
+
// @public
export const ThemeBlueprint: ExtensionBlueprint<{
kind: 'theme';
- name: undefined;
params: {
theme: AppTheme;
};
- output: ConfigurableExtensionDataRef;
+ output: ExtensionDataRef;
inputs: {};
config: {};
configInput: {};
@@ -1842,11 +1832,10 @@ export const ThemeBlueprint: ExtensionBlueprint<{
// @public
export const TranslationBlueprint: ExtensionBlueprint<{
kind: 'translation';
- name: undefined;
params: {
resource: TranslationResource | TranslationMessages;
};
- output: ConfigurableExtensionDataRef<
+ output: ExtensionDataRef<
| TranslationResource
| TranslationMessages<
string,
@@ -1901,11 +1890,6 @@ export { useApiHolder };
// @public
export function useAppNode(): AppNode | undefined;
-// @public
-export function useComponentRef(
- ref: ComponentRef,
-): ComponentType;
-
// @public
export function useRouteRef(
routeRef:
diff --git a/packages/frontend-plugin-api/src/analytics/index.ts b/packages/frontend-plugin-api/src/analytics/index.ts
index 651e8e105e..988a4e0163 100644
--- a/packages/frontend-plugin-api/src/analytics/index.ts
+++ b/packages/frontend-plugin-api/src/analytics/index.ts
@@ -15,5 +15,5 @@
*/
export { AnalyticsContext } from './AnalyticsContext';
-export type { AnalyticsContextValue, CommonAnalyticsContext } from './types';
+export type { AnalyticsContextValue } from './types';
export { useAnalytics } from './useAnalytics';
diff --git a/packages/frontend-plugin-api/src/analytics/types.ts b/packages/frontend-plugin-api/src/analytics/types.ts
index 3e3a9ad5eb..e9628dc628 100644
--- a/packages/frontend-plugin-api/src/analytics/types.ts
+++ b/packages/frontend-plugin-api/src/analytics/types.ts
@@ -15,11 +15,11 @@
*/
/**
- * Common analytics context attributes.
+ * Analytics context envelope.
*
* @public
*/
-export type CommonAnalyticsContext = {
+export interface AnalyticsContextValue {
/**
* The nearest known parent plugin where the event was captured.
*/
@@ -29,13 +29,6 @@ export type CommonAnalyticsContext = {
* The nearest known parent extension where the event was captured.
*/
extensionId: string;
-};
-/**
- * Analytics context envelope.
- *
- * @public
- */
-export type AnalyticsContextValue = CommonAnalyticsContext & {
- [param in string]: string | boolean | number | undefined;
-};
+ [key: string]: string | boolean | number | undefined;
+}
diff --git a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts
index 15b09482b7..2040966464 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts
@@ -16,6 +16,7 @@
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
import { AnalyticsContextValue } from '../../analytics/types';
+import type { AnalyticsImplementationBlueprint } from '../../blueprints/';
/**
* Represents an event worth tracking in an analytics system that could inform
@@ -102,6 +103,26 @@ export type AnalyticsTracker = {
) => void;
};
+/**
+ * Analytics implementations are used to track user behavior in a Backstage
+ * instance.
+ *
+ * @remarks
+ *
+ * To instrument your App or Plugin, retrieve an analytics tracker using the
+ * `useAnalytics()` hook. This will return a pre-configured `AnalyticsTracker`
+ * with relevant methods for instrumentation.
+ *
+ * @public
+ */
+export type AnalyticsImplementation = {
+ /**
+ * Primary event handler responsible for compiling and forwarding events to
+ * an analytics system.
+ */
+ captureEvent(event: AnalyticsEvent): void;
+};
+
/**
* The Analytics API is used to track user behavior in a Backstage instance.
*
@@ -124,6 +145,11 @@ export type AnalyticsApi = {
/**
* The API reference of {@link AnalyticsApi}.
*
+ * @remarks
+ *
+ * To define a concrete Analytics Implementation, use
+ * {@link AnalyticsImplementationBlueprint} instead.
+ *
* @public
*/
export const analyticsApiRef: ApiRef = createApiRef({
diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
index 0c6dde3cc8..f4fd2f3a6f 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
@@ -37,11 +37,7 @@ export interface AppNodeSpec {
readonly extension: Extension;
readonly disabled: boolean;
readonly config?: unknown;
- readonly plugin?: FrontendPlugin;
- /**
- * @deprecated Use {@link AppNodeSpec.plugin} instead.
- */
- readonly source?: FrontendPlugin;
+ readonly plugin: FrontendPlugin;
}
/**
@@ -117,7 +113,7 @@ export interface AppTreeApi {
/**
* Get all nodes in the app that are mounted at a given route path.
*/
- getNodesByRoutePath(sourcePath: string): { nodes: AppNode[] };
+ getNodesByRoutePath(routePath: string): { nodes: AppNode[] };
}
/**
diff --git a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts
deleted file mode 100644
index c5e9d2c44f..0000000000
--- a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright 2023 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 { ComponentType } from 'react';
-import { createApiRef, useApi } from '@backstage/core-plugin-api';
-import { ComponentRef } from '../../components';
-
-/**
- * API for looking up components based on component refs.
- *
- * @public
- */
-export interface ComponentsApi {
- // TODO: Should component refs also provide the default implementation so that we're guaranteed to get a component?
- getComponent(ref: ComponentRef): ComponentType;
-}
-
-/**
- * The `ApiRef` of {@link ComponentsApi}.
- *
- * @public
- */
-export const componentsApiRef = createApiRef({
- id: 'core.components',
-});
-
-/**
- * @public
- * Returns the component associated with the given ref.
- */
-export function useComponentRef(
- ref: ComponentRef,
-): ComponentType {
- const componentsApi = useApi(componentsApiRef);
- return componentsApi.getComponent(ref);
-}
diff --git a/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts
index 71cd70d3aa..801ec88c69 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/DialogApi.ts
@@ -25,7 +25,7 @@ import { createApiRef } from '@backstage/core-plugin-api';
*
* @public
*/
-export interface DialogApiDialog {
+export interface DialogApiDialog {
/**
* Closes the dialog with that provided result.
*
@@ -109,7 +109,7 @@ export interface DialogApi {
* @param elementOrComponent - The element or component to render in the dialog. If a component is provided, it will be provided with a `dialog` prop that contains the dialog handle.
* @public
*/
- show(
+ show]