Merge pull request #653 from spotify/rugvip/dev-utils
Add new dev-utils package with createDevApp() to simplify plugin dev setup
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { ApiFactory } from './types';
|
||||
|
||||
/**
|
||||
* Used to infer types for a standalone ApiFactory that isn't immediately passed
|
||||
* to another function.
|
||||
* This function doesn't actually do anything, it's only used to infer types.
|
||||
*/
|
||||
export function createApiFactory<Api, Impl, Deps>(
|
||||
factory: ApiFactory<Api, Impl, Deps>,
|
||||
): ApiFactory<Api, Impl, Deps> {
|
||||
return factory;
|
||||
}
|
||||
@@ -19,5 +19,6 @@ export { default as ApiRegistry } from './ApiRegistry';
|
||||
export { default as ApiTestRegistry } from './ApiTestRegistry';
|
||||
export { default as ApiRef } from './ApiRef';
|
||||
export * from './types';
|
||||
export * from './helpers';
|
||||
export * from './definitions';
|
||||
export * from './implementations';
|
||||
|
||||
@@ -30,8 +30,8 @@ export type ApiHolder = {
|
||||
get<T>(api: ApiRef<T>): T | undefined;
|
||||
};
|
||||
|
||||
export type ApiFactory<A, I, D> = {
|
||||
implements: ApiRef<A>;
|
||||
deps: TypesToApiRefs<D>;
|
||||
factory(deps: D): I extends A ? I : never;
|
||||
export type ApiFactory<Api, Impl, Deps> = {
|
||||
implements: ApiRef<Api>;
|
||||
deps: TypesToApiRefs<Deps>;
|
||||
factory(deps: Deps): Impl extends Api ? Impl : never;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
@@ -0,0 +1,24 @@
|
||||
# @backstage/dev-utils
|
||||
|
||||
Utilities for developing Backstage plugins.
|
||||
|
||||
This package provides utilities that help in developing plugins for Backstage, like App wrappers and API implementations for standalone serving of apps.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the package via npm or yarn:
|
||||
|
||||
```sh
|
||||
$ npm install --save-dev @backstage/dev-utils
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```sh
|
||||
$ yarn add -D @backstage/dev-utils
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
|
||||
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@backstage/dev-utils",
|
||||
"description": "Utilities for developing Backstage plugins.",
|
||||
"version": "0.1.1-alpha.4",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spotify/backstage",
|
||||
"directory": "packages/dev-utils"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.4",
|
||||
"@backstage/core": "^0.1.1-alpha.4",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.4",
|
||||
"@backstage/theme": "^0.1.1-alpha.4",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
"@types/jest": "^24.0.0",
|
||||
"@types/node": "^12.0.0",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-router": "^5.1.2",
|
||||
"react-router-dom": "^5.1.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * as apiFactories from './apiFactories';
|
||||
import { ApiTestRegistry } from '@backstage/core';
|
||||
|
||||
describe('apiFactories', () => {
|
||||
it('should be possible to get an instance of each API', () => {
|
||||
const registry = new ApiTestRegistry();
|
||||
const factories = Object.values(apiFactories);
|
||||
|
||||
for (const factory of factories) {
|
||||
registry.register(factory);
|
||||
}
|
||||
|
||||
for (const factory of factories) {
|
||||
const api = registry.get(factory.implements);
|
||||
expect(api).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 {
|
||||
alertApiRef,
|
||||
errorApiRef,
|
||||
ErrorApiForwarder,
|
||||
AlertApi,
|
||||
createApiFactory,
|
||||
} from '@backstage/core';
|
||||
|
||||
// TODO(rugvip): We should likely figure out how to reuse all of these between apps
|
||||
// and plugin serve with minimal boilerplate. For example we might move everything
|
||||
// to DI, and provide factories for the default implementations, so this just becomes
|
||||
// a list of things like `[ErrorApiForwarder.factory, AlertApiDialog.factory]`.
|
||||
|
||||
export const alertApiFactory = createApiFactory({
|
||||
implements: alertApiRef,
|
||||
deps: {},
|
||||
factory: (): AlertApi => ({
|
||||
// TODO: Figure out how to ship a nicer implementation without having
|
||||
// to export any external references.
|
||||
post(alertConfig) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(`Alert[${alertConfig.severity}]: ${alertConfig.message}`);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const errorApiFactory = createApiFactory({
|
||||
implements: errorApiRef,
|
||||
deps: { alertApi: alertApiRef },
|
||||
factory: ({ alertApi }) => new ErrorApiForwarder(alertApi),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './render';
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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, { FC, ComponentType } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import BookmarkIcon from '@material-ui/icons/Bookmark';
|
||||
import { ThemeProvider, CssBaseline } from '@material-ui/core';
|
||||
import {
|
||||
createApp,
|
||||
SidebarPage,
|
||||
Sidebar,
|
||||
SidebarItem,
|
||||
SidebarSpacer,
|
||||
ApiFactory,
|
||||
createPlugin,
|
||||
ApiTestRegistry,
|
||||
ApiHolder,
|
||||
} from '@backstage/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import * as defaultApiFactories from './apiFactories';
|
||||
|
||||
// TODO(rugvip): export proper plugin type from core that isn't the plugin class
|
||||
type BackstagePlugin = ReturnType<typeof createPlugin>;
|
||||
|
||||
/**
|
||||
* DevApp builder that is similar to the App builder API, but creates an App
|
||||
* with the purpose of developing one or more plugins inside it.
|
||||
*/
|
||||
class DevAppBuilder {
|
||||
private readonly plugins = new Array<BackstagePlugin>();
|
||||
private readonly factories = new Array<ApiFactory<any, any, any>>();
|
||||
|
||||
/**
|
||||
* Register one or more plugins to render in the dev app
|
||||
*/
|
||||
registerPlugin(...plugins: BackstagePlugin[]): DevAppBuilder {
|
||||
this.plugins.push(...plugins);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an API factory to add to the app
|
||||
*/
|
||||
registerApiFactory<Api, Impl, Deps>(
|
||||
factory: ApiFactory<Api, Impl, Deps>,
|
||||
): DevAppBuilder {
|
||||
this.factories.push(factory);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DevApp component using the resources registered so far
|
||||
*/
|
||||
build(): ComponentType<{}> {
|
||||
const app = createApp();
|
||||
app.registerApis(this.setupApiRegistry(this.factories));
|
||||
app.registerPlugin(...this.plugins);
|
||||
const AppComponent = app.build();
|
||||
|
||||
const sidebar = this.setupSidebar(this.plugins);
|
||||
|
||||
const DevApp: FC<{}> = () => {
|
||||
return (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return DevApp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and render directory to #root element
|
||||
*/
|
||||
render(): void {
|
||||
const DevApp = this.build();
|
||||
|
||||
const paths = this.findPluginPaths(this.plugins);
|
||||
|
||||
if (window.location.pathname === '/') {
|
||||
if (!paths.includes('/') && paths.length > 0) {
|
||||
window.location.pathname = paths[0];
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.render(<DevApp />, document.getElementById('root'));
|
||||
}
|
||||
|
||||
// Create a sidebar that exposes the touchpoints of a plugin
|
||||
private setupSidebar(plugins: BackstagePlugin[]): JSX.Element {
|
||||
const sidebarItems = new Array<JSX.Element>();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
for (const output of plugin.output()) {
|
||||
switch (output.type) {
|
||||
case 'route': {
|
||||
const { path } = output;
|
||||
sidebarItems.push(
|
||||
<SidebarItem
|
||||
key={path}
|
||||
to={path}
|
||||
text={path}
|
||||
icon={BookmarkIcon}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarSpacer />
|
||||
{sidebarItems}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// Set up an API registry that merges together default implementations with ones provided through config.
|
||||
private setupApiRegistry(
|
||||
providedFactories: ApiFactory<any, any, any>[],
|
||||
): ApiHolder {
|
||||
const providedApis = new Set(
|
||||
providedFactories.map(factory => factory.implements),
|
||||
);
|
||||
|
||||
// Exlude any default API factory that we receive a factory for in the config
|
||||
const defaultFactories = Object.values(
|
||||
defaultApiFactories,
|
||||
).filter(factory => providedApis.has(factory.implements));
|
||||
const allFactories = [...defaultFactories, ...providedFactories];
|
||||
|
||||
// Use a test registry with dependency injection so that the consumer
|
||||
// can override APIs but still depend on the default implementations.
|
||||
const registry = new ApiTestRegistry();
|
||||
for (const factory of allFactories) {
|
||||
registry.register(factory);
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
private findPluginPaths(plugins: BackstagePlugin[]) {
|
||||
const paths = new Array<string>();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
for (const output of plugin.output()) {
|
||||
if (output.type === 'route') {
|
||||
paths.push(output.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(rugvip): Figure out patterns for how to allow in-house apps to build upon
|
||||
// this to provide their own plugin dev wrappers.
|
||||
|
||||
/**
|
||||
* Creates a dev app for rendering one or more plugins and exposing the touchpoints of the plugin.
|
||||
*/
|
||||
export function createDevApp() {
|
||||
return new DevAppBuilder();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 * from './devApp';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 '@testing-library/jest-dom/extend-expect';
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
}
|
||||
}
|
||||
@@ -14,56 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import HomeIcon from '@material-ui/icons/Home';
|
||||
import { ThemeProvider, CssBaseline } from '@material-ui/core';
|
||||
import {
|
||||
createApp,
|
||||
SidebarPage,
|
||||
Sidebar,
|
||||
SidebarItem,
|
||||
SidebarSpacer,
|
||||
ApiRegistry,
|
||||
} from '@backstage/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin, GraphQLEndpoints, graphQlBrowseApiRef } from '../src';
|
||||
|
||||
const graphQlBrowseApi = GraphQLEndpoints.from([
|
||||
GraphQLEndpoints.create({
|
||||
id: 'gitlab',
|
||||
title: 'GitLab',
|
||||
url: 'https://gitlab.com/api/graphql',
|
||||
}),
|
||||
GraphQLEndpoints.create({
|
||||
id: 'countries',
|
||||
title: 'Countries',
|
||||
url: 'https://countries.trevorblades.com/',
|
||||
}),
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
app.registerApis(ApiRegistry.from([[graphQlBrowseApiRef, graphQlBrowseApi]]));
|
||||
app.registerPlugin(plugin);
|
||||
const AppComponent = app.build();
|
||||
|
||||
const App: FC<{}> = () => {
|
||||
return (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarSpacer />
|
||||
<SidebarItem icon={HomeIcon} to="/graphiql" text="Home" />
|
||||
</Sidebar>
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
createDevApp()
|
||||
.registerPlugin(plugin)
|
||||
.registerApiFactory({
|
||||
implements: graphQlBrowseApiRef,
|
||||
deps: {},
|
||||
factory() {
|
||||
return GraphQLEndpoints.from([
|
||||
GraphQLEndpoints.create({
|
||||
id: 'gitlab',
|
||||
title: 'GitLab',
|
||||
url: 'https://gitlab.com/api/graphql',
|
||||
}),
|
||||
GraphQLEndpoints.create({
|
||||
id: 'countries',
|
||||
title: 'Countries',
|
||||
url: 'https://countries.trevorblades.com/',
|
||||
}),
|
||||
]);
|
||||
},
|
||||
})
|
||||
.render();
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.4",
|
||||
"@backstage/dev-utils": "^0.1.1-alpha.4",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.4",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
@@ -38,7 +40,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core": "^0.1.1-alpha.4",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.4",
|
||||
"@backstage/theme": "^0.1.1-alpha.4",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
|
||||
@@ -14,41 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import HomeIcon from '@material-ui/icons/Home';
|
||||
import { ThemeProvider, CssBaseline } from '@material-ui/core';
|
||||
import {
|
||||
createApp,
|
||||
SidebarPage,
|
||||
Sidebar,
|
||||
SidebarItem,
|
||||
SidebarSpacer,
|
||||
} from '@backstage/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
|
||||
const app = createApp();
|
||||
app.registerPlugin(plugin);
|
||||
const AppComponent = app.build();
|
||||
|
||||
const App: FC<{}> = () => {
|
||||
return (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarSpacer />
|
||||
<SidebarItem icon={HomeIcon} to="/home" text="Home" />
|
||||
</Sidebar>
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
createDevApp()
|
||||
.registerPlugin(plugin)
|
||||
.render();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.4",
|
||||
"@backstage/dev-utils": "^0.1.1-alpha.4",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
|
||||
Reference in New Issue
Block a user