Merge branch 'backstage:master' into code-coverage

This commit is contained in:
Josh Uvi
2023-10-20 12:37:29 +01:00
committed by GitHub
95 changed files with 2527 additions and 586 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': patch
---
Correctly resolve config targets into absolute paths
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-stack-overflow-collator': minor
---
Extract a package for the Stack Overflow new backend system plugin.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Fix `RoutedTabs` so that it does not explode without tabs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-node': patch
---
Fix highlighting for non-string fields on the `Lunr` search engine implementation.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Use default extensions boundary and suspense on the alpha declarative `createCatalogFilterExtension` extension factory.
+7
View File
@@ -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).
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Add info about the entity when tech docs fail to build
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Added entity page content for the new plugin exported via `/alpha`.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Updated `app.extensions` configuration schema.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
The experimental package detection will now ignore packages that don't make `package.json` available.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Improve the extension boundary component and create a default extension suspense component.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Add examples for `github:webhook` scaffolder action & improve related tests
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Add component data `core.type` marker for `AppRouter` and `FlatRoutes`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-react': patch
---
Use default extensions boundary and suspense on the alpha declarative `createSearchResultListItem` extension factory.
@@ -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());
@@ -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();
```
@@ -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());
```
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+7 -2
View File
@@ -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',
-1
View File
@@ -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:^",
+1 -16
View File
@@ -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 () => <div>Just a temporary mocked entity page</div>,
});
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 */
@@ -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 [];
});
@@ -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: '/' } },
]);
});
@@ -161,7 +161,7 @@ export class ConfigSources {
}
return FileConfigSource.create({
watch: options.watch,
path: arg.target,
path: resolvePath(arg.target),
substitutionFunc: options.substitutionFunc,
});
});
@@ -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) {
</RouterComponent>
);
}
attachComponentData(AppRouter, 'core.type', 'AppRouter');
@@ -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');
+7
View File
@@ -6,11 +6,18 @@
/// <reference types="react" />
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)
```
@@ -30,6 +30,7 @@ describe('collectLegacyRoutes', () => {
<Route path="/score-board" element={<ScoreBoardPage />} />
<Route path="/stackstorm" element={<StackstormPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
</FlatRoutes>,
);
@@ -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' },
@@ -62,7 +62,10 @@ Existing tasks:
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[] {
const results = new Array<BackstagePlugin>();
const createdPluginIds = new Map<
LegacyBackstagePlugin,
Extension<unknown>[]
>();
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<Extension<unknown>>();
const detectedExtensions =
createdPluginIds.get(plugin) ?? new Array<Extension<unknown>>();
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;
}
@@ -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(
<>
<div />
<span />
<AppRouter>
<div />
<Root>
<FlatRoutes>
<Route path="/score-board" element={<ScoreBoardPage />} />
<Route path="/stackstorm" element={<StackstormPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
</FlatRoutes>
</Root>
</AppRouter>
</>,
);
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,
},
],
},
]);
});
});
@@ -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<ReactElement<{ children?: ReactNode }>> {
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],
}),
];
}
+1
View File
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { collectLegacyRoutes } from './collectLegacyRoutes';
export { convertLegacyApp } from './convertLegacyApp';
@@ -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}
/>
<Content>
<Helmet title={route.title} />
<Helmet title={route?.title} />
{element}
</Content>
</>
+5 -5
View File
@@ -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;
};
};
}
>;
};
}
@@ -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${
@@ -338,6 +338,10 @@ export interface ExtensionBoundaryProps {
// (undocumented)
children: ReactNode;
// (undocumented)
id: string;
// (undocumented)
routable?: boolean;
// (undocumented)
source?: BackstagePlugin;
}
@@ -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",
@@ -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 (
<ErrorPanel title={title} error={error} defaultExpanded>
<Button variant="outlined" onClick={resetError}>
Retry
</Button>
</ErrorPanel>
);
};
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 (
<DefaultErrorBoundaryFallback
plugin={plugin}
error={error}
resetError={this.handleErrorReset}
/>
);
}
return children;
}
}
@@ -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<unknown>,
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: (
<ExtensionBoundary id={id} source={source}>
{element}
</ExtensionBoundary>
),
});
},
});
};
describe('ExtensionBoundary', () => {
it('should render children when there is no error', async () => {
const text = 'Text Component';
const TextComponent = () => {
return <p>{text}</p>;
};
await renderExtensionInTestApp(wrapInBoundaryExtension(<TextComponent />));
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(<ErrorComponent />));
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(
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
<AnalyticsComponent />
</TestApiProvider>,
),
);
await waitFor(() =>
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
action,
subject,
context: {
extension: 'plugin.extension',
routeRef: 'unknown',
},
}),
);
});
});
@@ -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 (
<ExtensionSuspense>
<ErrorBoundary plugin={source}>
<AnalyticsContext attributes={attributes}>
<RouteTracker disableTracking={!routable}>{children}</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</ExtensionSuspense>
);
}
@@ -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(
<ExtensionSuspense>
<LazyComponent />
</ExtensionSuspense>,
),
);
expect(screen.getByTestId('progress')).toBeInTheDocument();
});
it('should render the lazy loaded children component', async () => {
const LazyComponent = lazy(() =>
Promise.resolve({ default: () => <div>Lazy Component</div> }),
);
await renderWithEffects(
wrapInTestApp(
<ExtensionSuspense>
<LazyComponent />
</ExtensionSuspense>,
),
);
await waitFor(() =>
expect(screen.getByText('Lazy Component')).toBeInTheDocument(),
);
});
});
@@ -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 <Suspense fallback={<Progress />}>{children}</Suspense>;
}
@@ -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 () => <div>Component</div>,
});
extension.factory({
bind: (values: ExtensionInputValues<any>) =>
renderWithEffects(
wrapInTestApp(values.element as unknown as JSX.Element),
),
source: createPlugin({ id: 'plugin ' }),
config: { path: '/' },
inputs: {},
});
await waitFor(() =>
expect(captureEvent).toHaveBeenCalledWith(
'_ROUTABLE-EXTENSION-RENDERED',
'',
),
);
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import React, { lazy } from 'react';
import { ExtensionBoundary } from '../components';
import { createSchemaFromZod, PortableSchema } from '../schema';
import {
@@ -22,10 +22,10 @@ import {
createExtension,
Extension,
ExtensionInputValues,
AnyExtensionInputMap,
} from '../wiring';
import { AnyExtensionInputMap } from '../wiring/createExtension';
import { Expand } from '../types';
import { RouteRef } from '../routing';
import { Expand } from '../types';
/**
* Helper for creating extensions for a routable React page component.
@@ -55,6 +55,8 @@ export function createPageExtension<
}) => Promise<JSX.Element>;
},
): Extension<TConfig> {
const { id } = options;
const configSchema =
'configSchema' in options
? options.configSchema
@@ -63,18 +65,18 @@ export function createPageExtension<
) as PortableSchema<TConfig>);
return createExtension({
id: options.id,
id,
attachTo: options.attachTo ?? { id: 'core.routes', input: 'routes' },
configSchema,
inputs: options.inputs,
disabled: options.disabled,
output: {
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
},
inputs: options.inputs,
configSchema,
factory({ bind, config, inputs, source }) {
const LazyComponent = React.lazy(() =>
const ExtensionComponent = lazy(() =>
options
.loader({ config, inputs })
.then(element => ({ default: () => element })),
@@ -82,14 +84,12 @@ export function createPageExtension<
bind({
path: config.path,
routeRef: options.routeRef,
element: (
<ExtensionBoundary source={source}>
<React.Suspense fallback="...">
<LazyComponent />
</React.Suspense>
<ExtensionBoundary id={id} source={source} routable>
<ExtensionComponent />
</ExtensionBoundary>
),
routeRef: options.routeRef,
});
},
});
+65
View File
@@ -3,8 +3,73 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { AnyExtensionInputMap } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionInputValues } from '@backstage/frontend-plugin-api';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
export function createEntityCardExtension<
TConfig,
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<TConfig>;
// @alpha (undocumented)
export function createEntityContentExtension<
TConfig extends {
path: string;
title: string;
},
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
defaultPath: string;
defaultTitle: string;
}
| {
configSchema: PortableSchema<TConfig>;
}
) & {
id: string;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig>;
// @alpha (undocumented)
export const entityContentTitleExtensionDataRef: ConfigurableExtensionDataRef<
string,
{}
>;
// @alpha
export function isOwnerOf(owner: Entity, entity: Entity): boolean;
+3 -2
View File
@@ -10,13 +10,13 @@
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
"src/alpha.tsx"
],
"package.json": [
"package.json"
@@ -51,6 +51,7 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
+158
View File
@@ -0,0 +1,158 @@
/*
* 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 {
AnyExtensionInputMap,
Extension,
ExtensionBoundary,
ExtensionInputValues,
PortableSchema,
RouteRef,
coreExtensionData,
createExtension,
createExtensionDataRef,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { Expand } from '../../../packages/frontend-plugin-api/src/types';
export { isOwnerOf } from './utils';
export { useEntityPermission } from './hooks/useEntityPermission';
/** @alpha */
export const entityContentTitleExtensionDataRef =
createExtensionDataRef<string>('plugin.catalog.entity.content.title');
/** @alpha */
export function createEntityCardExtension<
TConfig,
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<TConfig> {
const id = `entity.cards.${options.id}`;
return createExtension({
id,
attachTo: options.attachTo ?? {
id: 'entity.content.overview',
input: 'cards',
},
disabled: options.disabled ?? true,
output: {
element: coreExtensionData.reactElement,
},
inputs: options.inputs,
configSchema: options.configSchema,
factory({ bind, config, inputs, source }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config, inputs })
.then(element => ({ default: () => element })),
);
bind({
element: (
<ExtensionBoundary id={id} source={source}>
<ExtensionComponent />
</ExtensionBoundary>
),
});
},
});
}
/** @alpha */
export function createEntityContentExtension<
TConfig extends { path: string; title: string },
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
defaultPath: string;
defaultTitle: string;
}
| {
configSchema: PortableSchema<TConfig>;
}
) & {
id: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig> {
const id = `entity.content.${options.id}`;
const configSchema =
'configSchema' in options
? options.configSchema
: (createSchemaFromZod(z =>
z.object({
path: z.string().default(options.defaultPath),
title: z.string().default(options.defaultTitle),
}),
) as PortableSchema<TConfig>);
return createExtension({
id,
attachTo: options.attachTo ?? {
id: 'plugin.catalog.page.entity',
input: 'contents',
},
disabled: options.disabled ?? true,
output: {
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
title: entityContentTitleExtensionDataRef,
},
inputs: options.inputs,
configSchema,
factory({ bind, config, inputs, source }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config, inputs })
.then(element => ({ default: () => element })),
);
bind({
path: config.path,
title: config.title,
routeRef: options.routeRef,
element: (
<ExtensionBoundary id={id} source={source} routable>
<ExtensionComponent />
</ExtensionBoundary>
),
});
},
});
}
-11
View File
@@ -12,14 +12,6 @@ import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
export const CatalogApi: Extension<{}>;
// @alpha (undocumented)
export const CatalogSearchResultListItemExtension: Extension<{
noTrack?: boolean | undefined;
}>;
// @alpha (undocumented)
export function createCatalogFilterExtension<
TInputs extends AnyExtensionInputMap,
@@ -62,8 +54,5 @@ const _default: BackstagePlugin<
>;
export default _default;
// @alpha (undocumented)
export const StarredEntitiesApi: Extension<{}>;
// (No @packageDocumentation comment for this package)
```
+2 -2
View File
@@ -10,13 +10,13 @@
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.tsx",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.tsx"
"src/alpha.ts"
],
"package.json": [
"package.json"
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { isOwnerOf } from './utils';
export { useEntityPermission } from './hooks/useEntityPermission';
export * from './alpha/index';
export { default } from './alpha/index';
-286
View File
@@ -1,286 +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 React from 'react';
import HomeIcon from '@material-ui/icons/Home';
import {
createApiFactory,
discoveryApiRef,
fetchApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { CatalogClient } from '@backstage/catalog-client';
import {
createSchemaFromZod,
createApiExtension,
createPageExtension,
createPlugin,
createNavItemExtension,
createExtension,
coreExtensionData,
AnyExtensionInputMap,
PortableSchema,
ExtensionBoundary,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import {
AsyncEntityProvider,
catalogApiRef,
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha';
import { DefaultStarredEntitiesApi } from './apis';
import {
createComponentRouteRef,
createFromTemplateRouteRef,
rootRouteRef,
viewTechDocRouteRef,
} from './routes';
import { Progress } from '@backstage/core-components';
import { useEntityFromUrl } from './components/CatalogEntityPage/useEntityFromUrl';
/** @alpha */
export const CatalogApi = createApiExtension({
factory: createApiFactory({
api: catalogApiRef,
deps: {
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({ discoveryApi, fetchApi }) =>
new CatalogClient({ discoveryApi, fetchApi }),
}),
});
/** @alpha */
export const StarredEntitiesApi = createApiExtension({
factory: createApiFactory({
api: starredEntitiesApiRef,
deps: { storageApi: storageApiRef },
factory: ({ storageApi }) => new DefaultStarredEntitiesApi({ storageApi }),
}),
});
/** @alpha */
export const CatalogSearchResultListItemExtension =
createSearchResultListItemExtension({
id: 'catalog',
predicate: result => result.type === 'software-catalog',
component: () =>
import('./components/CatalogSearchResultListItem').then(
m => m.CatalogSearchResultListItem,
),
});
/** @alpha */
export function createCatalogFilterExtension<
TInputs extends AnyExtensionInputMap,
TConfig = never,
>(options: {
id: string;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: { config: TConfig }) => Promise<JSX.Element>;
}) {
return createExtension({
id: `catalog.filter.${options.id}`,
attachTo: { id: 'plugin.catalog.page.index', input: 'filters' },
inputs: options.inputs ?? {},
configSchema: options.configSchema,
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, config, source }) {
const LazyComponent = React.lazy(() =>
options
.loader({ config })
.then(element => ({ default: () => element })),
);
bind({
element: (
<ExtensionBoundary source={source}>
<React.Suspense fallback={<Progress />}>
<LazyComponent />
</React.Suspense>
</ExtensionBoundary>
),
});
},
});
}
const CatalogEntityTagFilter = createCatalogFilterExtension({
id: 'entity.tag',
loader: async () => {
const { EntityTagPicker } = await import('@backstage/plugin-catalog-react');
return <EntityTagPicker />;
},
});
const CatalogEntityKindFilter = createCatalogFilterExtension({
id: 'entity.kind',
configSchema: createSchemaFromZod(z =>
z.object({
initialFilter: z.string().default('component'),
}),
),
loader: async ({ config }) => {
const { EntityKindPicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityKindPicker initialFilter={config.initialFilter} />;
},
});
const CatalogEntityTypeFilter = createCatalogFilterExtension({
id: 'entity.type',
loader: async () => {
const { EntityTypePicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityTypePicker />;
},
});
const CatalogEntityOwnerFilter = createCatalogFilterExtension({
id: 'entity.mode',
configSchema: createSchemaFromZod(z =>
z.object({
mode: z.enum(['owners-only', 'all']).optional(),
}),
),
loader: async ({ config }) => {
const { EntityOwnerPicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityOwnerPicker mode={config.mode} />;
},
});
const CatalogEntityNamespaceFilter = createCatalogFilterExtension({
id: 'entity.namespace',
loader: async () => {
const { EntityNamespacePicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityNamespacePicker />;
},
});
const CatalogEntityLifecycleFilter = createCatalogFilterExtension({
id: 'entity.lifecycle',
loader: async () => {
const { EntityLifecyclePicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityLifecyclePicker />;
},
});
const CatalogEntityProcessingStatusFilter = createCatalogFilterExtension({
id: 'entity.processing.status',
loader: async () => {
const { EntityProcessingStatusPicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityProcessingStatusPicker />;
},
});
const CatalogUserListFilter = createCatalogFilterExtension({
id: 'user.list',
configSchema: createSchemaFromZod(z =>
z.object({
initialFilter: z.enum(['owned', 'starred', 'all']).default('owned'),
}),
),
loader: async ({ config }) => {
const { UserListPicker } = await import('@backstage/plugin-catalog-react');
return <UserListPicker initialFilter={config.initialFilter} />;
},
});
const CatalogIndexPage = createPageExtension({
id: 'plugin.catalog.page.index',
defaultPath: '/catalog',
routeRef: convertLegacyRouteRef(rootRouteRef),
inputs: {
filters: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
loader: async ({ inputs }) => {
const { BaseCatalogPage } = await import('./components/CatalogPage');
const filters = inputs.filters.map(filter => filter.element);
return <BaseCatalogPage filters={<>{filters}</>} />;
},
});
const CatalogEntityPage = createPageExtension({
id: 'plugin.catalog.page.entity',
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
loader: async () => {
const Component = () => {
return (
<AsyncEntityProvider {...useEntityFromUrl()}>
<div>🚧 Work In Progress</div>
</AsyncEntityProvider>
);
};
return <Component />;
},
});
const CatalogNavItem = createNavItemExtension({
id: 'catalog.nav.index',
routeRef: convertLegacyRouteRef(rootRouteRef),
title: 'Catalog',
icon: HomeIcon,
});
/** @alpha */
export default createPlugin({
id: 'catalog',
routes: {
catalogIndex: convertLegacyRouteRef(rootRouteRef),
catalogEntity: convertLegacyRouteRef(entityRouteRef),
},
externalRoutes: {
viewTechDoc: convertLegacyRouteRef(viewTechDocRouteRef),
createComponent: convertLegacyRouteRef(createComponentRouteRef),
createFromTemplate: convertLegacyRouteRef(createFromTemplateRouteRef),
},
extensions: [
CatalogApi,
StarredEntitiesApi,
CatalogSearchResultListItemExtension,
CatalogEntityKindFilter,
CatalogEntityTypeFilter,
CatalogUserListFilter,
CatalogEntityOwnerFilter,
CatalogEntityLifecycleFilter,
CatalogEntityTagFilter,
CatalogEntityProcessingStatusFilter,
CatalogEntityNamespaceFilter,
CatalogIndexPage,
CatalogEntityPage,
CatalogNavItem,
],
});
@@ -0,0 +1,121 @@
/*
* 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 from 'react';
import { createCatalogFilterExtension } from './createCatalogFilterExtension';
import { createSchemaFromZod } from '@backstage/frontend-plugin-api';
const CatalogEntityTagFilter = createCatalogFilterExtension({
id: 'entity.tag',
loader: async () => {
const { EntityTagPicker } = await import('@backstage/plugin-catalog-react');
return <EntityTagPicker />;
},
});
const CatalogEntityKindFilter = createCatalogFilterExtension({
id: 'entity.kind',
configSchema: createSchemaFromZod(z =>
z.object({
initialFilter: z.string().default('component'),
}),
),
loader: async ({ config }) => {
const { EntityKindPicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityKindPicker initialFilter={config.initialFilter} />;
},
});
const CatalogEntityTypeFilter = createCatalogFilterExtension({
id: 'entity.type',
loader: async () => {
const { EntityTypePicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityTypePicker />;
},
});
const CatalogEntityOwnerFilter = createCatalogFilterExtension({
id: 'entity.mode',
configSchema: createSchemaFromZod(z =>
z.object({
mode: z.enum(['owners-only', 'all']).optional(),
}),
),
loader: async ({ config }) => {
const { EntityOwnerPicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityOwnerPicker mode={config.mode} />;
},
});
const CatalogEntityNamespaceFilter = createCatalogFilterExtension({
id: 'entity.namespace',
loader: async () => {
const { EntityNamespacePicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityNamespacePicker />;
},
});
const CatalogEntityLifecycleFilter = createCatalogFilterExtension({
id: 'entity.lifecycle',
loader: async () => {
const { EntityLifecyclePicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityLifecyclePicker />;
},
});
const CatalogEntityProcessingStatusFilter = createCatalogFilterExtension({
id: 'entity.processing.status',
loader: async () => {
const { EntityProcessingStatusPicker } = await import(
'@backstage/plugin-catalog-react'
);
return <EntityProcessingStatusPicker />;
},
});
const CatalogUserListFilter = createCatalogFilterExtension({
id: 'user.list',
configSchema: createSchemaFromZod(z =>
z.object({
initialFilter: z.enum(['owned', 'starred', 'all']).default('owned'),
}),
),
loader: async ({ config }) => {
const { UserListPicker } = await import('@backstage/plugin-catalog-react');
return <UserListPicker initialFilter={config.initialFilter} />;
},
});
export const builtInFilterExtensions = [
CatalogEntityTagFilter,
CatalogEntityKindFilter,
CatalogEntityTypeFilter,
CatalogEntityOwnerFilter,
CatalogEntityNamespaceFilter,
CatalogEntityLifecycleFilter,
CatalogEntityProcessingStatusFilter,
CatalogUserListFilter,
];
@@ -0,0 +1,62 @@
/*
* 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 {
AnyExtensionInputMap,
ExtensionBoundary,
PortableSchema,
coreExtensionData,
createExtension,
} from '@backstage/frontend-plugin-api';
/** @alpha */
export function createCatalogFilterExtension<
TInputs extends AnyExtensionInputMap,
TConfig = never,
>(options: {
id: string;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: { config: TConfig }) => Promise<JSX.Element>;
}) {
const id = `catalog.filter.${options.id}`;
return createExtension({
id,
attachTo: { id: 'plugin.catalog.page.index', input: 'filters' },
inputs: options.inputs ?? {},
configSchema: options.configSchema,
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, config, source }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config })
.then(element => ({ default: () => element })),
);
bind({
element: (
<ExtensionBoundary id={id} source={source}>
<ExtensionComponent />
</ExtensionBoundary>
),
});
},
});
}
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { default } from './plugin';
export { createCatalogFilterExtension } from './createCatalogFilterExtension';
+201
View File
@@ -0,0 +1,201 @@
/*
* 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 from 'react';
import HomeIcon from '@material-ui/icons/Home';
import {
createApiFactory,
discoveryApiRef,
fetchApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { CatalogClient } from '@backstage/catalog-client';
import {
createApiExtension,
createPageExtension,
createPlugin,
createNavItemExtension,
coreExtensionData,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import {
AsyncEntityProvider,
catalogApiRef,
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import {
createEntityContentExtension,
createEntityCardExtension,
entityContentTitleExtensionDataRef,
} from '@backstage/plugin-catalog-react/alpha';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha';
import { DefaultStarredEntitiesApi } from '../apis';
import {
createComponentRouteRef,
createFromTemplateRouteRef,
rootRouteRef,
viewTechDocRouteRef,
} from '../routes';
import { builtInFilterExtensions } from './builtInFilterExtensions';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
import Grid from '@material-ui/core/Grid';
/** @alpha */
export const CatalogApi = createApiExtension({
factory: createApiFactory({
api: catalogApiRef,
deps: {
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({ discoveryApi, fetchApi }) =>
new CatalogClient({ discoveryApi, fetchApi }),
}),
});
/** @alpha */
export const StarredEntitiesApi = createApiExtension({
factory: createApiFactory({
api: starredEntitiesApiRef,
deps: { storageApi: storageApiRef },
factory: ({ storageApi }) => new DefaultStarredEntitiesApi({ storageApi }),
}),
});
/** @alpha */
export const CatalogSearchResultListItemExtension =
createSearchResultListItemExtension({
id: 'catalog',
predicate: result => result.type === 'software-catalog',
component: () =>
import('../components/CatalogSearchResultListItem').then(
m => m.CatalogSearchResultListItem,
),
});
const CatalogIndexPage = createPageExtension({
id: 'plugin.catalog.page.index',
defaultPath: '/catalog',
routeRef: convertLegacyRouteRef(rootRouteRef),
inputs: {
filters: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
loader: async ({ inputs }) => {
const { BaseCatalogPage } = await import('../components/CatalogPage');
const filters = inputs.filters.map(filter => filter.element);
return <BaseCatalogPage filters={<>{filters}</>} />;
},
});
const CatalogEntityPage = createPageExtension({
id: 'plugin.catalog.page.entity',
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
inputs: {
contents: createExtensionInput({
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
title: entityContentTitleExtensionDataRef,
}),
},
loader: async ({ inputs }) => {
const { EntityLayout } = await import('../components/EntityLayout');
const Component = () => {
return (
<AsyncEntityProvider {...useEntityFromUrl()}>
<EntityLayout>
{inputs.contents.map(content => (
<EntityLayout.Route
key={content.path}
path={content.path}
title={content.title}
>
{content.element}
</EntityLayout.Route>
))}
</EntityLayout>
</AsyncEntityProvider>
);
};
return <Component />;
},
});
const EntityAboutCard = createEntityCardExtension({
id: 'about',
loader: async () =>
import('../components/AboutCard').then(m => (
<m.AboutCard variant="gridItem" />
)),
});
const OverviewEntityContent = createEntityContentExtension({
id: 'overview',
defaultPath: '/',
defaultTitle: 'Overview',
disabled: false,
inputs: {
cards: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
loader: async ({ inputs }) => (
<Grid container spacing={3} alignItems="stretch">
{inputs.cards.map(card => (
<Grid item md={6} xs={12}>
{card.element}
</Grid>
))}
</Grid>
),
});
const CatalogNavItem = createNavItemExtension({
id: 'catalog.nav.index',
routeRef: convertLegacyRouteRef(rootRouteRef),
title: 'Catalog',
icon: HomeIcon,
});
/** @alpha */
export default createPlugin({
id: 'catalog',
routes: {
catalogIndex: convertLegacyRouteRef(rootRouteRef),
catalogEntity: convertLegacyRouteRef(entityRouteRef),
},
externalRoutes: {
viewTechDoc: convertLegacyRouteRef(viewTechDocRouteRef),
createComponent: convertLegacyRouteRef(createComponentRouteRef),
createFromTemplate: convertLegacyRouteRef(createFromTemplateRouteRef),
},
extensions: [
CatalogApi,
StarredEntitiesApi,
CatalogSearchResultListItemExtension,
CatalogIndexPage,
CatalogEntityPage,
CatalogNavItem,
OverviewEntityContent,
EntityAboutCard,
...builtInFilterExtensions,
],
});
@@ -41,11 +41,10 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/command-exists": "^1.2.0",
"@types/fs-extra": "^9.0.1",
"@types/mock-fs": "^4.13.0",
"mock-fs": "^5.2.0",
"msw": "^1.0.0"
},
"files": [
@@ -22,8 +22,7 @@ import {
import { ConfigReader } from '@backstage/config';
import { JsonObject } from '@backstage/types';
import { ScmIntegrations } from '@backstage/integration';
import mockFs from 'mock-fs';
import os from 'os';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { PassThrough } from 'stream';
import { createFetchCookiecutterAction } from './cookiecutter';
import { join } from 'path';
@@ -47,6 +46,7 @@ jest.mock(
);
describe('fetch:cookiecutter', () => {
const mockDir = createMockDirectory({ mockOsTmpDir: true });
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
@@ -58,7 +58,7 @@ describe('fetch:cookiecutter', () => {
}),
);
const mockTmpDir = os.tmpdir();
const mockTmpDir = mockDir.path;
let mockContext: ActionContext<{
url: string;
@@ -106,36 +106,25 @@ describe('fetch:cookiecutter', () => {
output: jest.fn(),
createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir),
};
// mock the temp directory
mockFs({ [mockTmpDir]: {} });
mockFs({ [`${join(mockTmpDir, 'template')}`]: {} });
mockDir.setContent({ template: {} });
commandExists.mockResolvedValue(null);
// Mock when run container is called it creates some new files in the mock filesystem
containerRunner.runContainer.mockImplementation(async () => {
mockFs({
[`${join(mockTmpDir, 'intermediate')}`]: {
'testfile.json': '{}',
},
mockDir.setContent({
'intermediate/testfile.json': '{}',
});
});
// Mock when executeShellCommand is called it creates some new files in the mock filesystem
executeShellCommand.mockImplementation(async () => {
mockFs({
[`${join(mockTmpDir, 'intermediate')}`]: {
'testfile.json': '{}',
},
mockDir.setContent({
'intermediate/testfile.json': '{}',
});
});
});
afterEach(() => {
mockFs.restore();
});
it('should throw an error when copyWithoutRender is not an array', async () => {
(mockContext.input as any).copyWithoutRender = 'not an array';
@@ -39,13 +39,12 @@
"fs-extra": "^10.0.1"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/command-exists": "^1.2.0",
"@types/fs-extra": "^9.0.1",
"@types/mock-fs": "^4.13.0",
"@types/node": "^18.17.8",
"jest-when": "^3.1.0",
"mock-fs": "^5.2.0"
"jest-when": "^3.1.0"
},
"files": [
"dist"
@@ -34,14 +34,14 @@ import {
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import mockFs from 'mock-fs';
import os from 'os';
import { resolve as resolvePath } from 'path';
import { PassThrough } from 'stream';
import { createFetchRailsAction } from './index';
import { fetchContents } from '@backstage/plugin-scaffolder-node';
import { createMockDirectory } from '@backstage/backend-test-utils';
describe('fetch:rails', () => {
const mockDir = createMockDirectory();
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
@@ -53,7 +53,7 @@ describe('fetch:rails', () => {
}),
);
const mockTmpDir = os.tmpdir();
const mockTmpDir = mockDir.path;
const mockContext = {
input: {
url: 'https://rubyonrails.org/generator',
@@ -90,14 +90,12 @@ describe('fetch:rails', () => {
});
beforeEach(() => {
mockFs({ [`${mockContext.workspacePath}/result`]: {} });
mockDir.setContent({
result: '{}',
});
jest.clearAllMocks();
});
afterEach(() => {
mockFs.restore();
});
it('should call fetchContents with the correct values', async () => {
await action.handler(mockContext);
@@ -0,0 +1,209 @@
/*
* 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 { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import {
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { PassThrough } from 'stream';
import { createGithubWebhookAction } from './githubWebhook';
import yaml from 'yaml';
import { examples } from './githubWebhook.examples';
const mockOctokit = {
rest: {
repos: {
createWebhook: jest.fn(),
},
},
};
jest.mock('octokit', () => ({
Octokit: class {
constructor() {
return mockOctokit;
}
},
}));
describe('github:webhook examples', () => {
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const defaultWebhookSecret = 'aafdfdivierernfdk23f';
const integrations = ScmIntegrations.fromConfig(config);
let githubCredentialsProvider: GithubCredentialsProvider;
let action: TemplateAction<any>;
const mockContext = {
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
githubCredentialsProvider =
DefaultGithubCredentialsProvider.fromIntegrations(integrations);
action = createGithubWebhookAction({
integrations,
defaultWebhookSecret,
githubCredentialsProvider,
});
});
it('Create a GitHub webhook for a repository', async () => {
const input = yaml.parse(examples[0].example).steps[0].input;
await action.handler({
...mockContext,
input,
});
expect(mockOctokit.rest.repos.createWebhook).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
config: {
url: input.webhookUrl,
content_type: input.contentType,
secret: input.webhookSecret,
insecure_ssl: '0',
},
events: input.events,
active: input.active,
});
});
it('Create a GitHub webhook with minimal configuration', async () => {
const input = yaml.parse(examples[1].example).steps[0].input;
await action.handler({
...mockContext,
input,
});
expect(mockOctokit.rest.repos.createWebhook).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
config: {
url: 'https://example.com/my-webhook',
content_type: 'form',
secret: defaultWebhookSecret,
insecure_ssl: '0',
},
events: ['push'],
active: true,
});
});
it('Create a GitHub webhook with custom events', async () => {
const input = yaml.parse(examples[2].example).steps[0].input;
await action.handler({
...mockContext,
input,
});
expect(mockOctokit.rest.repos.createWebhook).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
config: {
url: 'https://example.com/my-webhook',
content_type: 'form',
secret: defaultWebhookSecret,
insecure_ssl: '0',
},
events: ['push', 'pull_request'],
active: true,
});
});
it('Create a GitHub webhook with JSON content type', async () => {
const input = yaml.parse(examples[3].example).steps[0].input;
await action.handler({
...mockContext,
input,
});
expect(mockOctokit.rest.repos.createWebhook).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
config: {
url: 'https://example.com/my-webhook',
content_type: 'json',
secret: defaultWebhookSecret,
insecure_ssl: '0',
},
events: ['push'],
active: true,
});
});
it('Create a GitHub webhook with insecure SSL', async () => {
const input = yaml.parse(examples[4].example).steps[0].input;
await action.handler({
...mockContext,
input,
});
expect(mockOctokit.rest.repos.createWebhook).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
config: {
url: 'https://example.com/my-webhook',
content_type: 'form',
secret: defaultWebhookSecret,
insecure_ssl: '1',
},
events: ['push'],
active: true,
});
});
it('Create an inactive GitHub webhook', async () => {
const input = yaml.parse(examples[5].example).steps[0].input;
await action.handler({
...mockContext,
input,
});
expect(mockOctokit.rest.repos.createWebhook).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
config: {
url: 'https://example.com/my-webhook',
content_type: 'form',
secret: defaultWebhookSecret,
insecure_ssl: '0',
},
events: ['push'],
active: false,
});
});
});
@@ -0,0 +1,120 @@
/*
* 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 { TemplateExample } from '@backstage/plugin-scaffolder-node';
import yaml from 'yaml';
export const examples: TemplateExample[] = [
{
description: 'Create a GitHub webhook for a repository',
example: yaml.stringify({
steps: [
{
action: 'github:webhook',
name: 'Create GitHub Webhook',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
webhookUrl: 'https://example.com/my-webhook',
webhookSecret: 'mysecret',
events: ['push'],
active: true,
contentType: 'json',
insecureSsl: false,
token: 'my-github-token',
},
},
],
}),
},
{
description: 'Create a GitHub webhook with minimal configuration',
example: yaml.stringify({
steps: [
{
action: 'github:webhook',
name: 'Create GitHub Webhook',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
webhookUrl: 'https://example.com/my-webhook',
},
},
],
}),
},
{
description: 'Create a GitHub webhook with custom events',
example: yaml.stringify({
steps: [
{
action: 'github:webhook',
name: 'Create GitHub Webhook',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
webhookUrl: 'https://example.com/my-webhook',
events: ['push', 'pull_request'],
},
},
],
}),
},
{
description: 'Create a GitHub webhook with JSON content type',
example: yaml.stringify({
steps: [
{
action: 'github:webhook',
name: 'Create GitHub Webhook',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
webhookUrl: 'https://example.com/my-webhook',
contentType: 'json',
},
},
],
}),
},
{
description: 'Create a GitHub webhook with insecure SSL',
example: yaml.stringify({
steps: [
{
action: 'github:webhook',
name: 'Create GitHub Webhook',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
webhookUrl: 'https://example.com/my-webhook',
insecureSsl: true,
},
},
],
}),
},
{
description: 'Create an inactive GitHub webhook',
example: yaml.stringify({
steps: [
{
action: 'github:webhook',
name: 'Create GitHub Webhook',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
webhookUrl: 'https://example.com/my-webhook',
active: false,
},
},
],
}),
},
];
@@ -24,6 +24,7 @@ import { assertError, InputError } from '@backstage/errors';
import { Octokit } from 'octokit';
import { getOctokitOptions } from './helpers';
import { parseRepoUrl } from '../publish/util';
import { examples } from './githubWebhook.examples';
/**
* Creates new action that creates a webhook for a repository on GitHub.
@@ -51,6 +52,7 @@ export function createGithubWebhookAction(options: {
}>({
id: 'github:webhook',
description: 'Creates webhook for a repository on GitHub.',
examples,
schema: {
input: {
type: 'object',
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,95 @@
# Stack Overflow Search Backend Module
A plugin that provides stack overflow specific functionality that can be used in different ways (e.g. for search) to compose your Backstage App.
## Getting started
Before we begin, make sure:
- You have created your own standalone Backstage app using @backstage/create-app and not using a fork of the backstage repository. If you haven't setup Backstage already, start [here](https://backstage.io/docs/getting-started/).
To use any of the functionality this plugin provides, you need to start by configuring your App with the following config:
```yaml
stackoverflow:
baseUrl: https://api.stackexchange.com/2.2 # alternative: your internal stack overflow instance
```
### Stack Overflow for Teams
If you have a private Stack Overflow instance and/or a private Stack Overflow Team you will need to supply an API key or Personal Access Token. You can read more about how to set this up by going to [Stack Overflow's Help Page](https://stackoverflow.help/en/articles/4385859-stack-overflow-for-teams-api).
The existing API key approach remains the default, to support the new v2.3 API and PAT authentication model you need to pass the team name and the new PAT into the existing apiAccessToken parameter to the new URL. See [15770](https://github.com/backstage/backstage/issues/15770) for more details.
```yaml
stackoverflow:
baseUrl: https://api.stackexchange.com/2.2 # alternative: your internal stack overflow instance
apiKey: $STACK_OVERFLOW_API_KEY
apiAccessToken: $STACK_OVERFLOW_API_ACCESS_TOKEN
```
```yaml
stackoverflow:
baseUrl: https://api.stackoverflowteams.com/2.3 # alternative: your internal stack overflow instance
teamName: $STACK_OVERFLOW_TEAM_NAME
apiAccessToken: $STACK_OVERFLOW_API_ACCESS_TOKEN
```
## Areas of Responsibility
This stack overflow backend plugin is primarily responsible for the following:
- Provides a `StackOverflowQuestionsCollatorFactory`, which can be used in the search backend to index stack overflow questions to your Backstage Search.
### Index Stack Overflow Questions to search
Before you are able to start index stack overflow questions to search, you need to go through the [search getting started guide](https://backstage.io/docs/features/search/getting-started).
When you have your `packages/backend/src/plugins/search.ts` file ready to make modifications, add the following code snippet to add the `StackOverflowQuestionsCollatorFactory`. Note that you can optionally modify the `requestParams`, otherwise it will defaults to `{ order: 'desc', sort: 'activity', site: 'stackoverflow' }` as done in the `Try It` section on the [official Stack Overflow API documentation](https://api.stackexchange.com/docs/questions).
> Note: if your `baseUrl` is set to the external stack overflow api `https://api.stackexchange.com/2.2`, you can find optional and required parameters under the official API documentation under [`Usage of /questions GET`](https://api.stackexchange.com/docs/questions)
```ts
indexBuilder.addCollator({
schedule,
factory: StackOverflowQuestionsCollatorFactory.fromConfig(env.config, {
logger: env.logger,
requestParams: {
tagged: ['backstage'],
site: 'stackoverflow',
pagesize: 100,
},
}),
});
```
## New Backend System
> DISCLAIMER: The new backend system is in alpha, and so are the search backend module support for the new backend system. We don't recommend you to migrate your backend installations to the new system yet. But if you want to experiment, you can find getting started guides below.
This package exports a module that extends the search backend to also indexing the questions exposed by the [`Stack Overflow` API](https://api.stackexchange.com/docs/questions).
### Installation
Add the module package as a dependency:
```bash
# From your Backstage root directory
yarn add --cwd packages/backend @backstage/plugin-search-backend-module-stack-overflow-collator
```
Add the collator to your backend instance, along with the search plugin itself:
```tsx
// packages/backend/src/index.ts
import { createBackend } from '@backstage/backend-defaults';
const backend = createBackend();
backend.add(import('@backstage/plugin-search-backend/alpha'));
backend.add(
import('@backstage/plugin-search-backend-module-stack-overflow-collator'),
);
backend.start();
```
You may also want to add configuration parameters to your app-config, for example for controlling the scheduled indexing interval. These parameters should be placed under the `stackoverflow` key. See [the config definition file](https://github.com/backstage/backstage/blob/master/plugins/search-backend-module-stack-overflow-collator/config.d.ts) for more details.
@@ -0,0 +1,61 @@
## API Report File for "@backstage/plugin-search-backend-module-stack-overflow-collator"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="node" />
import { BackendFeature } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { DocumentCollatorFactory } from '@backstage/plugin-search-common';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { Logger } from 'winston';
import { Readable } from 'stream';
// @public
const searchStackOverflowCollatorModule: () => BackendFeature;
export default searchStackOverflowCollatorModule;
// @public
export interface StackOverflowDocument extends IndexableDocument {
// (undocumented)
answers: number;
// (undocumented)
tags: string[];
}
// @public
export class StackOverflowQuestionsCollatorFactory
implements DocumentCollatorFactory
{
// (undocumented)
execute(): AsyncGenerator<StackOverflowDocument>;
// (undocumented)
static fromConfig(
config: Config,
options: StackOverflowQuestionsCollatorFactoryOptions,
): StackOverflowQuestionsCollatorFactory;
// (undocumented)
getCollator(): Promise<Readable>;
// (undocumented)
protected requestParams: StackOverflowQuestionsRequestParams;
// (undocumented)
readonly type: string;
}
// @public
export type StackOverflowQuestionsCollatorFactoryOptions = {
baseUrl?: string;
maxPage?: number;
apiKey?: string;
apiAccessToken?: string;
teamName?: string;
requestParams?: StackOverflowQuestionsRequestParams;
logger: Logger;
};
// @public
export type StackOverflowQuestionsRequestParams = {
[key: string]: string | string[] | number;
};
```
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-search-backend-module-stack-overflow-collator
title: '@backstage/plugin-search-backend-module-stack-overflow-collator'
description: A module for the search backend that exports stack overflow modules
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: discoverability-maintainers
@@ -0,0 +1,51 @@
/*
* 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.
*/
export interface Config {
/**
* Configuration options for the stack overflow plugin
*/
stackoverflow?: {
/**
* The base url of the Stack Overflow API used for the plugin
*/
baseUrl?: string;
/**
* The API key to authenticate to Stack Overflow API
* @visibility secret
*/
apiKey?: string;
/**
* The name of the team for a Stack Overflow for Teams account
*/
teamName?: string;
/**
* The API Access Token to authenticate to Stack Overflow API
* @visibility secret
*/
apiAccessToken?: string;
/**
* Type representing the request parameters.
*/
requestParams?: {
[key: string]: string | string[] | number;
};
};
}
@@ -0,0 +1,52 @@
{
"name": "@backstage/plugin-search-backend-module-stack-overflow-collator",
"description": "A module for the search backend that exports stack overflow modules",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/search-backend-module-stack-overflow"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-search-backend-node": "workspace:^",
"@backstage/plugin-search-common": "workspace:^",
"node-fetch": "^2.6.7",
"qs": "^6.9.4",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"msw": "^1.2.1"
},
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
@@ -54,7 +54,7 @@ export type StackOverflowQuestionsCollatorFactoryOptions = {
apiKey?: string;
apiAccessToken?: string;
teamName?: string;
requestParams: StackOverflowQuestionsRequestParams;
requestParams?: StackOverflowQuestionsRequestParams;
logger: Logger;
};
@@ -81,7 +81,14 @@ export class StackOverflowQuestionsCollatorFactory
this.apiAccessToken = options.apiAccessToken;
this.teamName = options.teamName;
this.maxPage = options.maxPage;
this.requestParams = options.requestParams;
// Sets the same default request parameters as the official API documentation
// See https://api.stackexchange.com/docs/questions
this.requestParams = options.requestParams ?? {
order: 'desc',
sort: 'activity',
site: 'stackoverflow',
...(options.requestParams ?? {}),
};
this.logger = options.logger.child({ documentType: this.type });
}
@@ -98,12 +105,16 @@ export class StackOverflowQuestionsCollatorFactory
config.getOptionalString('stackoverflow.baseUrl') ||
'https://api.stackexchange.com/2.3';
const maxPage = options.maxPage || 100;
const requestParams = config
.getOptionalConfig('stackoverflow.requestParams')
?.get<StackOverflowQuestionsRequestParams>();
return new StackOverflowQuestionsCollatorFactory({
baseUrl,
maxPage,
apiKey,
apiAccessToken,
teamName,
requestParams,
...options,
});
}
@@ -175,7 +186,7 @@ export class StackOverflowQuestionsCollatorFactory
);
const data = await res.json();
for (const question of data.items) {
for (const question of data.items ?? []) {
yield {
title: question.title,
location: question.link,
@@ -0,0 +1,22 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
type StackOverflowDocument,
type StackOverflowQuestionsRequestParams,
type StackOverflowQuestionsCollatorFactoryOptions,
StackOverflowQuestionsCollatorFactory,
} from './StackOverflowQuestionsCollatorFactory';
@@ -0,0 +1,23 @@
/*
* 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.
*/
/**
* @packageDocumentation
* A module for the search backend that exports Stack Overflow modules.
*/
export * from './collators';
export { searchStackOverflowCollatorModule as default } from './module';
@@ -0,0 +1,55 @@
/*
* 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 { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { searchIndexRegistryExtensionPoint } from '@backstage/plugin-search-backend-node/alpha';
import { searchStackOverflowCollatorModule } from './SearchStackOverflowCollatorModule';
describe('searchStackOverflowCollatorModule', () => {
const schedule = {
frequency: { minutes: 10 },
timeout: { minutes: 15 },
initialDelay: { seconds: 3 },
};
it('should register the stack overflow collator to the search index registry extension point with factory and schedule', async () => {
const extensionPointMock = {
addCollator: jest.fn(),
};
await startTestBackend({
extensionPoints: [
[searchIndexRegistryExtensionPoint, extensionPointMock],
],
features: [
searchStackOverflowCollatorModule(),
mockServices.rootConfig.factory({
data: {
stackoverflow: {
schedule,
},
},
}),
],
});
expect(extensionPointMock.addCollator).toHaveBeenCalledTimes(1);
expect(extensionPointMock.addCollator).toHaveBeenCalledWith({
factory: expect.objectContaining({ type: 'stack-overflow' }),
schedule: expect.objectContaining({ run: expect.any(Function) }),
});
});
});
@@ -0,0 +1,64 @@
/*
* 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 { loggerToWinstonLogger } from '@backstage/backend-common';
import { readTaskScheduleDefinitionFromConfig } from '@backstage/backend-tasks';
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';
import { searchIndexRegistryExtensionPoint } from '@backstage/plugin-search-backend-node/alpha';
import { StackOverflowQuestionsCollatorFactory } from '../collators';
/**
* @public
* Search backend module for the Stack Overflow index.
*/
export const searchStackOverflowCollatorModule = createBackendModule({
moduleId: 'stackOverflowCollator',
pluginId: 'search',
register(env) {
env.registerInit({
deps: {
config: coreServices.rootConfig,
logger: coreServices.logger,
discovery: coreServices.discovery,
scheduler: coreServices.scheduler,
indexRegistry: searchIndexRegistryExtensionPoint,
},
async init({ config, logger, scheduler, indexRegistry }) {
const defaultSchedule = {
frequency: { minutes: 10 },
timeout: { minutes: 15 },
initialDelay: { seconds: 3 },
};
const schedule = config.has('stackoverflow.schedule')
? readTaskScheduleDefinitionFromConfig(
config.getConfig('stackoverflow.schedule'),
)
: defaultSchedule;
indexRegistry.addCollator({
schedule: scheduler.createScheduledTaskRunner(schedule),
factory: StackOverflowQuestionsCollatorFactory.fromConfig(config, {
logger: loggerToWinstonLogger(logger),
}),
});
},
});
},
});
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The Backstage Authors
* 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.
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export * from './StackOverflowQuestionsCollatorFactory';
export { searchStackOverflowCollatorModule } from './SearchStackOverflowCollatorModule';
@@ -329,11 +329,11 @@ export function parseHighlightFields({
const highlightedField = positions.reduce((content, pos) => {
return (
`${content.substring(0, pos[0])}${preTag}` +
`${content.substring(pos[0], pos[0] + pos[1])}` +
`${postTag}${content.substring(pos[0] + pos[1])}`
`${String(content).substring(0, pos[0])}${preTag}` +
`${String(content).substring(pos[0], pos[0] + pos[1])}` +
`${postTag}${String(content).substring(pos[0] + pos[1])}`
);
}, doc[field]);
}, doc[field] ?? '');
return [field, highlightedField];
}),
+18 -15
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { lazy, Suspense } from 'react';
import React, { lazy } from 'react';
import { ListItemProps } from '@material-ui/core';
@@ -25,7 +25,6 @@ import {
createExtensionDataRef,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import { Progress } from '@backstage/core-components';
import { SearchDocument, SearchResult } from '@backstage/plugin-search-common';
import { SearchResultListItemExtension } from './extensions';
@@ -87,6 +86,8 @@ export type SearchResultItemExtensionOptions<
export function createSearchResultListItemExtension<
TConfig extends { noTrack?: boolean },
>(options: SearchResultItemExtensionOptions<TConfig>) {
const id = `plugin.search.result.item.${options.id}`;
const configSchema =
'configSchema' in options
? options.configSchema
@@ -95,15 +96,19 @@ export function createSearchResultListItemExtension<
noTrack: z.boolean().default(false),
}),
) as PortableSchema<TConfig>);
return createExtension({
id: `plugin.search.result.item.${options.id}`,
attachTo: options.attachTo ?? { id: 'plugin.search.page', input: 'items' },
id,
attachTo: options.attachTo ?? {
id: 'plugin.search.page',
input: 'items',
},
configSchema,
output: {
item: searchResultItemExtensionData,
},
factory({ bind, config, source }) {
const LazyComponent = lazy(() =>
const ExtensionComponent = lazy(() =>
options
.component({ config })
.then(component => ({ default: component })),
@@ -113,16 +118,14 @@ export function createSearchResultListItemExtension<
item: {
predicate: options.predicate,
component: props => (
<ExtensionBoundary source={source}>
<Suspense fallback={<Progress />}>
<SearchResultListItemExtension
rank={props.rank}
result={props.result}
noTrack={config.noTrack}
>
<LazyComponent {...props} />
</SearchResultListItemExtension>
</Suspense>
<ExtensionBoundary id={id} source={source}>
<SearchResultListItemExtension
rank={props.rank}
result={props.result}
noTrack={config.noTrack}
>
<ExtensionComponent {...props} />
</SearchResultListItemExtension>
</ExtensionBoundary>
),
},
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
import { configApiRef } from '@backstage/core-plugin-api';
@@ -203,9 +203,9 @@ describe('SearchModal', () => {
expect.objectContaining({ term: 'term' }),
);
const input = screen.getByLabelText('Search');
const input = screen.getByLabelText<HTMLInputElement>('Search');
await userEvent.clear(input);
await 'a tick';
await waitFor(() => expect(input.value).toBe(''));
await userEvent.type(input, 'new term{enter}');
expect(navigate).toHaveBeenCalledWith('/search?query=new term');
+2 -63
View File
@@ -1,64 +1,3 @@
# Stack Overflow
# Stack Overflow Backend
A plugin that provides stack overflow specific functionality that can be used in different ways (e.g. for search) to compose your Backstage App.
## Getting started
Before we begin, make sure:
- You have created your own standalone Backstage app using @backstage/create-app and not using a fork of the backstage repository. If you haven't setup Backstage already, start [here](https://backstage.io/docs/getting-started/).
To use any of the functionality this plugin provides, you need to start by configuring your App with the following config:
```yaml
stackoverflow:
baseUrl: https://api.stackexchange.com/2.2 # alternative: your internal stack overflow instance
```
### Stack Overflow for Teams
If you have a private Stack Overflow instance and/or a private Stack Overflow Team you will need to supply an API key or Personal Access Token. You can read more about how to set this up by going to [Stack Overflow's Help Page](https://stackoverflow.help/en/articles/4385859-stack-overflow-for-teams-api).
The existing API key approach remains the default, to support the new v2.3 API and PAT authentication model you need to pass the team name and the new PAT into the existing apiAccessToken parameter to the new URL. See [15770](https://github.com/backstage/backstage/issues/15770) for more details.
```yaml
stackoverflow:
baseUrl: https://api.stackexchange.com/2.2 # alternative: your internal stack overflow instance
apiKey: $STACK_OVERFLOW_API_KEY
apiAccessToken: $STACK_OVERFLOW_API_ACCESS_TOKEN
```
```yaml
stackoverflow:
baseUrl: https://api.stackoverflowteams.com/2.3 # alternative: your internal stack overflow instance
teamName: $STACK_OVERFLOW_TEAM_NAME
apiAccessToken: $STACK_OVERFLOW_API_ACCESS_TOKEN
```
## Areas of Responsibility
This stack overflow backend plugin is primarily responsible for the following:
- Provides a `StackOverflowQuestionsCollatorFactory`, which can be used in the search backend to index stack overflow questions to your Backstage Search.
### Index Stack Overflow Questions to search
Before you are able to start index stack overflow questions to search, you need to go through the [search getting started guide](https://backstage.io/docs/features/search/getting-started).
When you have your `packages/backend/src/plugins/search.ts` file ready to make modifications, add the following code snippet to add the `StackOverflowQuestionsCollatorFactory`. Note that you can modify the `requestParams`.
> Note: if your `baseUrl` is set to the external stack overflow api `https://api.stackexchange.com/2.2`, you can find optional and required parameters under the official API documentation under [`Usage of /questions GET`](https://api.stackexchange.com/docs/questions)
```ts
indexBuilder.addCollator({
schedule,
factory: StackOverflowQuestionsCollatorFactory.fromConfig(env.config, {
logger: env.logger,
requestParams: {
tagged: ['backstage'],
site: 'stackoverflow',
pagesize: 100,
},
}),
});
```
Deprecated, consider using `@backstage/plugin-search-backend-module-stack-overflow-collator` instead.
+13 -46
View File
@@ -3,54 +3,21 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="node" />
import { StackOverflowDocument as StackOverflowDocument_2 } from '@backstage/plugin-search-backend-module-stack-overflow-collator';
import { StackOverflowQuestionsCollatorFactory as StackOverflowQuestionsCollatorFactory_2 } from '@backstage/plugin-search-backend-module-stack-overflow-collator';
import { StackOverflowQuestionsRequestParams as StackOverflowQuestionsRequestParams_2 } from '@backstage/plugin-search-backend-module-stack-overflow-collator';
import { Config } from '@backstage/config';
import { DocumentCollatorFactory } from '@backstage/plugin-search-common';
import { IndexableDocument } from '@backstage/plugin-search-common';
import { Logger } from 'winston';
import { Readable } from 'stream';
// @public @deprecated (undocumented)
export type StackOverflowDocument = StackOverflowDocument_2;
// @public
export interface StackOverflowDocument extends IndexableDocument {
// (undocumented)
answers: number;
// (undocumented)
tags: string[];
}
// @public @deprecated (undocumented)
export const StackOverflowQuestionsCollatorFactory: typeof StackOverflowQuestionsCollatorFactory_2;
// @public
export class StackOverflowQuestionsCollatorFactory
implements DocumentCollatorFactory
{
// (undocumented)
execute(): AsyncGenerator<StackOverflowDocument>;
// (undocumented)
static fromConfig(
config: Config,
options: StackOverflowQuestionsCollatorFactoryOptions,
): StackOverflowQuestionsCollatorFactory;
// (undocumented)
getCollator(): Promise<Readable>;
// (undocumented)
protected requestParams: StackOverflowQuestionsRequestParams;
// (undocumented)
readonly type: string;
}
// @public @deprecated (undocumented)
export type StackOverflowQuestionsCollatorFactoryOptions =
StackOverflowQuestionsCollatorFactory_2;
// @public
export type StackOverflowQuestionsCollatorFactoryOptions = {
baseUrl?: string;
maxPage?: number;
apiKey?: string;
apiAccessToken?: string;
teamName?: string;
requestParams: StackOverflowQuestionsRequestParams;
logger: Logger;
};
// @public
export type StackOverflowQuestionsRequestParams = {
[key: string]: string | string[] | number;
};
// @public @deprecated (undocumented)
export type StackOverflowQuestionsRequestParams =
StackOverflowQuestionsRequestParams_2;
```
+7
View File
@@ -40,5 +40,12 @@ export interface Config {
* @visibility secret
*/
apiAccessToken?: string;
/**
* Type representing the request parameters.
*/
requestParams?: {
[key: string]: string | string[] | number;
};
};
}
@@ -1,5 +1,6 @@
{
"name": "@backstage/plugin-stack-overflow-backend",
"description": "Deprecated, consider using @backstage/plugin-search-backend-module-stack-overflow-collator instead",
"version": "0.2.10",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -34,6 +35,7 @@
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-search-backend-module-stack-overflow-collator": "workspace:^",
"@backstage/plugin-search-common": "workspace:^",
"node-fetch": "^2.6.7",
"qs": "^6.9.4",
+40 -3
View File
@@ -15,9 +15,46 @@
*/
/**
* Stack Overflow backend plugin
*
* @packageDocumentation
* Stack Overflow backend plugin
* @deprecated
* Deprecated, consider using `@backstage/plugin-search-backend-module-stack-overflow-collator` instead.
*/
export * from './search';
import {
StackOverflowDocument as _StackOverflowDocument,
StackOverflowQuestionsRequestParams as _StackOverflowQuestionsRequestParams,
StackOverflowQuestionsCollatorFactory as _StackOverflowQuestionsCollatorFactory,
StackOverflowQuestionsCollatorFactoryOptions as _StackOverflowQuestionsCollatorFactoryOptions,
} from '@backstage/plugin-search-backend-module-stack-overflow-collator';
/**
* @public
* @deprecated
* Import from `@backstage/plugin-search-backend-module-stack-overflow-collator` instead.
*/
export type StackOverflowDocument = _StackOverflowDocument;
/**
* @public
* @deprecated
* Import from `@backstage/plugin-search-backend-module-stack-overflow-collator` instead.
*/
export type StackOverflowQuestionsRequestParams =
_StackOverflowQuestionsRequestParams;
/**
* @public
* @deprecated
* Import from `@backstage/plugin-search-backend-module-stack-overflow-collator` instead.
*/
export type StackOverflowQuestionsCollatorFactoryOptions =
_StackOverflowQuestionsCollatorFactory;
/**
* @public
* @deprecated
* Import from `@backstage/plugin-search-backend-module-stack-overflow-collator` instead.
*/
export const StackOverflowQuestionsCollatorFactory =
_StackOverflowQuestionsCollatorFactory;
@@ -0,0 +1,13 @@
## API Report File for "@backstage/plugin-stack-overflow"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
const _default: BackstagePlugin<{}, {}>;
export default _default;
// (No @packageDocumentation comment for this package)
```
+17 -3
View File
@@ -5,9 +5,22 @@
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
"access": "public"
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.tsx"
],
"package.json": [
"package.json"
]
}
},
"backstage": {
"role": "frontend-plugin"
@@ -32,6 +45,7 @@
"@backstage/config": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-home-react": "workspace:^",
"@backstage/plugin-search-common": "workspace:^",
"@backstage/plugin-search-react": "workspace:^",
+49
View File
@@ -0,0 +1,49 @@
/*
* 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 { configApiRef, createApiFactory } from '@backstage/core-plugin-api';
import {
createApiExtension,
createPlugin,
} from '@backstage/frontend-plugin-api';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha';
import { StackOverflowClient, stackOverflowApiRef } from './api';
/** @alpha */
const StackOverflowApi = createApiExtension({
factory: createApiFactory({
api: stackOverflowApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => StackOverflowClient.fromConfig(configApi),
}),
});
/** @alpha */
const StackOverflowSearchResultListItem = createSearchResultListItemExtension({
id: 'stack-overflow',
predicate: result => result.type === 'stack-overflow',
component: () =>
import('./search/StackOverflowSearchResultListItem').then(
m => m.StackOverflowSearchResultListItem,
),
});
/** @alpha */
export default createPlugin({
id: 'stack-overflow',
// TODO: Migrate homepage cards when the declarative homepage plugin supports them
extensions: [StackOverflowApi, StackOverflowSearchResultListItem],
});
@@ -258,7 +258,7 @@ describe('DocsSynchronizer', () => {
expect(mockResponseHandler.log).toHaveBeenCalledTimes(1);
expect(mockResponseHandler.log).toHaveBeenCalledWith(
expect.stringMatching(
/error.*: Failed to build the docs page: Some random error/,
/error.*: Failed to build the docs page for entity component:default\/test: Some random error/,
),
);
expect(mockResponseHandler.finish).toHaveBeenCalledTimes(0);
@@ -15,7 +15,11 @@
*/
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
DEFAULT_NAMESPACE,
Entity,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError, NotFoundError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -142,7 +146,9 @@ export class DocsSynchronizer {
}
} catch (e) {
assertError(e);
const msg = `Failed to build the docs page: ${e.message}`;
const msg = `Failed to build the docs page for entity ${stringifyEntityRef(
entity,
)}: ${e.message}`;
taskLogger.error(msg);
this.logger.error(msg, e);
error(e);
+14
View File
@@ -42,6 +42,7 @@ import {
rootDocsRouteRef,
rootRouteRef,
} from './routes';
import { createEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
/** @alpha */
const techDocsStorage = createApiExtension({
@@ -141,6 +142,18 @@ const TechDocsReaderPage = createPageExtension({
)),
});
/**
* Component responsible for rendering techdocs on entity pages
*
* @alpha
*/
const TechDocsEntityContent = createEntityContentExtension({
id: 'techdocs',
defaultPath: 'docs',
defaultTitle: 'TechDocs',
loader: () => import('./Router').then(m => <m.EmbeddedDocsRouter />),
});
/** @alpha */
const TechDocsNavItem = createNavItemExtension({
id: 'plugin.techdocs.nav.index',
@@ -158,6 +171,7 @@ export default createPlugin({
TechDocsNavItem,
TechDocsIndexPage,
TechDocsReaderPage,
TechDocsEntityContent,
TechDocsSearchResultListItemExtension,
],
routes: {
+26 -5
View File
@@ -4246,11 +4246,13 @@ __metadata:
resolution: "@backstage/frontend-plugin-api@workspace:packages/frontend-plugin-api"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@material-ui/core": ^4.12.4
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^14.0.0
"@types/react": ^16.13.1 || ^17.0.0
@@ -5835,6 +5837,7 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
@@ -8457,6 +8460,7 @@ __metadata:
resolution: "@backstage/plugin-scaffolder-backend-module-cookiecutter@workspace:plugins/scaffolder-backend-module-cookiecutter"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
@@ -8465,10 +8469,8 @@ __metadata:
"@backstage/types": "workspace:^"
"@types/command-exists": ^1.2.0
"@types/fs-extra": ^9.0.1
"@types/mock-fs": ^4.13.0
command-exists: ^1.2.9
fs-extra: 10.1.0
mock-fs: ^5.2.0
msw: ^1.0.0
winston: ^3.2.1
yn: ^4.0.0
@@ -8496,6 +8498,7 @@ __metadata:
resolution: "@backstage/plugin-scaffolder-backend-module-rails@workspace:plugins/scaffolder-backend-module-rails"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
@@ -8504,12 +8507,10 @@ __metadata:
"@backstage/types": "workspace:^"
"@types/command-exists": ^1.2.0
"@types/fs-extra": ^9.0.1
"@types/mock-fs": ^4.13.0
"@types/node": ^18.17.8
command-exists: ^1.2.9
fs-extra: ^10.0.1
jest-when: ^3.1.0
mock-fs: ^5.2.0
languageName: unknown
linkType: soft
@@ -8848,6 +8849,25 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-search-backend-module-stack-overflow-collator@workspace:^, @backstage/plugin-search-backend-module-stack-overflow-collator@workspace:plugins/search-backend-module-stack-overflow-collator":
version: 0.0.0-use.local
resolution: "@backstage/plugin-search-backend-module-stack-overflow-collator@workspace:plugins/search-backend-module-stack-overflow-collator"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/plugin-search-backend-node": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
msw: ^1.2.1
node-fetch: ^2.6.7
qs: ^6.9.4
winston: ^3.2.1
languageName: unknown
linkType: soft
"@backstage/plugin-search-backend-module-techdocs@workspace:^, @backstage/plugin-search-backend-module-techdocs@workspace:plugins/search-backend-module-techdocs":
version: 0.0.0-use.local
resolution: "@backstage/plugin-search-backend-module-techdocs@workspace:plugins/search-backend-module-techdocs"
@@ -9191,6 +9211,7 @@ __metadata:
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/plugin-search-backend-module-stack-overflow-collator": "workspace:^"
"@backstage/plugin-search-backend-node": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
msw: ^1.0.0
@@ -9210,6 +9231,7 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-home-react": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
"@backstage/plugin-search-react": "workspace:^"
@@ -25315,7 +25337,6 @@ __metadata:
"@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:^"