Merge branch 'backstage:master' into code-coverage
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/config-loader': patch
|
||||
---
|
||||
|
||||
Correctly resolve config targets into absolute paths
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend-module-stack-overflow-collator': minor
|
||||
---
|
||||
|
||||
Extract a package for the Stack Overflow new backend system plugin.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Fix `RoutedTabs` so that it does not explode without tabs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend-node': patch
|
||||
---
|
||||
|
||||
Fix highlighting for non-string fields on the `Lunr` search engine implementation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Use default extensions boundary and suspense on the alpha declarative `createCatalogFilterExtension` extension factory.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs-backend': patch
|
||||
---
|
||||
|
||||
Add info about the entity when tech docs fail to build
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Added entity page content for the new plugin exported via `/alpha`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Updated `app.extensions` configuration schema.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
The experimental package detection will now ignore packages that don't make `package.json` available.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
---
|
||||
|
||||
Improve the extension boundary component and create a default extension suspense component.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Add examples for `github:webhook` scaffolder action & improve related tests
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
Add component data `core.type` marker for `AppRouter` and `FlatRoutes`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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": [
|
||||
|
||||
+8
-19
@@ -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);
|
||||
|
||||
|
||||
+209
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+120
@@ -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"
|
||||
}
|
||||
+14
-3
@@ -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';
|
||||
+55
@@ -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) }),
|
||||
});
|
||||
});
|
||||
});
|
||||
+64
@@ -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),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
+2
-2
@@ -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];
|
||||
}),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user