diff --git a/.changeset/chatty-cobras-cheer.md b/.changeset/chatty-cobras-cheer.md
new file mode 100644
index 0000000000..c8203e58c0
--- /dev/null
+++ b/.changeset/chatty-cobras-cheer.md
@@ -0,0 +1,5 @@
+---
+'@backstage/config-loader': patch
+---
+
+Correctly resolve config targets into absolute paths
diff --git a/.changeset/chilly-terms-behave.md b/.changeset/chilly-terms-behave.md
new file mode 100644
index 0000000000..8ee1ad8375
--- /dev/null
+++ b/.changeset/chilly-terms-behave.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search-backend-module-stack-overflow-collator': minor
+---
+
+Extract a package for the Stack Overflow new backend system plugin.
diff --git a/.changeset/dirty-ducks-behave.md b/.changeset/dirty-ducks-behave.md
new file mode 100644
index 0000000000..c7bea8ea0a
--- /dev/null
+++ b/.changeset/dirty-ducks-behave.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-components': patch
+---
+
+Fix `RoutedTabs` so that it does not explode without tabs.
diff --git a/.changeset/flat-ducks-buy.md b/.changeset/flat-ducks-buy.md
new file mode 100644
index 0000000000..5c952faaf1
--- /dev/null
+++ b/.changeset/flat-ducks-buy.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search-backend-node': patch
+---
+
+Fix highlighting for non-string fields on the `Lunr` search engine implementation.
diff --git a/.changeset/fluffy-years-shake.md b/.changeset/fluffy-years-shake.md
new file mode 100644
index 0000000000..9612690d62
--- /dev/null
+++ b/.changeset/fluffy-years-shake.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog': patch
+---
+
+Use default extensions boundary and suspense on the alpha declarative `createCatalogFilterExtension` extension factory.
diff --git a/.changeset/giant-cycles-end.md b/.changeset/giant-cycles-end.md
new file mode 100644
index 0000000000..f69938ea25
--- /dev/null
+++ b/.changeset/giant-cycles-end.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-stack-overflow-backend': patch
+---
+
+Deprecate package in favor of the new `@backstage/plugin-search-backend-module-stack-overflow-collator` module.
+
+The search collator `requestParams` option is optional now, so its default value is `{ order: 'desc', sort: 'activity', site: 'stackoverflow' }` as defined in the `Try It` section on the [official Stack Overflow API documentation](https://api.stackexchange.com/docs/questions).
diff --git a/.changeset/hip-mugs-camp.md b/.changeset/hip-mugs-camp.md
new file mode 100644
index 0000000000..3413b5ac0a
--- /dev/null
+++ b/.changeset/hip-mugs-camp.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs-backend': patch
+---
+
+Add info about the entity when tech docs fail to build
diff --git a/.changeset/mighty-crews-attack.md b/.changeset/mighty-crews-attack.md
new file mode 100644
index 0000000000..c0b8587fe0
--- /dev/null
+++ b/.changeset/mighty-crews-attack.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog': patch
+---
+
+Initial entity page implementation for new frontend system at `/alpha`, with an overview page enabled by default and the about card available as an optional card.
diff --git a/.changeset/nice-apes-kneel.md b/.changeset/nice-apes-kneel.md
new file mode 100644
index 0000000000..1ecff9d223
--- /dev/null
+++ b/.changeset/nice-apes-kneel.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs': patch
+---
+
+Added entity page content for the new plugin exported via `/alpha`.
diff --git a/.changeset/nice-pillows-poke.md b/.changeset/nice-pillows-poke.md
new file mode 100644
index 0000000000..64034c39d2
--- /dev/null
+++ b/.changeset/nice-pillows-poke.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-stack-overflow': patch
+---
+
+Migrate package to the new Frontend system, the new module is distributed with a `/alpha` subpath.
diff --git a/.changeset/old-apricots-taste.md b/.changeset/old-apricots-taste.md
new file mode 100644
index 0000000000..544ba11536
--- /dev/null
+++ b/.changeset/old-apricots-taste.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-react': patch
+---
+
+Added new APIs at the `/alpha` subpath for creating entity page cards and content for the new frontend system.
diff --git a/.changeset/olive-paws-divide.md b/.changeset/olive-paws-divide.md
new file mode 100644
index 0000000000..688b6025bf
--- /dev/null
+++ b/.changeset/olive-paws-divide.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Updated `app.extensions` configuration schema.
diff --git a/.changeset/rich-pugs-chew.md b/.changeset/rich-pugs-chew.md
new file mode 100644
index 0000000000..6c5aa0af62
--- /dev/null
+++ b/.changeset/rich-pugs-chew.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+The experimental package detection will now ignore packages that don't make `package.json` available.
diff --git a/.changeset/sixty-tips-argue.md b/.changeset/sixty-tips-argue.md
new file mode 100644
index 0000000000..9fc965a281
--- /dev/null
+++ b/.changeset/sixty-tips-argue.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Improve the extension boundary component and create a default extension suspense component.
diff --git a/.changeset/sweet-countries-share.md b/.changeset/sweet-countries-share.md
new file mode 100644
index 0000000000..e1327a74b8
--- /dev/null
+++ b/.changeset/sweet-countries-share.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend': patch
+---
+
+Add examples for `github:webhook` scaffolder action & improve related tests
diff --git a/.changeset/tidy-camels-boil.md b/.changeset/tidy-camels-boil.md
new file mode 100644
index 0000000000..6e59f96f34
--- /dev/null
+++ b/.changeset/tidy-camels-boil.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-app-api': patch
+---
+
+Add component data `core.type` marker for `AppRouter` and `FlatRoutes`.
diff --git a/.changeset/young-days-talk.md b/.changeset/young-days-talk.md
new file mode 100644
index 0000000000..b7420c7894
--- /dev/null
+++ b/.changeset/young-days-talk.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-search-react': patch
+---
+
+Use default extensions boundary and suspense on the alpha declarative `createSearchResultListItem` extension factory.
diff --git a/docs/backend-system/architecture/02-backends.md b/docs/backend-system/architecture/02-backends.md
index 4ba76a5af3..c64024d653 100644
--- a/docs/backend-system/architecture/02-backends.md
+++ b/docs/backend-system/architecture/02-backends.md
@@ -23,7 +23,7 @@ import scaffolderPlugin from '@backstage/plugin-scaffolder-backend';
const backend = createBackend();
// Install desired features
-backend.add(import('@backstage/plugin-catalog-backend'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
// Features can also be installed using an explicit reference
backend.add(scaffolderPlugin());
diff --git a/docs/backend-system/building-backends/01-index.md b/docs/backend-system/building-backends/01-index.md
index 7d331b4ec3..c381999429 100644
--- a/docs/backend-system/building-backends/01-index.md
+++ b/docs/backend-system/building-backends/01-index.md
@@ -24,9 +24,9 @@ import { createBackend } from '@backstage/backend-defaults'; // Omitted in the e
const backend = createBackend();
-backend.add(import('@backstage/plugin-app-backend'));
-backend.add(import('@backstage/plugin-catalog-backend'));
-backend.add(import('@backstage/plugin-scaffolder-backend'));
+backend.add(import('@backstage/plugin-app-backend/alpha'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
+backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -126,8 +126,8 @@ You can now trim down the `src/index.ts` files to only include the plugins and m
```ts
const backend = createBackend();
-backend.add(import('@backstage/plugin-app-backend'));
-backend.add(import('@backstage/plugin-catalog-backend'));
+backend.add(import('@backstage/plugin-app-backend/alpha'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -139,7 +139,7 @@ And `backend-b`, don't forget to clean up dependencies in `package.json` as well
```ts
const backend = createBackend();
-backend.add(import('@backstage/plugin-scaffolder-backend'));
+backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.start();
```
diff --git a/docs/backend-system/building-backends/08-migrating.md b/docs/backend-system/building-backends/08-migrating.md
index 1a380421c1..1e30d0aa9e 100644
--- a/docs/backend-system/building-backends/08-migrating.md
+++ b/docs/backend-system/building-backends/08-migrating.md
@@ -177,11 +177,10 @@ custom API, so we use a helper function to transform that particular one.
To make additions as mentioned above to the environment, you will start to get
into the weeds of how the backend system wiring works. You'll need to have a
service reference and a service factory that performs the actual creation of
-your service. Please see [the services
-article](../architecture/03-services.md#defining-a-service) to learn how to
-create a service ref and its default factory. You can place that code directly
-in the index file for now if you want, or near the actual implementation class
-in question.
+your service. Please see [the services article](../architecture/03-services.md)
+to learn how to create a service ref and its default factory. You can place that
+code directly in the index file for now if you want, or near the actual implementation
+class in question.
In this example, we'll assume that your added environment field is named
`example`, and the created ref is named `exampleServiceRef`.
@@ -233,7 +232,7 @@ be used in its new form.
```ts title="packages/backend/src/index.ts"
const backend = createBackend();
/* highlight-add-next-line */
-backend.add(import('@backstage/plugin-app-backend'));
+backend.add(import('@backstage/plugin-app-backend/alpha'));
```
If you need to override the app package name, which otherwise defaults to `"app"`,
@@ -248,7 +247,7 @@ A basic installation of the catalog plugin looks as follows.
```ts title="packages/backend/src/index.ts"
const backend = createBackend();
/* highlight-add-start */
-backend.add(import('@backstage/plugin-catalog-backend'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -296,7 +295,7 @@ const catalogModuleCustomExtensions = createBackendModule({
/* highlight-add-end */
const backend = createBackend();
-backend.add(import('@backstage/plugin-catalog-backend'));
+backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -390,7 +389,7 @@ A basic installation of the scaffolder plugin looks as follows.
```ts title="packages/backend/src/index.ts"
const backend = createBackend();
/* highlight-add-next-line */
-backend.add(import('@backstage/plugin-scaffolder-backend'));
+backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
```
If you have other customizations made to `plugins/scaffolder.ts`, such as adding
@@ -429,7 +428,7 @@ const scaffolderModuleCustomExtensions = createBackendModule({
/* highlight-add-end */
const backend = createBackend();
-backend.add(import('@backstage/plugin-scaffolder-backend'));
+backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
/* highlight-add-next-line */
backend.add(scaffolderModuleCustomExtensions());
```
diff --git a/docs/plugins/integrating-search-into-plugins.md b/docs/plugins/integrating-search-into-plugins.md
index 7fb8e40b20..28c8b410e3 100644
--- a/docs/plugins/integrating-search-into-plugins.md
+++ b/docs/plugins/integrating-search-into-plugins.md
@@ -22,7 +22,7 @@ Imagine you have a plugin that is responsible for storing FAQ snippets in a data
The search platform provides an interface (`DocumentCollatorFactory` from package `@backstage/plugin-search-common`) that allows you to do exactly that. It works by registering each of your entries as a "document" that later represents one search result each.
-> You can always look at a working example, e.g. [StackOverflowQuestionsCollatorFactory](https://github.com/backstage/backstage/blob/master/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.ts), if you are unsure or want to follow best practices.
+> You can always look at a working example, e.g. [StackOverflowQuestionsCollatorFactory](https://github.com/backstage/backstage/blob/master/plugins/search-backend-module-stack-overflow-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts), if you are unsure or want to follow best practices.
#### 1. Install collator interface dependencies
diff --git a/docs/releases/v1.9.0-changelog.md b/docs/releases/v1.9.0-changelog.md
index df14c3d34d..07451f6d2c 100644
--- a/docs/releases/v1.9.0-changelog.md
+++ b/docs/releases/v1.9.0-changelog.md
@@ -1299,7 +1299,7 @@
- d3fea4ae0a: Internal fixes to avoid implicit usage of globals
- 3280711113: Updated dependency `msw` to `^0.49.0`.
- 9516b0c355: Added support for sending virtual pageviews on `search` events in order to enable
- Site Search functionality in GA. For more information consult [README](/plugins/analytics-module-ga/README.md#enabling-site-search)
+ Site Search functionality in GA. For more information consult [README](https://github.com/backstage/backstage/blob/master/plugins/analytics-module-ga/README.md#enabling-site-search)
- Updated dependencies
- @backstage/core-plugin-api@1.2.0
- @backstage/core-components@0.12.1
diff --git a/docs/releases/v1.9.0-next.2-changelog.md b/docs/releases/v1.9.0-next.2-changelog.md
index a410da5263..2c6d495641 100644
--- a/docs/releases/v1.9.0-next.2-changelog.md
+++ b/docs/releases/v1.9.0-next.2-changelog.md
@@ -525,7 +525,7 @@
### Patch Changes
- 9516b0c355: Added support for sending virtual pageviews on `search` events in order to enable
- Site Search functionality in GA. For more information consult [README](/plugins/analytics-module-ga/README.md#enabling-site-search)
+ Site Search functionality in GA. For more information consult [README](https://github.com/backstage/backstage/blob/master/plugins/analytics-module-ga/README.md#enabling-site-search)
- Updated dependencies
- @backstage/core-plugin-api@1.2.0-next.2
- @backstage/core-components@0.12.1-next.2
diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml
index 6f6081fece..0c75e1c96c 100644
--- a/packages/app-next/app-config.yaml
+++ b/packages/app-next/app-config.yaml
@@ -4,12 +4,17 @@ app:
routes:
bindings:
plugin.pages.externalRoutes.pageX: plugin.pages.routes.pageX
- # waiting for https://github.com/backstage/backstage/pull/20605
- # catalog.externalRoutes.viewTechDoc: techdocs.routes.docRoot
+ plugin.catalog.externalRoutes.viewTechDoc: plugin.techdocs.routes.docRoot
extensions:
- apis.plugin.graphiql.browse.gitlab: true
+ # Entity page cards
+ - 'entity.cards.about'
+
+ # Entity page content
+ - 'entity.content.techdocs'
+
# scmAuthExtension: >-
# createScmAuthExtension({
# id: 'apis.scmAuth.addons.ghe',
diff --git a/packages/app-next/package.json b/packages/app-next/package.json
index 71d888ee50..5437c66272 100644
--- a/packages/app-next/package.json
+++ b/packages/app-next/package.json
@@ -68,7 +68,6 @@
"@backstage/plugin-search-react": "workspace:^",
"@backstage/plugin-sentry": "workspace:^",
"@backstage/plugin-shortcuts": "workspace:^",
- "@backstage/plugin-stack-overflow": "workspace:^",
"@backstage/plugin-stackstorm": "workspace:^",
"@backstage/plugin-tech-insights": "workspace:^",
"@backstage/plugin-tech-radar": "workspace:^",
diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx
index 4e42d9b268..1275722da4 100644
--- a/packages/app-next/src/App.tsx
+++ b/packages/app-next/src/App.tsx
@@ -29,11 +29,8 @@ import {
createExtension,
createApiExtension,
createExtensionOverrides,
- createPageExtension,
} from '@backstage/frontend-plugin-api';
-import { entityRouteRef } from '@backstage/plugin-catalog-react';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
-import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { homePage } from './HomePage';
import { collectLegacyRoutes } from '@backstage/core-compat-api';
import { FlatRoutes } from '@backstage/core-app-api';
@@ -75,13 +72,6 @@ TODO:
/* app.tsx */
-const entityPageExtension = createPageExtension({
- id: 'catalog:entity',
- defaultPath: '/catalog/:namespace/:kind/:name',
- routeRef: convertLegacyRouteRef(entityRouteRef),
- loader: async () =>
Just a temporary mocked entity page
,
-});
-
const homePageExtension = createExtension({
id: 'myhomepage',
attachTo: { id: 'home', input: 'props' },
@@ -122,12 +112,7 @@ const app = createApp({
homePlugin,
...collectedLegacyPlugins,
createExtensionOverrides({
- extensions: [
- entityPageExtension,
- homePageExtension,
- scmAuthExtension,
- scmIntegrationApi,
- ],
+ extensions: [homePageExtension, scmAuthExtension, scmIntegrationApi],
}),
],
/* Handled through config instead */
diff --git a/packages/cli/src/lib/bundler/packageDetection.ts b/packages/cli/src/lib/bundler/packageDetection.ts
index 2095fe23e7..3aa0955c06 100644
--- a/packages/cli/src/lib/bundler/packageDetection.ts
+++ b/packages/cli/src/lib/bundler/packageDetection.ts
@@ -77,24 +77,28 @@ async function detectPackages(
return [];
}
- const depPackageJson: BackstagePackageJson = require(require.resolve(
- `${depName}/package.json`,
- { paths: [targetPath] },
- ));
- if (
- ['frontend-plugin', 'frontend-plugin-module'].includes(
- depPackageJson.backstage?.role ?? '',
- )
- ) {
- // Include alpha entry point if available. If there's no default export it will be ignored
- const exp = depPackageJson.exports;
- if (exp && typeof exp === 'object' && './alpha' in exp) {
- return [
- { name: depName, import: depName },
- { name: depName, export: './alpha', import: `${depName}/alpha` },
- ];
+ try {
+ const depPackageJson: BackstagePackageJson = require(require.resolve(
+ `${depName}/package.json`,
+ { paths: [targetPath] },
+ ));
+ if (
+ ['frontend-plugin', 'frontend-plugin-module'].includes(
+ depPackageJson.backstage?.role ?? '',
+ )
+ ) {
+ // Include alpha entry point if available. If there's no default export it will be ignored
+ const exp = depPackageJson.exports;
+ if (exp && typeof exp === 'object' && './alpha' in exp) {
+ return [
+ { name: depName, import: depName },
+ { name: depName, export: './alpha', import: `${depName}/alpha` },
+ ];
+ }
+ return [{ name: depName, import: depName }];
}
- return [{ name: depName, import: depName }];
+ } catch {
+ /* ignore packages that don't make package.json available */
}
return [];
});
diff --git a/packages/config-loader/src/sources/ConfigSources.test.ts b/packages/config-loader/src/sources/ConfigSources.test.ts
index 43feb3ca27..f095bbec42 100644
--- a/packages/config-loader/src/sources/ConfigSources.test.ts
+++ b/packages/config-loader/src/sources/ConfigSources.test.ts
@@ -95,6 +95,14 @@ describe('ConfigSources', () => {
),
).toEqual([{ name: 'FileConfigSource', path: '/config.yaml' }]);
+ expect(
+ mergeSources(
+ ConfigSources.defaultForTargets({
+ targets: [{ type: 'path', target: 'config.yaml' }],
+ }),
+ ),
+ ).toEqual([{ name: 'FileConfigSource', path: resolvePath('config.yaml') }]);
+
const subFunc = async () => undefined;
expect(
mergeSources(
@@ -172,8 +180,8 @@ describe('ConfigSources', () => {
}),
),
).toEqual([
- { name: 'FileConfigSource', path: 'a.yaml' },
- { name: 'FileConfigSource', path: 'b.yaml' },
+ { name: 'FileConfigSource', path: resolvePath('a.yaml') },
+ { name: 'FileConfigSource', path: resolvePath('b.yaml') },
{ name: 'EnvConfigSource', env: { HOME: '/' } },
]);
});
diff --git a/packages/config-loader/src/sources/ConfigSources.ts b/packages/config-loader/src/sources/ConfigSources.ts
index 43123f87ea..03fc6d83d6 100644
--- a/packages/config-loader/src/sources/ConfigSources.ts
+++ b/packages/config-loader/src/sources/ConfigSources.ts
@@ -161,7 +161,7 @@ export class ConfigSources {
}
return FileConfigSource.create({
watch: options.watch,
- path: arg.target,
+ path: resolvePath(arg.target),
substitutionFunc: options.substitutionFunc,
});
});
diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx
index 9d07be1ffd..7481f6662a 100644
--- a/packages/core-app-api/src/app/AppRouter.tsx
+++ b/packages/core-app-api/src/app/AppRouter.tsx
@@ -16,6 +16,7 @@
import React, { useContext, ReactNode, ComponentType, useState } from 'react';
import {
+ attachComponentData,
ConfigApi,
configApiRef,
IdentityApi,
@@ -186,3 +187,5 @@ export function AppRouter(props: AppRouterProps) {
);
}
+
+attachComponentData(AppRouter, 'core.type', 'AppRouter');
diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx
index abb7fe9432..46c2270d0d 100644
--- a/packages/core-app-api/src/routing/FlatRoutes.tsx
+++ b/packages/core-app-api/src/routing/FlatRoutes.tsx
@@ -16,7 +16,11 @@
import React, { ReactNode, useMemo } from 'react';
import { useRoutes } from 'react-router-dom';
-import { useApp, useElementFilter } from '@backstage/core-plugin-api';
+import {
+ attachComponentData,
+ useApp,
+ useElementFilter,
+} from '@backstage/core-plugin-api';
import { isReactRouterBeta } from '../app/isReactRouterBeta';
let warned = false;
@@ -115,3 +119,5 @@ export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => {
return useRoutes(withNotFound);
};
+
+attachComponentData(FlatRoutes, 'core.type', 'FlatRoutes');
diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md
index 5175c76ac8..3dd2eb6b22 100644
--- a/packages/core-compat-api/api-report.md
+++ b/packages/core-compat-api/api-report.md
@@ -6,11 +6,18 @@
///
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
+import { ExtensionOverrides } from '@backstage/frontend-plugin-api';
+import { default as React_2 } from 'react';
// @public (undocumented)
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[];
+// @public (undocumented)
+export function convertLegacyApp(
+ rootElement: React_2.JSX.Element,
+): (ExtensionOverrides | BackstagePlugin)[];
+
// (No @packageDocumentation comment for this package)
```
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
index 486c373991..85fed83e8c 100644
--- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
+++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
@@ -30,6 +30,7 @@ describe('collectLegacyRoutes', () => {
} />
} />
} />
+ } />
,
);
@@ -85,6 +86,12 @@ describe('collectLegacyRoutes', () => {
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
+ {
+ id: 'plugin.puppetDb.page2',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'puppetdb' },
+ },
{
id: 'apis.plugin.puppetdb.service',
attachTo: { id: 'core', input: 'apis' },
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx
index 2b149e6285..8178de2876 100644
--- a/packages/core-compat-api/src/collectLegacyRoutes.tsx
+++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx
@@ -62,7 +62,10 @@ Existing tasks:
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[] {
- const results = new Array();
+ const createdPluginIds = new Map<
+ LegacyBackstagePlugin,
+ Extension[]
+ >();
React.Children.forEach(
flatRoutesElement.props.children,
@@ -93,13 +96,18 @@ export function collectLegacyRoutes(
);
const pluginId = plugin.getId();
- const path: string = route.props.path;
- const detectedExtensions = new Array>();
+ const detectedExtensions =
+ createdPluginIds.get(plugin) ?? new Array>();
+ createdPluginIds.set(plugin, detectedExtensions);
+
+ const path: string = route.props.path;
detectedExtensions.push(
createPageExtension({
- id: `plugin.${pluginId}.page`,
+ id: `plugin.${pluginId}.page${
+ detectedExtensions.length ? detectedExtensions.length + 1 : ''
+ }`,
defaultPath: path[0] === '/' ? path.slice(1) : path,
routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined,
@@ -115,23 +123,20 @@ export function collectLegacyRoutes(
),
}),
);
+ },
+ );
- detectedExtensions.push(
+ return Array.from(createdPluginIds).map(([plugin, extensions]) =>
+ createPlugin({
+ id: plugin.getId(),
+ extensions: [
+ ...extensions,
...Array.from(plugin.getApis()).map(factory =>
createApiExtension({
factory,
}),
),
- );
-
- results.push(
- createPlugin({
- id: plugin.getId(),
- extensions: detectedExtensions,
- }),
- );
- },
+ ],
+ }),
);
-
- return results;
}
diff --git a/packages/core-compat-api/src/convertLegacyApp.test.tsx b/packages/core-compat-api/src/convertLegacyApp.test.tsx
new file mode 100644
index 0000000000..0f1f6145c9
--- /dev/null
+++ b/packages/core-compat-api/src/convertLegacyApp.test.tsx
@@ -0,0 +1,129 @@
+/*
+ * 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 { AppRouter, FlatRoutes } from '@backstage/core-app-api';
+import { PuppetDbPage } from '@backstage/plugin-puppetdb';
+import { StackstormPage } from '@backstage/plugin-stackstorm';
+import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card';
+import React, { ReactNode } from 'react';
+import { Route } from 'react-router-dom';
+import { convertLegacyApp } from './convertLegacyApp';
+
+const Root = ({ children }: { children: ReactNode }) => <>{children}>;
+
+describe('convertLegacyApp', () => {
+ it('should find and extract root and routes', () => {
+ const collected = convertLegacyApp(
+ <>
+
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+ >,
+ );
+
+ expect(
+ collected.map((p: any /* TODO */) => ({
+ id: p.id,
+ extensions: p.extensions.map((e: any) => ({
+ id: e.id,
+ attachTo: e.attachTo,
+ disabled: e.disabled,
+ defaultConfig: e.configSchema?.parse({}),
+ })),
+ })),
+ ).toEqual([
+ {
+ id: 'score-card',
+ extensions: [
+ {
+ id: 'plugin.score-card.page',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'score-board' },
+ },
+ {
+ id: 'apis.plugin.scoringdata.service',
+ attachTo: { id: 'core', input: 'apis' },
+ disabled: false,
+ },
+ ],
+ },
+ {
+ id: 'stackstorm',
+ extensions: [
+ {
+ id: 'plugin.stackstorm.page',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'stackstorm' },
+ },
+ {
+ id: 'apis.plugin.stackstorm.service',
+ attachTo: { id: 'core', input: 'apis' },
+ disabled: false,
+ },
+ ],
+ },
+ {
+ id: 'puppetDb',
+ extensions: [
+ {
+ id: 'plugin.puppetDb.page',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'puppetdb' },
+ },
+ {
+ id: 'plugin.puppetDb.page2',
+ attachTo: { id: 'core.routes', input: 'routes' },
+ disabled: false,
+ defaultConfig: { path: 'puppetdb' },
+ },
+ {
+ id: 'apis.plugin.puppetdb.service',
+ attachTo: { id: 'core', input: 'apis' },
+ disabled: false,
+ },
+ ],
+ },
+ {
+ id: undefined,
+ extensions: [
+ {
+ id: 'core.layout',
+ attachTo: { id: 'core', input: 'root' },
+ disabled: false,
+ },
+ {
+ id: 'core.nav',
+ attachTo: { id: 'core.layout', input: 'nav' },
+ disabled: true,
+ },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts
new file mode 100644
index 0000000000..59cb136c65
--- /dev/null
+++ b/packages/core-compat-api/src/convertLegacyApp.ts
@@ -0,0 +1,139 @@
+/*
+ * 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 React, {
+ Children,
+ Fragment,
+ ReactElement,
+ ReactNode,
+ isValidElement,
+} from 'react';
+import {
+ BackstagePlugin,
+ ExtensionOverrides,
+ coreExtensionData,
+ createExtension,
+ createExtensionInput,
+ createExtensionOverrides,
+} from '@backstage/frontend-plugin-api';
+import { getComponentData } from '@backstage/core-plugin-api';
+import { collectLegacyRoutes } from './collectLegacyRoutes';
+
+function selectChildren(
+ rootNode: ReactNode,
+ selector?: (element: ReactElement<{ children?: ReactNode }>) => boolean,
+ strictError?: string,
+): Array> {
+ return Children.toArray(rootNode).flatMap(node => {
+ if (!isValidElement<{ children?: ReactNode }>(node)) {
+ return [];
+ }
+
+ if (node.type === Fragment) {
+ return selectChildren(node.props.children, selector, strictError);
+ }
+
+ if (selector === undefined || selector(node)) {
+ return [node];
+ }
+
+ if (strictError) {
+ throw new Error(strictError);
+ }
+
+ return selectChildren(node.props.children, selector, strictError);
+ });
+}
+
+/** @public */
+export function convertLegacyApp(
+ rootElement: React.JSX.Element,
+): (ExtensionOverrides | BackstagePlugin)[] {
+ const appRouterEls = selectChildren(
+ rootElement,
+ el => getComponentData(el, 'core.type') === 'AppRouter',
+ );
+ if (appRouterEls.length !== 1) {
+ throw new Error(
+ "Failed to convert legacy app, AppRouter element could not been found. Make sure it's at the top level of the App element tree",
+ );
+ }
+
+ const rootEls = selectChildren(
+ appRouterEls[0].props.children,
+ el =>
+ Boolean(el.props.children) &&
+ selectChildren(
+ el.props.children,
+ innerEl => getComponentData(innerEl, 'core.type') === 'FlatRoutes',
+ ).length === 1,
+ );
+ if (rootEls.length !== 1) {
+ throw new Error(
+ "Failed to convert legacy app, Root element containing FlatRoutes could not been found. Make sure it's within the AppRouter element of the App element tree",
+ );
+ }
+ const [rootEl] = rootEls;
+
+ const routesEls = selectChildren(
+ rootEls[0].props.children,
+ el => getComponentData(el, 'core.type') === 'FlatRoutes',
+ );
+ if (routesEls.length !== 1) {
+ throw new Error(
+ 'Unexpectedly failed to find FlatRoutes in app element tree',
+ );
+ }
+ const [routesEl] = routesEls;
+
+ const CoreLayoutOverride = createExtension({
+ id: 'core.layout',
+ attachTo: { id: 'core', input: 'root' },
+ inputs: {
+ content: createExtensionInput(
+ {
+ element: coreExtensionData.reactElement,
+ },
+ { singleton: true },
+ ),
+ },
+ output: {
+ element: coreExtensionData.reactElement,
+ },
+ factory({ bind, inputs }) {
+ // Clone the root element, this replaces the FlatRoutes declared in the app with out content input
+ bind({
+ element: React.cloneElement(rootEl, undefined, inputs.content.element),
+ });
+ },
+ });
+ const CoreNavOverride = createExtension({
+ id: 'core.nav',
+ attachTo: { id: 'core.layout', input: 'nav' },
+ output: {},
+ factory() {},
+ disabled: true,
+ });
+
+ const collectedRoutes = collectLegacyRoutes(routesEl);
+
+ return [
+ ...collectedRoutes,
+ createExtensionOverrides({
+ extensions: [CoreLayoutOverride, CoreNavOverride],
+ }),
+ ];
+}
diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts
index 2181b2fa9a..e5f61119a3 100644
--- a/packages/core-compat-api/src/index.ts
+++ b/packages/core-compat-api/src/index.ts
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { collectLegacyRoutes } from './collectLegacyRoutes';
+export { convertLegacyApp } from './convertLegacyApp';
diff --git a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx
index 2a0b0eae28..2c727efe1b 100644
--- a/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx
+++ b/packages/core-components/src/components/TabbedLayout/RoutedTabs.tsx
@@ -27,8 +27,8 @@ import { SubRoute } from './types';
export function useSelectedSubRoute(subRoutes: SubRoute[]): {
index: number;
- route: SubRoute;
- element: JSX.Element;
+ route?: SubRoute;
+ element?: JSX.Element;
} {
const params = useParams();
@@ -44,7 +44,7 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): {
b.path.replace(/\/\*$/, '').localeCompare(a.path.replace(/\/\*$/, '')),
);
- const element = useRoutes(sortedRoutes) ?? subRoutes[0].children;
+ const element = useRoutes(sortedRoutes) ?? subRoutes[0]?.children;
// TODO(Rugvip): Once we only support v6 stable we can always prefix
// This avoids having a double / prefix for react-router v6 beta, which in turn breaks
@@ -98,7 +98,7 @@ export function RoutedTabs(props: { routes: SubRoute[] }) {
onChange={onTabChange}
/>
-
+
{element}
>
diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts
index 9ec020fa56..28355f5376 100644
--- a/packages/frontend-app-api/config.d.ts
+++ b/packages/frontend-app-api/config.d.ts
@@ -34,17 +34,17 @@ export interface Config {
/**
* @deepVisibility frontend
*/
- extensions?:
+ extensions?: Array<
| string
| {
[extensionId: string]:
| boolean
- | string
| {
- at?: string;
- extension?: string;
+ attachTo?: { id: string; input: string };
+ disabled?: boolean;
config?: unknown;
};
- };
+ }
+ >;
};
}
diff --git a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
index 5a325dc0a0..767329da7f 100644
--- a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
+++ b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts
@@ -63,6 +63,7 @@ function resolveInputs(
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
+ // TODO: Make this a warning rather than an error
if (undeclaredAttachments.length > 0) {
throw new Error(
`received undeclared input${
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index 3ed5965c99..ac8d164fae 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -338,6 +338,10 @@ export interface ExtensionBoundaryProps {
// (undocumented)
children: ReactNode;
// (undocumented)
+ id: string;
+ // (undocumented)
+ routable?: boolean;
+ // (undocumented)
source?: BackstagePlugin;
}
diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json
index baf8cb4575..528d86e0d1 100644
--- a/packages/frontend-plugin-api/package.json
+++ b/packages/frontend-plugin-api/package.json
@@ -38,9 +38,11 @@
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"dependencies": {
+ "@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
+ "@material-ui/core": "^4.12.4",
"@types/react": "^16.13.1 || ^17.0.0",
"lodash": "^4.17.21",
"zod": "^3.21.4",
diff --git a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000000..1191b75a1d
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx
@@ -0,0 +1,80 @@
+/*
+ * 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 React, { Component, PropsWithChildren } from 'react';
+// TODO: Dependency on MUI should be removed from core packages
+import { Button } from '@material-ui/core';
+import { ErrorPanel } from '@backstage/core-components';
+import { BackstagePlugin } from '../wiring';
+
+type DefaultErrorBoundaryFallbackProps = PropsWithChildren<{
+ plugin?: BackstagePlugin;
+ error: Error;
+ resetError: () => void;
+}>;
+
+const DefaultErrorBoundaryFallback = ({
+ plugin,
+ error,
+ resetError,
+}: DefaultErrorBoundaryFallbackProps) => {
+ const title = `Error in ${plugin?.id}`;
+
+ return (
+
+
+
+ );
+};
+
+type ErrorBoundaryProps = PropsWithChildren<{ plugin?: BackstagePlugin }>;
+type ErrorBoundaryState = { error?: Error };
+
+/** @internal */
+export class ErrorBoundary extends Component<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+
+ state: ErrorBoundaryState = { error: undefined };
+
+ handleErrorReset = () => {
+ this.setState({ error: undefined });
+ };
+
+ render() {
+ const { error } = this.state;
+ const { plugin, children } = this.props;
+
+ if (error) {
+ // TODO: use a configurable error boundary fallback
+ return (
+
+ );
+ }
+
+ return children;
+ }
+}
diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx
new file mode 100644
index 0000000000..704cafda2d
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx
@@ -0,0 +1,134 @@
+/*
+ * 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 React, { useEffect } from 'react';
+import { screen, waitFor } from '@testing-library/react';
+import {
+ MockAnalyticsApi,
+ MockConfigApi,
+ TestApiProvider,
+ renderWithEffects,
+} from '@backstage/test-utils';
+import { ExtensionBoundary } from './ExtensionBoundary';
+import {
+ Extension,
+ coreExtensionData,
+ createExtension,
+ createPlugin,
+} from '../wiring';
+import { analyticsApiRef, useAnalytics } from '@backstage/core-plugin-api';
+import { createApp } from '@backstage/frontend-app-api';
+import { JsonObject } from '@backstage/types';
+import { createRouteRef } from '../routing';
+
+function renderExtensionInTestApp(
+ extension: Extension,
+ options?: {
+ config?: JsonObject;
+ },
+) {
+ const { config = {} } = options ?? {};
+
+ const app = createApp({
+ features: [
+ createPlugin({
+ id: 'plugin',
+ extensions: [extension],
+ }),
+ ],
+ configLoader: async () => new MockConfigApi(config),
+ });
+
+ return renderWithEffects(app.createRoot());
+}
+
+const wrapInBoundaryExtension = (element: JSX.Element) => {
+ const id = 'plugin.extension';
+ const routeRef = createRouteRef();
+ return createExtension({
+ id,
+ attachTo: { id: 'core.routes', input: 'routes' },
+ output: {
+ element: coreExtensionData.reactElement,
+ path: coreExtensionData.routePath,
+ routeRef: coreExtensionData.routeRef.optional(),
+ },
+ factory({ bind, source }) {
+ bind({
+ routeRef,
+ path: '/',
+ element: (
+
+ {element}
+
+ ),
+ });
+ },
+ });
+};
+
+describe('ExtensionBoundary', () => {
+ it('should render children when there is no error', async () => {
+ const text = 'Text Component';
+ const TextComponent = () => {
+ return
{text}
;
+ };
+ await renderExtensionInTestApp(wrapInBoundaryExtension());
+ await waitFor(() => expect(screen.getByText(text)).toBeInTheDocument());
+ });
+
+ it('should show app error component when an error is thrown', async () => {
+ const error = 'Something went wrong';
+ const ErrorComponent = () => {
+ throw new Error(error);
+ };
+ await renderExtensionInTestApp(wrapInBoundaryExtension());
+ await waitFor(() => expect(screen.getByText(error)).toBeInTheDocument());
+ });
+
+ it('should wrap children with analytics context', async () => {
+ const action = 'render';
+ const subject = 'analytics';
+ const analyticsApiMock = new MockAnalyticsApi();
+
+ const AnalyticsComponent = () => {
+ const analytics = useAnalytics();
+ useEffect(() => {
+ analytics.captureEvent(action, subject);
+ }, [analytics]);
+ return null;
+ };
+
+ await renderExtensionInTestApp(
+ wrapInBoundaryExtension(
+
+
+ ,
+ ),
+ );
+
+ await waitFor(() =>
+ expect(analyticsApiMock.getEvents()[0]).toMatchObject({
+ action,
+ subject,
+ context: {
+ extension: 'plugin.extension',
+ routeRef: 'unknown',
+ },
+ }),
+ );
+ });
+});
diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
index c6a218c15f..a9ea297216 100644
--- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
+++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
@@ -14,16 +14,59 @@
* limitations under the License.
*/
-import React, { ReactNode } from 'react';
+import React, { PropsWithChildren, ReactNode, useEffect } from 'react';
+import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '../wiring';
+import { ErrorBoundary } from './ErrorBoundary';
+import { ExtensionSuspense } from './ExtensionSuspense';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
+
+type RouteTrackerProps = PropsWithChildren<{
+ disableTracking?: boolean;
+}>;
+
+const RouteTracker = (props: RouteTrackerProps) => {
+ const { disableTracking, children } = props;
+ const analytics = useAnalytics();
+
+ // This event, never exposed to end-users of the analytics API,
+ // helps inform which extension metadata gets associated with a
+ // navigation event when the route navigated to is a gathered
+ // mountpoint.
+ useEffect(() => {
+ if (disableTracking) return;
+ analytics.captureEvent(routableExtensionRenderedEvent, '');
+ }, [analytics, disableTracking]);
+
+ return <>{children}>;
+};
/** @public */
export interface ExtensionBoundaryProps {
- children: ReactNode;
+ id: string;
source?: BackstagePlugin;
+ routable?: boolean;
+ children: ReactNode;
}
/** @public */
export function ExtensionBoundary(props: ExtensionBoundaryProps) {
- return <>{props.children}>;
+ const { id, source, routable, children } = props;
+
+ // Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight
+ const attributes = {
+ extension: id,
+ pluginId: source?.id,
+ };
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
}
diff --git a/packages/frontend-plugin-api/src/components/ExtensionSuspense.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionSuspense.test.tsx
new file mode 100644
index 0000000000..166c57b83b
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/ExtensionSuspense.test.tsx
@@ -0,0 +1,54 @@
+/*
+ * 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 React, { lazy } from 'react';
+import { screen, waitFor } from '@testing-library/react';
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { ExtensionSuspense } from './ExtensionSuspense';
+
+describe('ExtensionSuspense', () => {
+ it('should render the app progress component as fallback', async () => {
+ const LazyComponent = lazy(() => new Promise(() => {}));
+
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ expect(screen.getByTestId('progress')).toBeInTheDocument();
+ });
+
+ it('should render the lazy loaded children component', async () => {
+ const LazyComponent = lazy(() =>
+ Promise.resolve({ default: () =>
Lazy Component
}),
+ );
+
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ await waitFor(() =>
+ expect(screen.getByText('Lazy Component')).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/packages/frontend-plugin-api/src/components/ExtensionSuspense.tsx b/packages/frontend-plugin-api/src/components/ExtensionSuspense.tsx
new file mode 100644
index 0000000000..e80f59f09c
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/ExtensionSuspense.tsx
@@ -0,0 +1,33 @@
+/*
+ * 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 React, { ReactNode, Suspense } from 'react';
+import { useApp } from '@backstage/core-plugin-api';
+
+/** @public */
+export interface ExtensionSuspenseProps {
+ children: ReactNode;
+}
+
+/** @public */
+export function ExtensionSuspense(props: ExtensionSuspenseProps) {
+ const { children } = props;
+
+ const app = useApp();
+ const { Progress } = app.getComponents();
+
+ return }>{children};
+}
diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx
index 125bf3495d..37d97e7621 100644
--- a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx
+++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx
@@ -15,10 +15,23 @@
*/
import React from 'react';
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { useAnalytics } from '@backstage/core-plugin-api';
+import { waitFor } from '@testing-library/react';
import { PortableSchema } from '../schema';
-import { coreExtensionData, createExtensionInput } from '../wiring';
+import {
+ ExtensionInputValues,
+ coreExtensionData,
+ createExtensionInput,
+ createPlugin,
+} from '../wiring';
import { createPageExtension } from './createPageExtension';
+jest.mock('@backstage/core-plugin-api', () => ({
+ ...jest.requireActual('@backstage/core-plugin-api'),
+ useAnalytics: jest.fn(),
+}));
+
describe('createPageExtension', () => {
it('creates the extension properly', () => {
const configSchema: PortableSchema<{ path: string }> = {
@@ -100,4 +113,35 @@ describe('createPageExtension', () => {
factory: expect.any(Function),
});
});
+
+ it('capture page view event in analytics', async () => {
+ const captureEvent = jest.fn();
+
+ (useAnalytics as jest.Mock).mockReturnValue({
+ captureEvent,
+ });
+
+ const extension = createPageExtension({
+ id: 'plugin.page',
+ defaultPath: '/',
+ loader: async () =>