Merge remote-tracking branch 'origin/master' into cost-insight-trending-line
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-graph': patch
|
||||
'@backstage/plugin-pagerduty': patch
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
'@backstage/plugin-splunk-on-call': patch
|
||||
'@backstage/plugin-techdocs-react': patch
|
||||
---
|
||||
|
||||
Internal refactor to improve tests
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-client': minor
|
||||
---
|
||||
|
||||
**BREAKING PRODUCERS**: Added a new `getEntitiesByRefs` endpoint to `CatalogApi`, for efficient batch fetching of entities by ref.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Do not use deprecated `LocationSpec` from the `@backstage/plugin-catalog-node` package
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Added new `POST /entities/by-refs` endpoint, which allows you to efficiently
|
||||
batch-fetch entities by their entity ref. This can be useful e.g. in graphql
|
||||
resolvers or similar contexts where you need to fetch many entities at the same
|
||||
time.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
'@backstage/plugin-events-backend': minor
|
||||
---
|
||||
|
||||
**BREAKING:** Remove required field `router` at `HttpPostIngressEventPublisher.fromConfig`
|
||||
and replace it with `bind(router: Router)`.
|
||||
Additionally, the path prefix `/http` will be added inside `HttpPostIngressEventPublisher`.
|
||||
|
||||
```diff
|
||||
// at packages/backend/src/plugins/events.ts
|
||||
const eventsRouter = Router();
|
||||
- const httpRouter = Router();
|
||||
- eventsRouter.use('/http', httpRouter);
|
||||
|
||||
const http = HttpPostIngressEventPublisher.fromConfig({
|
||||
config: env.config,
|
||||
logger: env.logger,
|
||||
- router: httpRouter,
|
||||
});
|
||||
+ http.bind(eventsRouter);
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Use Json types from @backstage/types
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Create a variable for minimum height and add a prop named 'fit' for determining if the graph height should grow or be contained.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-explore': patch
|
||||
---
|
||||
|
||||
Adds styling to graph forcing it to always fill out the available space.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/plugin-events-backend': patch
|
||||
'@backstage/plugin-events-node': minor
|
||||
---
|
||||
|
||||
Introduce a new interface `RequestDetails` to abstract `Request`
|
||||
providing access to request body and headers.
|
||||
|
||||
**BREAKING:** Replace `request: Request` with `request: RequestDetails` at `RequestValidator`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Added `lifecycleFactory` implementation.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-codescene': patch
|
||||
'@backstage/plugin-sonarqube': patch
|
||||
---
|
||||
|
||||
Updated dependency `rc-progress` to `3.4.1`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend-node': patch
|
||||
---
|
||||
|
||||
Updated indexer and decorator base classes to take advantage of features introduced in Node.js v16; be sure you are running a [supported version of Node.js](https://backstage.io/docs/releases/v1.8.0#node-16-and-18).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Registered shutdown hook in experimental catalog plugin.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
fixed `headerOptions` not passed to `TemplatePage` component
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Fixed Entity kind pluralisation in the `CatalogKindHeader` component.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-vault-backend': patch
|
||||
---
|
||||
|
||||
Use `express-promise-router` to catch errors properly.
|
||||
Add `403` error as a known one. It will now return a `NotAllowed` error.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-defaults': patch
|
||||
---
|
||||
|
||||
Added `lifecycleFactory` to default service factories.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Added initial support for registering shutdown hooks via `lifecycleServiceRef`.
|
||||
+6
-1
@@ -18,7 +18,7 @@ var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
plugins: ['notice'],
|
||||
plugins: ['notice', 'testing-library'],
|
||||
rules: {
|
||||
'notice/notice': [
|
||||
'error',
|
||||
@@ -45,5 +45,10 @@ module.exports = {
|
||||
"CallExpression[arguments.length=0] > MemberExpression[property.name='toUpperCase']",
|
||||
},
|
||||
],
|
||||
'testing-library/await-async-query': 'error',
|
||||
'testing-library/await-async-utils': 'error',
|
||||
'testing-library/no-await-sync-query': 'error',
|
||||
'testing-library/prefer-wait-for': 'error',
|
||||
'testing-library/no-dom-import': 'error',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -241,6 +241,7 @@ pageview
|
||||
parallelization
|
||||
parseable
|
||||
Patrik
|
||||
pattison
|
||||
Peloton
|
||||
performant
|
||||
Performant
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Automate merge message
|
||||
on:
|
||||
pull_request:
|
||||
branches: ['master']
|
||||
types: ['closed']
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: none
|
||||
checks: none
|
||||
contents: none
|
||||
deployments: none
|
||||
issues: none
|
||||
packages: none
|
||||
pages: none
|
||||
repository-projects: none
|
||||
security-events: none
|
||||
statuses: none
|
||||
|
||||
jobs:
|
||||
message:
|
||||
# prevent running towards forks, and only run on merged PRs
|
||||
if: github.repository == 'backstage/backstage' && github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: '${{ github.event.pull_request.merge_commit_sha }}'
|
||||
|
||||
- name: fetch base
|
||||
run: git fetch --depth 1 origin ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
# We avoid using the in-source script since this workflow has elevated permissions that we don't want to expose
|
||||
- name: Generate Message
|
||||
id: generate-message
|
||||
run: |
|
||||
rm -f generate.js
|
||||
wget -O generate.js https://raw.githubusercontent.com/backstage/backstage/master/scripts/generate-merge-message.js 1>&2
|
||||
node generate.js FETCH_HEAD > message.txt
|
||||
|
||||
- name: Post Message
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
script: |
|
||||
const owner = "backstage";
|
||||
const repo = "backstage";
|
||||
const body = require('fs').readFileSync('message.txt', 'utf8').trim();
|
||||
const issue_number = Number(process.env.ISSUE_NUMBER);
|
||||
|
||||
if (!body) {
|
||||
console.log(`skipping comment for #${issue_number}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`creating comment for #${issue_number}`);
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
});
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
|
||||
|
||||
- name: verify yarn dependency duplicates
|
||||
run: yarn dedupe --check
|
||||
run: node scripts/verify-lockfile-duplicates.js
|
||||
|
||||
- name: verify changesets
|
||||
run: node scripts/verify-changesets.js
|
||||
|
||||
@@ -32,6 +32,9 @@ jobs:
|
||||
run: yarn install --immutable
|
||||
working-directory: microsite
|
||||
|
||||
- name: verify yarn dependency duplicates
|
||||
run: node scripts/verify-lockfile-duplicates.js
|
||||
|
||||
- name: prettier
|
||||
run: yarn prettier:check
|
||||
working-directory: microsite
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -159,7 +159,12 @@ Check out the numbered markings - let's go through them one by one.
|
||||
the outcome of that. This example issues a `fetch` to the right service and
|
||||
issues a full refresh of its entity bucket based on that.
|
||||
5. The method translates the foreign data model to the native `Entity` form, as
|
||||
expected by the catalog.
|
||||
expected by the catalog. The `Entity` must include the
|
||||
`backstage.io/managed-by-location` and
|
||||
`backstage.io/managed-by-origin-location annotations`; otherwise, it will not
|
||||
appear in the Catalog and will generate warning logs. The
|
||||
[Well-known Annotations](./well-known-annotations.md#backstageiomanaged-by-location)
|
||||
documentation has guidance on what values to use for these.
|
||||
6. Finally, we issue a "mutation" to the catalog. This persists the entities in
|
||||
our own bucket, along with an optional `locationKey` that's used for conflict
|
||||
checks. But this is a bigger topic - mutations warrant their own explanatory
|
||||
|
||||
@@ -393,6 +393,98 @@ You can add more icons, if the [default icons](https://github.com/backstage/back
|
||||
|
||||
Note: If the icon is not available as one of the default icons or one you've added then it will fall back to Material UI's `LanguageIcon`
|
||||
|
||||
## Custom Sidebar
|
||||
|
||||
As you've seen there are many ways that you can customize your Backstage app. The following section will show you how you can customize the sidebar.
|
||||
|
||||
### Sidebar Sub-menu
|
||||
|
||||
For this example we'll show you how you can expand the sidebar with a sub-menu:
|
||||
|
||||
1. Open the `Root.tsx` file located in `packages/app/src/components/Root` as this is where the sidebar code lives
|
||||
2. Then we want to add the following imports for the icons:
|
||||
|
||||
```ts
|
||||
import ApiIcon from '@material-ui/icons/Extension';
|
||||
import ComponentIcon from '@material-ui/icons/Memory';
|
||||
import DomainIcon from '@material-ui/icons/Apartment';
|
||||
import ResourceIcon from '@material-ui/icons/Work';
|
||||
import SystemIcon from '@material-ui/icons/Category';
|
||||
import UserIcon from '@material-ui/icons/Person';
|
||||
```
|
||||
|
||||
3. Then update the `@backstage/core-components` import like this:
|
||||
|
||||
```diff
|
||||
import {
|
||||
Sidebar,
|
||||
sidebarConfig,
|
||||
SidebarDivider,
|
||||
SidebarGroup,
|
||||
SidebarItem,
|
||||
SidebarPage,
|
||||
SidebarScrollWrapper,
|
||||
SidebarSpace,
|
||||
useSidebarOpenState,
|
||||
Link,
|
||||
+ GroupIcon,
|
||||
+ SidebarSubmenu,
|
||||
+ SidebarSubmenuItem,
|
||||
} from '@backstage/core-components';
|
||||
```
|
||||
|
||||
4. Finally replace `<SidebarItem icon={HomeIcon} to="catalog" text="Home" />` with this:
|
||||
|
||||
```ts
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home">
|
||||
<SidebarSubmenu title="Catalog">
|
||||
<SidebarSubmenuItem
|
||||
title="Domains"
|
||||
to="catalog?filters[kind]=domain"
|
||||
icon={DomainIcon}
|
||||
/>
|
||||
<SidebarSubmenuItem
|
||||
title="Systems"
|
||||
to="catalog?filters[kind]=system"
|
||||
icon={SystemIcon}
|
||||
/>
|
||||
<SidebarSubmenuItem
|
||||
title="Components"
|
||||
to="catalog?filters[kind]=component"
|
||||
icon={ComponentIcon}
|
||||
/>
|
||||
<SidebarSubmenuItem
|
||||
title="APIs"
|
||||
to="catalog?filters[kind]=api"
|
||||
icon={ApiIcon}
|
||||
/>
|
||||
<SidebarDivider />
|
||||
<SidebarSubmenuItem
|
||||
title="Resources"
|
||||
to="catalog?filters[kind]=resource"
|
||||
icon={ResourceIcon}
|
||||
/>
|
||||
<SidebarDivider />
|
||||
<SidebarSubmenuItem
|
||||
title="Groups"
|
||||
to="catalog?filters[kind]=group"
|
||||
icon={GroupIcon}
|
||||
/>
|
||||
<SidebarSubmenuItem
|
||||
title="Users"
|
||||
to="catalog?filters[kind]=user"
|
||||
icon={UserIcon}
|
||||
/>
|
||||
</SidebarSubmenu>
|
||||
</SidebarItem>
|
||||
```
|
||||
|
||||
When you startup your Backstage app and hover over the Home option on the sidebar you'll now see a nice sub-menu appear with links to the various Kinds in your Catalog. It would look like this:
|
||||
|
||||

|
||||
|
||||
You can see more ways to use this in the [Storybook Sidebar examples](https://backstage.io/storybook/?path=/story/layout-sidebar--sample-scalable-sidebar)
|
||||
|
||||
## Custom Homepage
|
||||
|
||||
In addition to a custom theme, a custom logo, you can also customize the
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Cloudsmith
|
||||
author: Roadie
|
||||
authorUrl: https://roadie.io
|
||||
category: CI/CD
|
||||
description: Show Cloudsmith Repository stats on your backstage homepage.
|
||||
documentation: https://github.com/RoadieHQ/roadie-backstage-plugins/tree/main/plugins/frontend/backstage-plugin-cloudsmith
|
||||
iconUrl: https://cloudsmith.com/img/cloudsmith-mini-dark.svg
|
||||
npmPackageName: '@roadiehq/backstage-plugin-cloudsmith'
|
||||
tags:
|
||||
- dashboards
|
||||
- monitoring
|
||||
addedDate: '2022-11-18'
|
||||
@@ -6,7 +6,7 @@ category: Deployment # A single category e.g. CI, Machine Learning, Services, Mo
|
||||
description: |
|
||||
Show workloads, environments and resources deployed by Humanitec Platform Orchestrator.
|
||||
Plugin includes an Entity ComponentCard, Backend API route and scaffolder actions.
|
||||
documentation: https://github.com/thefrontside/backstage/tree/main/plugins/humanitec
|
||||
documentation: https://github.com/thefrontside/playhouse/tree/main/plugins/humanitec
|
||||
iconUrl: img/humanitec-logo.png
|
||||
npmPackageName: '@frontside/backstage-plugin-humanitec'
|
||||
addedDate: '2022-06-22'
|
||||
|
||||
+12
-87
@@ -281,14 +281,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-validator-identifier@npm:^7.10.4, @babel/helper-validator-identifier@npm:^7.12.11":
|
||||
version: 7.12.11
|
||||
resolution: "@babel/helper-validator-identifier@npm:7.12.11"
|
||||
checksum: e604c6bf890704fc46c1ae13bf23afb242b810224ec3403bba67cdbf0d8dabfec4b82123d6dfb18135a0ee3f7f79218583c819363ebb5e04a0a49d8418db7fce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-validator-identifier@npm:^7.18.6":
|
||||
"@babel/helper-validator-identifier@npm:^7.10.4, @babel/helper-validator-identifier@npm:^7.18.6":
|
||||
version: 7.18.6
|
||||
resolution: "@babel/helper-validator-identifier@npm:7.18.6"
|
||||
checksum: e295254d616bbe26e48c196a198476ab4d42a73b90478c9842536cf910ead887f5af6b5c4df544d3052a25ccb3614866fa808dc1e3a5a4291acd444e243c0648
|
||||
@@ -1241,18 +1234,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/types@npm:^7.10.4, @babel/types@npm:^7.10.5, @babel/types@npm:^7.12.1, @babel/types@npm:^7.12.10, @babel/types@npm:^7.12.11, @babel/types@npm:^7.12.12, @babel/types@npm:^7.12.5, @babel/types@npm:^7.12.6, @babel/types@npm:^7.12.7, @babel/types@npm:^7.4.4":
|
||||
version: 7.12.12
|
||||
resolution: "@babel/types@npm:7.12.12"
|
||||
dependencies:
|
||||
"@babel/helper-validator-identifier": ^7.12.11
|
||||
lodash: ^4.17.19
|
||||
to-fast-properties: ^2.0.0
|
||||
checksum: 1eefe9468573a32491030f2986f820eb4289ff0b93e437b97f665e73cc8dae0ec0bdd95f37995bb3b70b5ce2a9a45a302affbbad497150021cf874c5ef10e361
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/types@npm:^7.8.3":
|
||||
"@babel/types@npm:^7.10.4, @babel/types@npm:^7.10.5, @babel/types@npm:^7.12.1, @babel/types@npm:^7.12.10, @babel/types@npm:^7.12.11, @babel/types@npm:^7.12.12, @babel/types@npm:^7.12.5, @babel/types@npm:^7.12.6, @babel/types@npm:^7.12.7, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3":
|
||||
version: 7.18.10
|
||||
resolution: "@babel/types@npm:7.18.10"
|
||||
dependencies:
|
||||
@@ -2917,7 +2899,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debug@npm:4, debug@npm:^4.3.3":
|
||||
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.3":
|
||||
version: 4.3.4
|
||||
resolution: "debug@npm:4.3.4"
|
||||
dependencies:
|
||||
@@ -2929,7 +2911,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debug@npm:4.3.1, debug@npm:^4.1.0":
|
||||
"debug@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "debug@npm:4.3.1"
|
||||
dependencies:
|
||||
@@ -4275,21 +4257,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"glob@npm:^7.0.0, glob@npm:^7.0.5, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.6":
|
||||
version: 7.2.0
|
||||
resolution: "glob@npm:7.2.0"
|
||||
dependencies:
|
||||
fs.realpath: ^1.0.0
|
||||
inflight: ^1.0.4
|
||||
inherits: 2
|
||||
minimatch: ^3.0.4
|
||||
once: ^1.3.0
|
||||
path-is-absolute: ^1.0.0
|
||||
checksum: 78a8ea942331f08ed2e055cb5b9e40fe6f46f579d7fd3d694f3412fe5db23223d29b7fee1575440202e9a7ff9a72ab106a39fee39934c7bedafe5e5f8ae20134
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"glob@npm:^7.1.4":
|
||||
"glob@npm:^7.0.0, glob@npm:^7.0.5, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6":
|
||||
version: 7.2.3
|
||||
resolution: "glob@npm:7.2.3"
|
||||
dependencies:
|
||||
@@ -4430,14 +4398,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graceful-fs@npm:^4.1.10, graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0":
|
||||
version: 4.2.4
|
||||
resolution: "graceful-fs@npm:4.2.4"
|
||||
checksum: 9d58c444eb4f391ce30b451aae8a8af2bd675d9f6f624719e97306f571ab89b2bd2b5f9025199bc63a2edfe2e53e7701554012f32a708148d53aa689163728cc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graceful-fs@npm:^4.2.6":
|
||||
"graceful-fs@npm:^4.1.10, graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6":
|
||||
version: 4.2.10
|
||||
resolution: "graceful-fs@npm:4.2.10"
|
||||
checksum: 3f109d70ae123951905d85032ebeae3c2a5a7a997430df00ea30df0e3a6c60cf6689b109654d6fdacd28810a053348c4d14642da1d075049e6be1ba5216218da
|
||||
@@ -5056,15 +5017,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-core-module@npm:^2.1.0":
|
||||
version: 2.2.0
|
||||
resolution: "is-core-module@npm:2.2.0"
|
||||
dependencies:
|
||||
has: ^1.0.3
|
||||
checksum: 61e2aff4a7db4f8f7d5a97b484808af17290f4197b34a797cd3d3d27b6b448951064f8d3d6ceae4394fa9b7e6cf08aacd2ba7a17ef6352e922fe803580fbde56
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-core-module@npm:^2.8.0":
|
||||
version: 2.8.1
|
||||
resolution: "is-core-module@npm:2.8.1"
|
||||
@@ -6196,7 +6148,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minimatch@npm:3.0.4, minimatch@npm:^3.0.4, minimatch@npm:~3.0.2":
|
||||
"minimatch@npm:3.0.4, minimatch@npm:~3.0.2":
|
||||
version: 3.0.4
|
||||
resolution: "minimatch@npm:3.0.4"
|
||||
dependencies:
|
||||
@@ -6205,7 +6157,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minimatch@npm:^3.1.1":
|
||||
"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1":
|
||||
version: 3.1.2
|
||||
resolution: "minimatch@npm:3.1.2"
|
||||
dependencies:
|
||||
@@ -6941,7 +6893,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-parse@npm:^1.0.6, path-parse@npm:^1.0.7":
|
||||
"path-parse@npm:^1.0.7":
|
||||
version: 1.0.7
|
||||
resolution: "path-parse@npm:1.0.7"
|
||||
checksum: 49abf3d81115642938a8700ec580da6e830dde670be21893c62f4e10bd7dd4c3742ddc603fe24f898cba7eb0c6bc1777f8d9ac14185d34540c6d4d80cd9cae8a
|
||||
@@ -7967,7 +7919,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve@npm:^1.1.6":
|
||||
"resolve@npm:^1.1.6, resolve@npm:^1.10.0":
|
||||
version: 1.21.0
|
||||
resolution: "resolve@npm:1.21.0"
|
||||
dependencies:
|
||||
@@ -7980,17 +7932,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve@npm:^1.10.0":
|
||||
version: 1.19.0
|
||||
resolution: "resolve@npm:1.19.0"
|
||||
dependencies:
|
||||
is-core-module: ^2.1.0
|
||||
path-parse: ^1.0.6
|
||||
checksum: a05b356e47b85ad3613d9e2a39a824f3c27f4fcad9c9ff6c7cc71a2e314c5904a90ab37481ad0069d03cab9eaaac6eb68aca1bc3355fdb05f1045cd50e2aacea
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve@patch:resolve@^1.1.6#~builtin<compat/resolve>":
|
||||
"resolve@patch:resolve@^1.1.6#~builtin<compat/resolve>, resolve@patch:resolve@^1.10.0#~builtin<compat/resolve>":
|
||||
version: 1.21.0
|
||||
resolution: "resolve@patch:resolve@npm%3A1.21.0#~builtin<compat/resolve>::version=1.21.0&hash=07638b"
|
||||
dependencies:
|
||||
@@ -8003,16 +7945,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"resolve@patch:resolve@^1.10.0#~builtin<compat/resolve>":
|
||||
version: 1.19.0
|
||||
resolution: "resolve@patch:resolve@npm%3A1.19.0#~builtin<compat/resolve>::version=1.19.0&hash=07638b"
|
||||
dependencies:
|
||||
is-core-module: ^2.1.0
|
||||
path-parse: ^1.0.6
|
||||
checksum: 2443b94d347e6946c87c85faf13071f605e609e0b54784829b0ed2b917d050bfc1cbaf4ecc6453f224cfa7d0c5dcd97cbb273454cd210bee68e4af15c1a5abc9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"responselike@npm:1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "responselike@npm:1.0.2"
|
||||
@@ -8313,14 +8245,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2":
|
||||
version: 3.0.3
|
||||
resolution: "signal-exit@npm:3.0.3"
|
||||
checksum: f0169d3f1263d06df32ca072b0bf33b34c6f8f0341a7a1621558a2444dfbe8f5fec76b35537fcc6f0bc4944bdb5336fe0bdcf41a5422c4e45a1dba3f45475e6c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"signal-exit@npm:^3.0.7":
|
||||
"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7":
|
||||
version: 3.0.7
|
||||
resolution: "signal-exit@npm:3.0.7"
|
||||
checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318
|
||||
|
||||
+9
-1
@@ -33,7 +33,8 @@
|
||||
"build-storybook": "yarn ./storybook run build-storybook",
|
||||
"techdocs-cli": "node scripts/techdocs-cli.js",
|
||||
"techdocs-cli:dev": "cross-env TECHDOCS_CLI_DEV_MODE=true node scripts/techdocs-cli.js",
|
||||
"prepare": "husky install"
|
||||
"prepare": "husky install",
|
||||
"postinstall": "husky install"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -70,6 +71,7 @@
|
||||
"e2e-test": "workspace:*",
|
||||
"eslint": "^8.6.0",
|
||||
"eslint-plugin-notice": "^0.9.10",
|
||||
"eslint-plugin-testing-library": "^5.9.1",
|
||||
"fs-extra": "10.1.0",
|
||||
"husky": "^8.0.0",
|
||||
"lint-staged": "^13.0.0",
|
||||
@@ -92,6 +94,12 @@
|
||||
],
|
||||
"*.md": [
|
||||
"node ./scripts/check-docs-quality"
|
||||
],
|
||||
"./yarn.lock": [
|
||||
"node ./scripts/verify-lockfile-duplicates --fix"
|
||||
],
|
||||
"*/yarn.lock": [
|
||||
"node ./scripts/verify-lockfile-duplicates --fix"
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@3.2.3"
|
||||
|
||||
@@ -25,14 +25,17 @@ describe('App', () => {
|
||||
|
||||
it('should display support info when clicking the button', () => {
|
||||
cy.visit('/');
|
||||
// eslint-disable-next-line testing-library/await-async-query
|
||||
cy.findByTestId('support-button').click({ force: true });
|
||||
cy.contains('#backstage');
|
||||
});
|
||||
|
||||
it('should display error message when triggering it', () => {
|
||||
cy.visit('/');
|
||||
// eslint-disable-next-line testing-library/await-async-query
|
||||
cy.findByTestId('error-button').click({ force: true });
|
||||
cy.contains('Error: Oh no!');
|
||||
// eslint-disable-next-line testing-library/await-async-query
|
||||
cy.findByTestId('error-button-close').click({ force: true });
|
||||
});
|
||||
|
||||
@@ -55,6 +58,7 @@ describe('App', () => {
|
||||
cy.contains('Token')
|
||||
.get('input[name=github-auth-tf]')
|
||||
.type('password', { force: true });
|
||||
// eslint-disable-next-line testing-library/await-async-query
|
||||
cy.findByTestId('github-auth-button').click({ force: true });
|
||||
cy.contains(`Welcome, ${name}!`);
|
||||
cy.contains('Logout').click({ force: true });
|
||||
|
||||
@@ -47,12 +47,21 @@ import {
|
||||
SidebarSpace,
|
||||
Link,
|
||||
useSidebarOpenState,
|
||||
SidebarSubmenu,
|
||||
SidebarSubmenuItem,
|
||||
} from '@backstage/core-components';
|
||||
import { MyGroupsSidebarItem } from '@backstage/plugin-org';
|
||||
import GroupIcon from '@material-ui/icons/People';
|
||||
import { SearchModal } from '../search/SearchModal';
|
||||
import Score from '@material-ui/icons/Score';
|
||||
|
||||
import ApiIcon from '@material-ui/icons/Extension';
|
||||
import ComponentIcon from '@material-ui/icons/Memory';
|
||||
import DomainIcon from '@material-ui/icons/Apartment';
|
||||
import ResourceIcon from '@material-ui/icons/Work';
|
||||
import SystemIcon from '@material-ui/icons/Category';
|
||||
import UserIcon from '@material-ui/icons/Person';
|
||||
|
||||
const useSidebarLogoStyles = makeStyles({
|
||||
root: {
|
||||
width: sidebarConfig.drawerWidthClosed,
|
||||
@@ -93,7 +102,47 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
|
||||
<SidebarDivider />
|
||||
<SidebarGroup label="Menu" icon={<MenuIcon />}>
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
|
||||
<SidebarItem icon={HomeIcon} to="catalog" text="Home">
|
||||
<SidebarSubmenu title="Catalog">
|
||||
<SidebarSubmenuItem
|
||||
title="Domains"
|
||||
to="catalog?filters[kind]=domain"
|
||||
icon={DomainIcon}
|
||||
/>
|
||||
<SidebarSubmenuItem
|
||||
title="Systems"
|
||||
to="catalog?filters[kind]=system"
|
||||
icon={SystemIcon}
|
||||
/>
|
||||
<SidebarSubmenuItem
|
||||
title="Components"
|
||||
to="catalog?filters[kind]=component"
|
||||
icon={ComponentIcon}
|
||||
/>
|
||||
<SidebarSubmenuItem
|
||||
title="APIs"
|
||||
to="catalog?filters[kind]=api"
|
||||
icon={ApiIcon}
|
||||
/>
|
||||
<SidebarDivider />
|
||||
<SidebarSubmenuItem
|
||||
title="Resources"
|
||||
to="catalog?filters[kind]=resource"
|
||||
icon={ResourceIcon}
|
||||
/>
|
||||
<SidebarDivider />
|
||||
<SidebarSubmenuItem
|
||||
title="Groups"
|
||||
to="catalog?filters[kind]=group"
|
||||
icon={GroupIcon}
|
||||
/>
|
||||
<SidebarSubmenuItem
|
||||
title="Users"
|
||||
to="catalog?filters[kind]=user"
|
||||
icon={UserIcon}
|
||||
/>
|
||||
</SidebarSubmenu>
|
||||
</SidebarItem>
|
||||
<MyGroupsSidebarItem
|
||||
singularTitle="My Squad"
|
||||
pluralTitle="My Squads"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
```ts
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { BackendLifecycle } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ExtensionPoint } from '@backstage/backend-plugin-api';
|
||||
import { HttpRouterService } from '@backstage/backend-plugin-api';
|
||||
@@ -66,6 +67,11 @@ export type HttpRouterFactoryOptions = {
|
||||
indexPlugin?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const lifecycleFactory: (
|
||||
options?: undefined,
|
||||
) => ServiceFactory<BackendLifecycle>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const loggerFactory: (options?: undefined) => ServiceFactory<Logger>;
|
||||
|
||||
|
||||
@@ -25,4 +25,5 @@ export { schedulerFactory } from './schedulerService';
|
||||
export { tokenManagerFactory } from './tokenManagerService';
|
||||
export { urlReaderFactory } from './urlReaderService';
|
||||
export { httpRouterFactory } from './httpRouterService';
|
||||
export { lifecycleFactory } from './lifecycleService';
|
||||
export type { HttpRouterFactoryOptions } from './httpRouterService';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { BackendLifecycleImpl } from './lifecycleService';
|
||||
|
||||
describe('lifecycleService', () => {
|
||||
it('should execute registered shutdown hook', async () => {
|
||||
const service = new BackendLifecycleImpl(getVoidLogger());
|
||||
const hook = jest.fn();
|
||||
service.addShutdownHook({
|
||||
pluginId: 'test',
|
||||
fn: async () => {
|
||||
hook();
|
||||
},
|
||||
});
|
||||
// should not execute the hook more than once.
|
||||
await service.shutdown();
|
||||
await service.shutdown();
|
||||
await service.shutdown();
|
||||
expect(hook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not throw errors', async () => {
|
||||
const service = new BackendLifecycleImpl(getVoidLogger());
|
||||
service.addShutdownHook({
|
||||
pluginId: 'test',
|
||||
fn: async () => {
|
||||
throw new Error('oh no');
|
||||
},
|
||||
});
|
||||
await expect(service.shutdown()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
import {
|
||||
BackendLifecycle,
|
||||
createServiceFactory,
|
||||
lifecycleServiceRef,
|
||||
loggerToWinstonLogger,
|
||||
pluginMetadataServiceRef,
|
||||
rootLoggerServiceRef,
|
||||
BackendLifecycleShutdownHook,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
const CALLBACKS = ['SIGTERM', 'SIGINT', 'beforeExit'];
|
||||
export class BackendLifecycleImpl {
|
||||
constructor(private readonly logger: Logger) {
|
||||
CALLBACKS.map(signal => process.on(signal, () => this.shutdown()));
|
||||
}
|
||||
|
||||
#isCalled = false;
|
||||
#shutdownTasks: Array<BackendLifecycleShutdownHook & { pluginId: string }> =
|
||||
[];
|
||||
|
||||
addShutdownHook(
|
||||
options: BackendLifecycleShutdownHook & { pluginId: string },
|
||||
): void {
|
||||
this.#shutdownTasks.push(options);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.#isCalled) {
|
||||
return;
|
||||
}
|
||||
this.#isCalled = true;
|
||||
|
||||
this.logger.info(`Running ${this.#shutdownTasks.length} shutdown tasks...`);
|
||||
await Promise.all(
|
||||
this.#shutdownTasks.map(hook =>
|
||||
Promise.resolve()
|
||||
.then(() => hook.fn())
|
||||
.catch(e => {
|
||||
this.logger.error(
|
||||
`Shutdown hook registered by plugin '${hook.pluginId}' failed with: ${e}`,
|
||||
);
|
||||
})
|
||||
.then(() =>
|
||||
this.logger.info(
|
||||
`Successfully ran shutdown hook registered by plugin ${hook.pluginId}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PluginScopedLifecycleImpl implements BackendLifecycle {
|
||||
constructor(
|
||||
private readonly lifecycle: BackendLifecycleImpl,
|
||||
private readonly pluginId: string,
|
||||
) {}
|
||||
addShutdownHook(options: BackendLifecycleShutdownHook): void {
|
||||
this.lifecycle.addShutdownHook({ ...options, pluginId: this.pluginId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows plugins to register shutdown hooks that are run when the process is about to exit.
|
||||
* @public */
|
||||
export const lifecycleFactory = createServiceFactory({
|
||||
service: lifecycleServiceRef,
|
||||
deps: {
|
||||
logger: rootLoggerServiceRef,
|
||||
plugin: pluginMetadataServiceRef,
|
||||
},
|
||||
async factory({ logger }) {
|
||||
const rootLifecycle = new BackendLifecycleImpl(
|
||||
loggerToWinstonLogger(logger),
|
||||
);
|
||||
return async ({ plugin }) => {
|
||||
return new PluginScopedLifecycleImpl(rootLifecycle, plugin.getId());
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
databaseFactory,
|
||||
discoveryFactory,
|
||||
httpRouterFactory,
|
||||
lifecycleFactory,
|
||||
loggerFactory,
|
||||
permissionsFactory,
|
||||
rootLoggerFactory,
|
||||
@@ -43,6 +44,7 @@ export const defaultServiceFactories = [
|
||||
tokenManagerFactory,
|
||||
urlReaderFactory,
|
||||
httpRouterFactory,
|
||||
lifecycleFactory,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,16 @@ export interface BackendFeature {
|
||||
register(reg: BackendRegistrationPoints): void;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface BackendLifecycle {
|
||||
addShutdownHook(options: BackendLifecycleShutdownHook): void;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type BackendLifecycleShutdownHook = {
|
||||
fn: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface BackendModuleConfig<TOptions> {
|
||||
// (undocumented)
|
||||
@@ -158,6 +168,9 @@ export interface HttpRouterService {
|
||||
// @public (undocumented)
|
||||
export const httpRouterServiceRef: ServiceRef<HttpRouterService, 'plugin'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const lifecycleServiceRef: ServiceRef<BackendLifecycle, 'plugin'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface Logger {
|
||||
// (undocumented)
|
||||
|
||||
@@ -28,4 +28,9 @@ export { permissionsServiceRef } from './permissionsServiceRef';
|
||||
export { schedulerServiceRef } from './schedulerServiceRef';
|
||||
export { rootLoggerServiceRef } from './rootLoggerServiceRef';
|
||||
export { pluginMetadataServiceRef } from './pluginMetadataServiceRef';
|
||||
export { lifecycleServiceRef } from './lifecycleServiceRef';
|
||||
export type {
|
||||
BackendLifecycle,
|
||||
BackendLifecycleShutdownHook,
|
||||
} from './lifecycleServiceRef';
|
||||
export type { PluginMetadata } from './pluginMetadataServiceRef';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { createServiceRef } from '../system/types';
|
||||
|
||||
/**
|
||||
* @public
|
||||
**/
|
||||
export type BackendLifecycleShutdownHook = {
|
||||
fn: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
**/
|
||||
export interface BackendLifecycle {
|
||||
/**
|
||||
* Register a function to be called when the backend is shutting down.
|
||||
*/
|
||||
addShutdownHook(options: BackendLifecycleShutdownHook): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const lifecycleServiceRef = createServiceRef<BackendLifecycle>({
|
||||
id: 'core.lifecycle',
|
||||
scope: 'plugin',
|
||||
});
|
||||
@@ -27,14 +27,12 @@ export default async function createPlugin(
|
||||
subscribers: EventSubscriber[],
|
||||
): Promise<Router> {
|
||||
const eventsRouter = Router();
|
||||
const httpRouter = Router();
|
||||
eventsRouter.use('/http', httpRouter);
|
||||
|
||||
const http = HttpPostIngressEventPublisher.fromConfig({
|
||||
config: env.config,
|
||||
logger: env.logger,
|
||||
router: httpRouter,
|
||||
});
|
||||
http.bind(eventsRouter);
|
||||
|
||||
await new EventsBackend(env.logger)
|
||||
.addPublishers(http)
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface CatalogApi {
|
||||
request?: GetEntitiesRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntitiesResponse>;
|
||||
getEntitiesByRefs(
|
||||
request: GetEntitiesByRefsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntitiesByRefsResponse>;
|
||||
getEntityAncestors(
|
||||
request: GetEntityAncestorsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
@@ -91,6 +95,10 @@ export class CatalogClient implements CatalogApi {
|
||||
request?: GetEntitiesRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntitiesResponse>;
|
||||
getEntitiesByRefs(
|
||||
request: GetEntitiesByRefsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntitiesByRefsResponse>;
|
||||
getEntityAncestors(
|
||||
request: GetEntityAncestorsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
@@ -145,14 +153,30 @@ export interface CatalogRequestOptions {
|
||||
export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE =
|
||||
'backstage.io/catalog-processing';
|
||||
|
||||
// @public
|
||||
export type EntityFieldsQuery = string[];
|
||||
|
||||
// @public
|
||||
export type EntityFilterQuery =
|
||||
| Record<string, string | symbol | (string | symbol)[]>[]
|
||||
| Record<string, string | symbol | (string | symbol)[]>;
|
||||
|
||||
// @public
|
||||
export interface GetEntitiesByRefsRequest {
|
||||
entityRefs: string[];
|
||||
fields?: EntityFieldsQuery | undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface GetEntitiesByRefsResponse {
|
||||
items: Array<Entity | undefined>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface GetEntitiesRequest {
|
||||
after?: string;
|
||||
fields?: string[] | undefined;
|
||||
filter?:
|
||||
| Record<string, string | symbol | (string | symbol)[]>[]
|
||||
| Record<string, string | symbol | (string | symbol)[]>
|
||||
| undefined;
|
||||
fields?: EntityFieldsQuery;
|
||||
filter?: EntityFilterQuery;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@@ -195,6 +195,35 @@ describe('CatalogClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntitiesByRefs', () => {
|
||||
it('encodes and decodes the query correctly', async () => {
|
||||
const entity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Test2',
|
||||
namespace: 'test1',
|
||||
},
|
||||
};
|
||||
server.use(
|
||||
rest.post(`${mockBaseUrl}/entities/by-refs`, async (req, res, ctx) => {
|
||||
expect(req.url.searchParams.get('fields')).toBe('a,b');
|
||||
await expect(req.json()).resolves.toEqual({
|
||||
entityRefs: ['k:n/a', 'k:n/b'],
|
||||
});
|
||||
return res(ctx.json({ items: [entity, null] }));
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await client.getEntitiesByRefs(
|
||||
{ entityRefs: ['k:n/a', 'k:n/b'], fields: ['a', 'b'] },
|
||||
{ token },
|
||||
);
|
||||
|
||||
expect(response).toEqual({ items: [entity, null] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntityByRef', () => {
|
||||
const existingEntity: Entity = {
|
||||
apiVersion: 'v1',
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
GetEntityFacetsRequest,
|
||||
GetEntityFacetsResponse,
|
||||
ValidateEntityResponse,
|
||||
GetEntitiesByRefsRequest,
|
||||
GetEntitiesByRefsResponse,
|
||||
} from './types/api';
|
||||
import { DiscoveryApi } from './types/discovery';
|
||||
import { FetchApi } from './types/fetch';
|
||||
@@ -169,6 +171,40 @@ export class CatalogClient implements CatalogApi {
|
||||
return { items: entities.sort(refCompare) };
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc CatalogApi.getEntitiesByRefs}
|
||||
*/
|
||||
async getEntitiesByRefs(
|
||||
request: GetEntitiesByRefsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntitiesByRefsResponse> {
|
||||
const params: string[] = [];
|
||||
if (request.fields?.length) {
|
||||
params.push(`fields=${request.fields.map(encodeURIComponent).join(',')}`);
|
||||
}
|
||||
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('catalog');
|
||||
const query = params.length ? `?${params.join('&')}` : '';
|
||||
const url = `${baseUrl}/entities/by-refs${query}`;
|
||||
|
||||
const response = await this.fetchApi.fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ entityRefs: request.entityRefs }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
const { items } = await response.json();
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc CatalogApi.getEntityByRef}
|
||||
*/
|
||||
|
||||
@@ -29,6 +29,75 @@ export const CATALOG_FILTER_EXISTS = Symbol.for(
|
||||
'CATALOG_FILTER_EXISTS_0e15b590c0b343a2bae3e787e84c2111',
|
||||
);
|
||||
|
||||
/**
|
||||
* A key-value based filter expression for entities.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Each key of a record is a dot-separated path into the entity structure, e.g.
|
||||
* `metadata.name`.
|
||||
*
|
||||
* The values are literal values to match against. As a value you can also pass
|
||||
* in the symbol `CATALOG_FILTER_EXISTS` (exported from this package), which
|
||||
* means that you assert on the existence of that key, no matter what its value
|
||||
* is.
|
||||
*
|
||||
* All matching of keys and values is case insensitive.
|
||||
*
|
||||
* If multiple filter sets are given as an array, then there is effectively an
|
||||
* OR between each filter set.
|
||||
*
|
||||
* Within one filter set, there is effectively an AND between the various keys.
|
||||
*
|
||||
* Within one key, if there are more than one value, then there is effectively
|
||||
* an OR between them.
|
||||
*
|
||||
* Example: For an input of
|
||||
*
|
||||
* ```
|
||||
* [
|
||||
* { kind: ['API', 'Component'] },
|
||||
* { 'metadata.name': 'a', 'metadata.namespace': 'b' }
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* This effectively means
|
||||
*
|
||||
* ```
|
||||
* (kind = EITHER 'API' OR 'Component')
|
||||
* OR
|
||||
* (metadata.name = 'a' AND metadata.namespace = 'b' )
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type EntityFilterQuery =
|
||||
| Record<string, string | symbol | (string | symbol)[]>[]
|
||||
| Record<string, string | symbol | (string | symbol)[]>;
|
||||
|
||||
/**
|
||||
* A set of dot-separated paths into an entity's keys, showing what parts of an
|
||||
* entity to include in a response, and excluding all others.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Example: For an input of `['kind', 'metadata.annotations']`, then response
|
||||
* objects will be shaped like
|
||||
*
|
||||
* ```
|
||||
* {
|
||||
* "kind": "Component",
|
||||
* "metadata": {
|
||||
* "annotations": {
|
||||
* "foo": "bar"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export type EntityFieldsQuery = string[];
|
||||
|
||||
/**
|
||||
* The request type for {@link CatalogClient.getEntities}.
|
||||
*
|
||||
@@ -36,67 +105,14 @@ export const CATALOG_FILTER_EXISTS = Symbol.for(
|
||||
*/
|
||||
export interface GetEntitiesRequest {
|
||||
/**
|
||||
* If given, return only entities that match the given patterns.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If multiple filter sets are given as an array, then there is effectively an
|
||||
* OR between each filter set.
|
||||
*
|
||||
* Within one filter set, there is effectively an AND between the various
|
||||
* keys.
|
||||
*
|
||||
* Within one key, if there are more than one value, then there is effectively
|
||||
* an OR between them.
|
||||
*
|
||||
* Example: For an input of
|
||||
*
|
||||
* ```
|
||||
* [
|
||||
* { kind: ['API', 'Component'] },
|
||||
* { 'metadata.name': 'a', 'metadata.namespace': 'b' }
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* This effectively means
|
||||
*
|
||||
* ```
|
||||
* (kind = EITHER 'API' OR 'Component')
|
||||
* OR
|
||||
* (metadata.name = 'a' AND metadata.namespace = 'b' )
|
||||
* ```
|
||||
*
|
||||
* Each key is a dot separated path in each object.
|
||||
*
|
||||
* As a value you can also pass in the symbol `CATALOG_FILTER_EXISTS`
|
||||
* (exported from this package), which means that you assert on the existence
|
||||
* of that key, no matter what its value is.
|
||||
* If given, return only entities that match the given filter.
|
||||
*/
|
||||
filter?:
|
||||
| Record<string, string | symbol | (string | symbol)[]>[]
|
||||
| Record<string, string | symbol | (string | symbol)[]>
|
||||
| undefined;
|
||||
filter?: EntityFilterQuery;
|
||||
/**
|
||||
* If given, return only the parts of each entity that match those dot
|
||||
* separated paths in each object.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Example: For an input of `['kind', 'metadata.annotations']`, then response
|
||||
* objects will be shaped like
|
||||
*
|
||||
* ```
|
||||
* {
|
||||
* "kind": "Component",
|
||||
* "metadata": {
|
||||
* "annotations": {
|
||||
* "foo": "bar"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
* If given, return only the parts of each entity that match the field
|
||||
* declarations.
|
||||
*/
|
||||
fields?: string[] | undefined;
|
||||
fields?: EntityFieldsQuery;
|
||||
/**
|
||||
* If given, skips over the first N items in the result set.
|
||||
*/
|
||||
@@ -121,6 +137,45 @@ export interface GetEntitiesResponse {
|
||||
items: Entity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The request type for {@link CatalogClient.getEntitiesByRefs}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface GetEntitiesByRefsRequest {
|
||||
/**
|
||||
* The list of entity refs to fetch.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The returned list of entities will be in the same order as the refs, and
|
||||
* null will be returned in those positions that were not found.
|
||||
*/
|
||||
entityRefs: string[];
|
||||
/**
|
||||
* If given, return only the parts of each entity that match the field
|
||||
* declarations.
|
||||
*/
|
||||
fields?: EntityFieldsQuery | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The response type for {@link CatalogClient.getEntitiesByRefs}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface GetEntitiesByRefsResponse {
|
||||
/**
|
||||
* The returned list of entities.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The list will be in the same order as the refs given in the request, and
|
||||
* null will be returned in those positions that were not found.
|
||||
*/
|
||||
items: Array<Entity | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request type for {@link CatalogClient.getEntityAncestors}.
|
||||
*
|
||||
@@ -296,6 +351,23 @@ export interface CatalogApi {
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntitiesResponse>;
|
||||
|
||||
/**
|
||||
* Gets a batch of entities, by their entity refs.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The output list of entities is of the same size and in the same order as
|
||||
* the requested list of entity refs. Entries that are not found are returned
|
||||
* as null.
|
||||
*
|
||||
* @param request - Request parameters
|
||||
* @param options - Additional options
|
||||
*/
|
||||
getEntitiesByRefs(
|
||||
request: GetEntitiesByRefsRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<GetEntitiesByRefsResponse>;
|
||||
|
||||
/**
|
||||
* Gets entity ancestor information, i.e. the hierarchy of parent entities
|
||||
* whose processing resulted in a given entity appearing in the catalog.
|
||||
|
||||
@@ -20,13 +20,17 @@ export type {
|
||||
AddLocationResponse,
|
||||
CatalogApi,
|
||||
CatalogRequestOptions,
|
||||
EntityFieldsQuery,
|
||||
EntityFilterQuery,
|
||||
GetEntitiesByRefsRequest,
|
||||
GetEntitiesByRefsResponse,
|
||||
GetEntitiesRequest,
|
||||
GetEntitiesResponse,
|
||||
GetEntityAncestorsRequest,
|
||||
GetEntityAncestorsResponse,
|
||||
Location,
|
||||
GetEntityFacetsRequest,
|
||||
GetEntityFacetsResponse,
|
||||
Location,
|
||||
ValidateEntityResponse,
|
||||
} from './api';
|
||||
export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FeatureFlagged } from './FeatureFlagged';
|
||||
import { render } from '@testing-library/react';
|
||||
@@ -44,7 +45,7 @@ describe('FeatureFlagged', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await queryByText('BACKSTAGE!')).toBeInTheDocument();
|
||||
expect(queryByText('BACKSTAGE!')).toBeInTheDocument();
|
||||
});
|
||||
it('should not render contents when the feature flag is disabled', async () => {
|
||||
jest
|
||||
@@ -61,7 +62,7 @@ describe('FeatureFlagged', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument();
|
||||
expect(queryByText('BACKSTAGE!')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('without', () => {
|
||||
@@ -80,7 +81,7 @@ describe('FeatureFlagged', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument();
|
||||
expect(queryByText('BACKSTAGE!')).not.toBeInTheDocument();
|
||||
});
|
||||
it('should render contents when the feature flag is disabled', async () => {
|
||||
jest
|
||||
@@ -97,7 +98,7 @@ describe('FeatureFlagged', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await queryByText('BACKSTAGE!')).toBeInTheDocument();
|
||||
expect(queryByText('BACKSTAGE!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +250,7 @@ export interface DependencyGraphProps<NodeData, EdgeData>
|
||||
edgeRanks?: number;
|
||||
edges: DependencyEdge<EdgeData>[];
|
||||
edgeWeight?: number;
|
||||
fit?: 'grow' | 'contain';
|
||||
labelOffset?: number;
|
||||
// Warning: (ae-unresolved-link) The @link reference could not be resolved: This type of declaration is not supported yet by the resolver
|
||||
labelPosition?: LabelPosition;
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"pluralize": "^8.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"qs": "^6.9.4",
|
||||
"rc-progress": "3.4.0",
|
||||
"rc-progress": "3.4.1",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-hook-form": "^7.12.2",
|
||||
"react-markdown": "^8.0.0",
|
||||
|
||||
@@ -170,6 +170,14 @@ export interface DependencyGraphProps<NodeData, EdgeData>
|
||||
* Default: 'curveMonotoneX'
|
||||
*/
|
||||
curve?: 'curveStepBefore' | 'curveMonotoneX';
|
||||
/**
|
||||
* Controls if the graph should be contained or grow
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Default: 'grow'
|
||||
*/
|
||||
fit?: 'grow' | 'contain';
|
||||
}
|
||||
|
||||
const WORKSPACE_ID = 'workspace';
|
||||
@@ -203,6 +211,7 @@ export function DependencyGraph<NodeData, EdgeData>(
|
||||
defs,
|
||||
zoom = 'enabled',
|
||||
curve = 'curveMonotoneX',
|
||||
fit = 'grow',
|
||||
...svgProps
|
||||
} = props;
|
||||
const theme: BackstageTheme = useTheme();
|
||||
@@ -223,6 +232,9 @@ export function DependencyGraph<NodeData, EdgeData>(
|
||||
|
||||
const maxWidth = Math.max(graphWidth, containerWidth);
|
||||
const maxHeight = Math.max(graphHeight, containerHeight);
|
||||
const minHeight = Math.min(graphHeight, containerHeight);
|
||||
|
||||
const scalableHeight = fit === 'grow' ? maxHeight : minHeight;
|
||||
|
||||
const containerRef = React.useMemo(
|
||||
() =>
|
||||
@@ -394,7 +406,7 @@ export function DependencyGraph<NodeData, EdgeData>(
|
||||
ref={containerRef}
|
||||
{...svgProps}
|
||||
width="100%"
|
||||
height={maxHeight}
|
||||
height={scalableHeight}
|
||||
viewBox={`0 0 ${maxWidth} ${maxHeight}`}
|
||||
>
|
||||
<defs>
|
||||
|
||||
+6
-6
@@ -70,8 +70,8 @@ describe('<FeatureCalloutCircular />', () => {
|
||||
description="description"
|
||||
/>,
|
||||
);
|
||||
const dot = await getByTestId('dot');
|
||||
const text = await getByTestId('text');
|
||||
const dot = getByTestId('dot');
|
||||
const text = getByTestId('text');
|
||||
|
||||
expect(dot).toBeInTheDocument();
|
||||
expect(text).toBeInTheDocument();
|
||||
@@ -96,8 +96,8 @@ describe('<FeatureCalloutCircular />', () => {
|
||||
description="description"
|
||||
/>,
|
||||
);
|
||||
const dot = await getByTestId('dot');
|
||||
const text = await getByTestId('text');
|
||||
const dot = getByTestId('dot');
|
||||
const text = getByTestId('text');
|
||||
|
||||
act(() => {
|
||||
Element.prototype.getBoundingClientRect = jest.fn(
|
||||
@@ -128,8 +128,8 @@ describe('<FeatureCalloutCircular />', () => {
|
||||
description="description"
|
||||
/>,
|
||||
);
|
||||
const dot = await getByTestId('dot');
|
||||
const text = await getByTestId('text');
|
||||
const dot = getByTestId('dot');
|
||||
const text = getByTestId('text');
|
||||
|
||||
act(() => {
|
||||
Element.prototype.getBoundingClientRect = jest.fn(
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('<Select />', () => {
|
||||
const { getByText, getByTestId } = render(<Select {...minProps} />);
|
||||
|
||||
expect(getByText('Default')).toBeInTheDocument();
|
||||
const input = await getByTestId('select');
|
||||
const input = getByTestId('select');
|
||||
expect(input.textContent).toBe('All results');
|
||||
|
||||
// Simulate click on input
|
||||
|
||||
@@ -18,7 +18,6 @@ import React from 'react';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
|
||||
import { WarningPanel, WarningProps } from './WarningPanel';
|
||||
|
||||
const propsTitle: WarningProps = { title: 'Mock title' };
|
||||
@@ -41,7 +40,7 @@ describe('<WarningPanel />', () => {
|
||||
|
||||
it('renders title', async () => {
|
||||
await renderInTestApp(<WarningPanel {...propsTitleMessage} />);
|
||||
const expandIcon = await screen.getByText('Warning: Mock title');
|
||||
const expandIcon = screen.getByText('Warning: Mock title');
|
||||
fireEvent.click(expandIcon);
|
||||
expect(screen.getByText('Warning: Mock title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Some more info')).toBeInTheDocument();
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ describe('BitriseArtifactsComponent', () => {
|
||||
const rendered = renderComponent();
|
||||
const btn = await rendered.findByTestId('btn');
|
||||
|
||||
expect(await rendered.queryByText('VISIBLE')).not.toBeInTheDocument();
|
||||
expect(rendered.queryByText('VISIBLE')).not.toBeInTheDocument();
|
||||
|
||||
btn.click();
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import { EntityRelationSpec } from '@backstage/plugin-catalog-node';
|
||||
import { GetEntitiesRequest } from '@backstage/catalog-client';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { LocationEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-node';
|
||||
import { LocationSpec as LocationSpec_2 } from '@backstage/plugin-catalog-common';
|
||||
import { Logger } from 'winston';
|
||||
import { Permission } from '@backstage/plugin-permission-common';
|
||||
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
|
||||
@@ -86,9 +86,9 @@ export class AnnotateLocationEntityProcessor implements CatalogProcessor {
|
||||
// (undocumented)
|
||||
preProcessEntity(
|
||||
entity: Entity,
|
||||
location: LocationSpec,
|
||||
location: LocationSpec_2,
|
||||
_: CatalogProcessorEmit,
|
||||
originLocation: LocationSpec,
|
||||
originLocation: LocationSpec_2,
|
||||
): Promise<Entity>;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export class AnnotateScmSlugEntityProcessor implements CatalogProcessor {
|
||||
// (undocumented)
|
||||
getProcessorName(): string;
|
||||
// (undocumented)
|
||||
preProcessEntity(entity: Entity, location: LocationSpec): Promise<Entity>;
|
||||
preProcessEntity(entity: Entity, location: LocationSpec_2): Promise<Entity>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -110,7 +110,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
|
||||
// (undocumented)
|
||||
postProcessEntity(
|
||||
entity: Entity,
|
||||
_location: LocationSpec,
|
||||
_location: LocationSpec_2,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<Entity>;
|
||||
// (undocumented)
|
||||
@@ -284,7 +284,7 @@ export class CodeOwnersProcessor implements CatalogProcessor {
|
||||
// (undocumented)
|
||||
getProcessorName(): string;
|
||||
// (undocumented)
|
||||
preProcessEntity(entity: Entity, location: LocationSpec): Promise<Entity>;
|
||||
preProcessEntity(entity: Entity, location: LocationSpec_2): Promise<Entity>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
@@ -409,7 +409,7 @@ export class FileReaderProcessor implements CatalogProcessor {
|
||||
getProcessorName(): string;
|
||||
// (undocumented)
|
||||
readLocation(
|
||||
location: LocationSpec,
|
||||
location: LocationSpec_2,
|
||||
optional: boolean,
|
||||
emit: CatalogProcessorEmit,
|
||||
parser: CatalogProcessorParser,
|
||||
@@ -431,7 +431,7 @@ export class LocationEntityProcessor implements CatalogProcessor {
|
||||
// (undocumented)
|
||||
postProcessEntity(
|
||||
entity: Entity,
|
||||
location: LocationSpec,
|
||||
location: LocationSpec_2,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<Entity>;
|
||||
}
|
||||
@@ -441,18 +441,19 @@ export type LocationEntityProcessorOptions = {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
};
|
||||
|
||||
export { LocationSpec };
|
||||
// @public @deprecated
|
||||
export type LocationSpec = LocationSpec_2;
|
||||
|
||||
// @public (undocumented)
|
||||
export function locationSpecToLocationEntity(opts: {
|
||||
location: LocationSpec;
|
||||
location: LocationSpec_2;
|
||||
parentEntity?: Entity;
|
||||
}): LocationEntityV1alpha1;
|
||||
|
||||
// @public (undocumented)
|
||||
export function parseEntityYaml(
|
||||
data: Buffer,
|
||||
location: LocationSpec,
|
||||
location: LocationSpec_2,
|
||||
): Iterable<CatalogProcessorResult>;
|
||||
|
||||
// @alpha
|
||||
@@ -518,7 +519,7 @@ export class PlaceholderProcessor implements CatalogProcessor {
|
||||
// (undocumented)
|
||||
preProcessEntity(
|
||||
entity: Entity,
|
||||
location: LocationSpec,
|
||||
location: LocationSpec_2,
|
||||
emit: CatalogProcessorEmit,
|
||||
): Promise<Entity>;
|
||||
}
|
||||
@@ -574,7 +575,7 @@ export class UrlReaderProcessor implements CatalogProcessor {
|
||||
getProcessorName(): string;
|
||||
// (undocumented)
|
||||
readLocation(
|
||||
location: LocationSpec,
|
||||
location: LocationSpec_2,
|
||||
optional: boolean,
|
||||
emit: CatalogProcessorEmit,
|
||||
parser: CatalogProcessorParser,
|
||||
|
||||
@@ -80,6 +80,36 @@ export type EntitiesResponse = {
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
/**
|
||||
* A request for a batch of entities.
|
||||
*/
|
||||
export interface EntitiesBatchRequest {
|
||||
/**
|
||||
* The refs for which to fetch entities.
|
||||
*/
|
||||
entityRefs: string[];
|
||||
/**
|
||||
* Any additional filters to apply in the selection of the entities.
|
||||
*/
|
||||
filter?: EntityFilter;
|
||||
/**
|
||||
* Strips out only the parts of the entity bodies to include in the response.
|
||||
*/
|
||||
fields?: (entity: Entity) => Entity;
|
||||
/**
|
||||
* The optional token that authorizes the action.
|
||||
*/
|
||||
authorizationToken?: string;
|
||||
}
|
||||
|
||||
export interface EntitiesBatchResponse {
|
||||
/**
|
||||
* The list of entities, in the same order as the refs in the request. Entries
|
||||
* that are null signify that no entity existed with that ref.
|
||||
*/
|
||||
items: Array<Entity | null>;
|
||||
}
|
||||
|
||||
export type EntityAncestryResponse = {
|
||||
rootEntityRef: string;
|
||||
items: Array<{
|
||||
@@ -130,6 +160,11 @@ export interface EntitiesCatalog {
|
||||
*/
|
||||
entities(request?: EntitiesRequest): Promise<EntitiesResponse>;
|
||||
|
||||
/**
|
||||
* Fetches a batch of entities.
|
||||
*/
|
||||
entitiesBatch(request: EntitiesBatchRequest): Promise<EntitiesBatchResponse>;
|
||||
|
||||
/**
|
||||
* Removes a single entity.
|
||||
*
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
|
||||
export type {
|
||||
DeferredEntity,
|
||||
LocationSpec,
|
||||
EntityRelationSpec,
|
||||
CatalogProcessor,
|
||||
CatalogProcessorParser,
|
||||
@@ -48,3 +47,19 @@ export * from './processing';
|
||||
export * from './search';
|
||||
export * from './service';
|
||||
export * from './util';
|
||||
|
||||
import { LocationSpec as NonDeprecatedLocationSpec } from '@backstage/plugin-catalog-common';
|
||||
|
||||
/**
|
||||
* Holds the entity location information.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* `presence` flag: when using repo importer plugin, location is being created before the component yaml file is merged to the main branch.
|
||||
* This flag is then set to indicate that the file can be not present.
|
||||
* default value: 'required'.
|
||||
*
|
||||
* @public
|
||||
* @deprecated use the same type from `@backstage/plugin-catalog-common` instead
|
||||
*/
|
||||
export type LocationSpec = NonDeprecatedLocationSpec;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { Config } from '@backstage/config';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import path from 'path';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-node';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
|
||||
/**
|
||||
* Rules to apply to catalog entities.
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
ScmIntegrations,
|
||||
} from '@backstage/integration';
|
||||
import { Logger } from 'winston';
|
||||
import { CatalogProcessor, LocationSpec } from '@backstage/plugin-catalog-node';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
|
||||
import { findCodeOwnerByTarget } from './lib';
|
||||
|
||||
const ALLOWED_KINDS = ['API', 'Component', 'Domain', 'Resource', 'System'];
|
||||
|
||||
@@ -25,10 +25,10 @@ import {
|
||||
} from '@backstage/catalog-model';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import { identity, merge, pickBy } from 'lodash';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
CatalogProcessorEmit,
|
||||
LocationSpec,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
@@ -21,7 +22,8 @@ import {
|
||||
} from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { identity, merge, pickBy } from 'lodash';
|
||||
import { CatalogProcessor, LocationSpec } from '@backstage/plugin-catalog-node';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
|
||||
|
||||
const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug';
|
||||
const GITLAB_ACTIONS_ANNOTATION = 'gitlab.com/project-slug';
|
||||
|
||||
@@ -48,10 +48,10 @@ import {
|
||||
UserEntity,
|
||||
userEntityV1alpha1Validator,
|
||||
} from '@backstage/catalog-model';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
CatalogProcessorEmit,
|
||||
LocationSpec,
|
||||
processingResult,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ import fs from 'fs-extra';
|
||||
import g from 'glob';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
CatalogProcessorEmit,
|
||||
CatalogProcessorParser,
|
||||
LocationSpec,
|
||||
processingResult,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
import { Entity, LocationEntity } from '@backstage/catalog-model';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import path from 'path';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
processingResult,
|
||||
CatalogProcessor,
|
||||
CatalogProcessorEmit,
|
||||
LocationSpec,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
|
||||
export function toAbsoluteUrl(
|
||||
|
||||
@@ -19,10 +19,10 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import yaml from 'yaml';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
CatalogProcessorEmit,
|
||||
LocationSpec,
|
||||
processingResult,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { assertError } from '@backstage/errors';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import limiterFactory from 'p-limit';
|
||||
import { Logger } from 'winston';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
CatalogProcessorCache,
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
CatalogProcessorEntityResult,
|
||||
CatalogProcessorParser,
|
||||
CatalogProcessorResult,
|
||||
LocationSpec,
|
||||
processingResult,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
import { Entity, stringifyLocationRef } from '@backstage/catalog-model';
|
||||
import lodash from 'lodash';
|
||||
import yaml from 'yaml';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
CatalogProcessorParser,
|
||||
CatalogProcessorResult,
|
||||
LocationSpec,
|
||||
processingResult,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@ import { JsonValue } from '@backstage/types';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import path from 'path';
|
||||
import { Logger } from 'winston';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
CatalogProcessorParser,
|
||||
LocationSpec,
|
||||
processingResult,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
import {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import path from 'path';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-node';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
|
||||
export function isLocationEntity(entity: Entity): entity is LocationEntity {
|
||||
return entity.kind === 'Location';
|
||||
|
||||
@@ -24,6 +24,7 @@ import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
|
||||
describe('AuthorizedEntitiesCatalog', () => {
|
||||
const fakeCatalog = {
|
||||
entities: jest.fn(),
|
||||
entitiesBatch: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
facets: jest.fn(),
|
||||
@@ -92,6 +93,67 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('entitiesBatch', () => {
|
||||
it('returns empty response on DENY', async () => {
|
||||
fakePermissionApi.authorizeConditional.mockResolvedValue([
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
const catalog = createCatalog();
|
||||
|
||||
await expect(
|
||||
catalog.entitiesBatch({
|
||||
entityRefs: ['component:default/component-a'],
|
||||
authorizationToken: 'abcd',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
items: [null],
|
||||
});
|
||||
|
||||
expect(fakeCatalog.entitiesBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls underlying catalog method with correct filter on CONDITIONAL', async () => {
|
||||
fakePermissionApi.authorizeConditional.mockResolvedValue([
|
||||
{
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: {
|
||||
rule: 'IS_ENTITY_KIND',
|
||||
params: { kinds: ['b'] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const catalog = createCatalog(isEntityKind);
|
||||
|
||||
await catalog.entitiesBatch({
|
||||
entityRefs: ['component:default/component-a'],
|
||||
authorizationToken: 'abcd',
|
||||
});
|
||||
|
||||
expect(fakeCatalog.entitiesBatch).toHaveBeenCalledWith({
|
||||
entityRefs: ['component:default/component-a'],
|
||||
authorizationToken: 'abcd',
|
||||
filter: { key: 'kind', values: ['b'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('calls underlying catalog method on ALLOW', async () => {
|
||||
fakePermissionApi.authorizeConditional.mockResolvedValue([
|
||||
{ result: AuthorizeResult.ALLOW },
|
||||
]);
|
||||
const catalog = createCatalog();
|
||||
|
||||
await catalog.entitiesBatch({
|
||||
entityRefs: ['component:default/component-a'],
|
||||
authorizationToken: 'abcd',
|
||||
});
|
||||
|
||||
expect(fakeCatalog.entitiesBatch).toHaveBeenCalledWith({
|
||||
entityRefs: ['component:default/component-a'],
|
||||
authorizationToken: 'abcd',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEntityByUid', () => {
|
||||
it('throws error on DENY', async () => {
|
||||
fakeCatalog.entities.mockResolvedValue({
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { ConditionTransformer } from '@backstage/plugin-permission-node';
|
||||
import {
|
||||
EntitiesBatchRequest,
|
||||
EntitiesBatchResponse,
|
||||
EntitiesCatalog,
|
||||
EntitiesRequest,
|
||||
EntitiesResponse,
|
||||
@@ -73,6 +75,37 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
return this.entitiesCatalog.entities(request);
|
||||
}
|
||||
|
||||
async entitiesBatch(
|
||||
request: EntitiesBatchRequest,
|
||||
): Promise<EntitiesBatchResponse> {
|
||||
const authorizeDecision = (
|
||||
await this.permissionApi.authorizeConditional(
|
||||
[{ permission: catalogEntityReadPermission }],
|
||||
{ token: request?.authorizationToken },
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (authorizeDecision.result === AuthorizeResult.DENY) {
|
||||
return {
|
||||
items: new Array(request.entityRefs.length).fill(null),
|
||||
};
|
||||
}
|
||||
|
||||
if (authorizeDecision.result === AuthorizeResult.CONDITIONAL) {
|
||||
const permissionFilter: EntityFilter = this.transformConditions(
|
||||
authorizeDecision.conditions,
|
||||
);
|
||||
return this.entitiesCatalog.entitiesBatch({
|
||||
...request,
|
||||
filter: request?.filter
|
||||
? { allOf: [permissionFilter, request.filter] }
|
||||
: permissionFilter,
|
||||
});
|
||||
}
|
||||
|
||||
return this.entitiesCatalog.entitiesBatch(request);
|
||||
}
|
||||
|
||||
async removeEntityByUid(
|
||||
uid: string,
|
||||
options?: { authorizationToken?: string },
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
permissionsServiceRef,
|
||||
urlReaderServiceRef,
|
||||
httpRouterServiceRef,
|
||||
lifecycleServiceRef,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { CatalogBuilder } from './CatalogBuilder';
|
||||
import {
|
||||
@@ -78,6 +79,7 @@ export const catalogPlugin = createBackendPlugin({
|
||||
permissions: permissionsServiceRef,
|
||||
database: databaseServiceRef,
|
||||
httpRouter: httpRouterServiceRef,
|
||||
lifecycle: lifecycleServiceRef,
|
||||
},
|
||||
async init({
|
||||
logger,
|
||||
@@ -86,6 +88,7 @@ export const catalogPlugin = createBackendPlugin({
|
||||
database,
|
||||
permissions,
|
||||
httpRouter,
|
||||
lifecycle,
|
||||
}) {
|
||||
const winstonLogger = loggerToWinstonLogger(logger);
|
||||
const builder = await CatalogBuilder.create({
|
||||
@@ -100,7 +103,11 @@ export const catalogPlugin = createBackendPlugin({
|
||||
const { processingEngine, router } = await builder.build();
|
||||
|
||||
await processingEngine.start();
|
||||
|
||||
lifecycle.addShutdownHook({
|
||||
fn: async () => {
|
||||
await processingEngine.stop();
|
||||
},
|
||||
});
|
||||
httpRouter.use(router);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -534,6 +534,60 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('entitiesBatch', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'queries for entities by ref, including duplicates, and gracefully returns null for missing entities',
|
||||
async databaseId => {
|
||||
const { knex } = await createDatabase(databaseId);
|
||||
|
||||
await addEntity(
|
||||
knex,
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'one' },
|
||||
spec: {},
|
||||
relations: [],
|
||||
},
|
||||
[],
|
||||
);
|
||||
await addEntity(
|
||||
knex,
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'two' },
|
||||
spec: {},
|
||||
relations: [],
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const catalog = new DefaultEntitiesCatalog(knex, stitcher);
|
||||
|
||||
const { items } = await catalog.entitiesBatch({
|
||||
entityRefs: [
|
||||
'k:default/two',
|
||||
'k:default/one',
|
||||
'k:default/two',
|
||||
'not-even-a-ref',
|
||||
'k:default/does-not-exist',
|
||||
'k:default/two',
|
||||
],
|
||||
});
|
||||
|
||||
expect(items.map(e => e && stringifyEntityRef(e))).toEqual([
|
||||
'k:default/two',
|
||||
'k:default/one',
|
||||
'k:default/two',
|
||||
null,
|
||||
null,
|
||||
'k:default/two',
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('removeEntityByUid', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'also clears parent hashes',
|
||||
|
||||
@@ -23,6 +23,8 @@ import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import { Knex } from 'knex';
|
||||
import lodash from 'lodash';
|
||||
import {
|
||||
EntitiesBatchRequest,
|
||||
EntitiesBatchResponse,
|
||||
EntitiesCatalog,
|
||||
EntitiesRequest,
|
||||
EntitiesResponse,
|
||||
@@ -237,6 +239,38 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
async entitiesBatch(
|
||||
request: EntitiesBatchRequest,
|
||||
): Promise<EntitiesBatchResponse> {
|
||||
const lookup = new Map<string, Entity>();
|
||||
|
||||
for (const chunk of lodash.chunk(request.entityRefs, 200)) {
|
||||
let query = this.database<DbFinalEntitiesRow>('final_entities')
|
||||
.innerJoin<DbRefreshStateRow>('refresh_state', {
|
||||
'refresh_state.entity_id': 'final_entities.entity_id',
|
||||
})
|
||||
.select({
|
||||
entityRef: 'refresh_state.entity_ref',
|
||||
entity: 'final_entities.final_entity',
|
||||
})
|
||||
.whereIn('refresh_state.entity_ref', chunk);
|
||||
if (request?.filter) {
|
||||
query = parseFilter(request.filter, query, this.database);
|
||||
}
|
||||
for (const row of await query) {
|
||||
lookup.set(row.entityRef, row.entity ? JSON.parse(row.entity) : null);
|
||||
}
|
||||
}
|
||||
|
||||
let items = request.entityRefs.map(ref => lookup.get(ref) ?? null);
|
||||
|
||||
if (request.fields) {
|
||||
items = items.map(e => e && request.fields!(e));
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
async removeEntityByUid(uid: string): Promise<void> {
|
||||
// Clear the hashed state of the immediate parents of the deleted entity.
|
||||
// This makes sure that when they get reprocessed, their output is written
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('createRouter readonly disabled', () => {
|
||||
beforeAll(async () => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
entitiesBatch: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
facets: jest.fn(),
|
||||
@@ -257,6 +258,38 @@ describe('createRouter readonly disabled', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /entities/by-refs', () => {
|
||||
it.each([
|
||||
'',
|
||||
'not json',
|
||||
'[',
|
||||
'[]',
|
||||
'{}',
|
||||
'{"unknown":7}',
|
||||
'{"entityRefs":7}',
|
||||
'{"entityRefs":[7]}',
|
||||
])('properly rejects malformed request body, %p', async p => {
|
||||
await expect(
|
||||
request(app)
|
||||
.post('/entities/by-refs')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(p),
|
||||
).resolves.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('can fetch entities by refs', async () => {
|
||||
const entity: Entity = {} as any;
|
||||
entitiesCatalog.entitiesBatch.mockResolvedValue({ items: [entity] });
|
||||
const response = await request(app)
|
||||
.post('/entities/by-refs')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"entityRefs":["a"]}');
|
||||
expect(entitiesCatalog.entitiesBatch).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ items: [entity] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /locations', () => {
|
||||
it('happy path: lists locations', async () => {
|
||||
const locations: Location[] = [
|
||||
@@ -517,6 +550,7 @@ describe('createRouter readonly enabled', () => {
|
||||
beforeAll(async () => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
entitiesBatch: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
facets: jest.fn(),
|
||||
@@ -706,6 +740,7 @@ describe('NextRouter permissioning', () => {
|
||||
beforeAll(async () => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
entitiesBatch: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
facets: jest.fn(),
|
||||
|
||||
@@ -28,24 +28,25 @@ import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import yn from 'yn';
|
||||
import { z } from 'zod';
|
||||
import { EntitiesCatalog } from '../catalog/types';
|
||||
import { LocationAnalyzer } from '../ingestion/types';
|
||||
import { CatalogProcessingOrchestrator } from '../processing/types';
|
||||
import { validateEntityEnvelope } from '../processing/util';
|
||||
import {
|
||||
basicEntityFilter,
|
||||
entitiesBatchRequest,
|
||||
parseEntityFilterParams,
|
||||
parseEntityPaginationParams,
|
||||
parseEntityTransformParams,
|
||||
} from './request';
|
||||
import { parseEntityFacetParams } from './request/parseEntityFacetParams';
|
||||
import { LocationService, RefreshOptions, RefreshService } from './types';
|
||||
import {
|
||||
disallowReadonlyMode,
|
||||
locationInput,
|
||||
validateRequestBody,
|
||||
} from './util';
|
||||
import { z } from 'zod';
|
||||
import { parseEntityFacetParams } from './request/parseEntityFacetParams';
|
||||
import { RefreshOptions, LocationService, RefreshService } from './types';
|
||||
import { CatalogProcessingOrchestrator } from '../processing/types';
|
||||
import { validateEntityEnvelope } from '../processing/util';
|
||||
|
||||
/**
|
||||
* Options used by {@link createRouter}.
|
||||
@@ -173,6 +174,16 @@ export async function createRouter(
|
||||
res.status(200).json(response);
|
||||
},
|
||||
)
|
||||
.post('/entities/by-refs', async (req, res) => {
|
||||
const request = entitiesBatchRequest(req);
|
||||
const token = getBearerToken(req.header('authorization'));
|
||||
const response = await entitiesCatalog.entitiesBatch({
|
||||
entityRefs: request.entityRefs,
|
||||
fields: parseEntityTransformParams(req.query),
|
||||
authorizationToken: token,
|
||||
});
|
||||
res.status(200).json(response);
|
||||
})
|
||||
.get('/entity-facets', async (req, res) => {
|
||||
const response = await entitiesCatalog.facets({
|
||||
filter: parseEntityFilterParams(req.query),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { Request } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
const schema = z.object({
|
||||
entityRefs: z.array(z.string()),
|
||||
});
|
||||
|
||||
export function entitiesBatchRequest(req: Request) {
|
||||
try {
|
||||
return schema.parse(req.body);
|
||||
} catch (error) {
|
||||
throw new InputError(
|
||||
`Malformed request body (did you remember to specify an application/json content type?), ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { entitiesBatchRequest } from './entitiesBatchRequest';
|
||||
export { basicEntityFilter } from './basicEntityFilter';
|
||||
export { parseEntityFilterParams } from './parseEntityFilterParams';
|
||||
export { parseEntityPaginationParams } from './parseEntityPaginationParams';
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
stringifyLocationRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { createHash } from 'crypto';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-node';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
|
||||
export function locationSpecToMetadataName(location: LocationSpec) {
|
||||
const hash = createHash('sha1')
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
|
||||
rules: {
|
||||
'testing-library/prefer-screen-queries': 'error',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TestApiProvider,
|
||||
TestApiRegistry,
|
||||
} from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { catalogGraphRouteRef } from '../../routes';
|
||||
@@ -79,15 +80,15 @@ describe('<CatalogGraphCard/>', () => {
|
||||
relations: [],
|
||||
}));
|
||||
|
||||
const { findByText, findAllByTestId } = await renderInTestApp(wrapper, {
|
||||
await renderInTestApp(wrapper, {
|
||||
mountedRoutes: {
|
||||
'/entity/{kind}/{namespace}/{name}': entityRouteRef,
|
||||
'/catalog-graph': catalogGraphRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(1);
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(1);
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -97,7 +98,7 @@ describe('<CatalogGraphCard/>', () => {
|
||||
relations: [],
|
||||
}));
|
||||
|
||||
const { findByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<EntityProvider entity={entity}>
|
||||
<CatalogGraphCard title="Custom Title" />
|
||||
@@ -111,7 +112,7 @@ describe('<CatalogGraphCard/>', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(await findByText('Custom Title')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Custom Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders link to standalone viewer', async () => {
|
||||
@@ -120,15 +121,15 @@ describe('<CatalogGraphCard/>', () => {
|
||||
relations: [],
|
||||
}));
|
||||
|
||||
const { findByText, getByText } = await renderInTestApp(wrapper, {
|
||||
await renderInTestApp(wrapper, {
|
||||
mountedRoutes: {
|
||||
'/entity/{kind}/{namespace}/{name}': entityRouteRef,
|
||||
'/catalog-graph': catalogGraphRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
const button = getByText('View graph');
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
const button = screen.getByText('View graph');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
@@ -137,7 +138,7 @@ describe('<CatalogGraphCard/>', () => {
|
||||
});
|
||||
|
||||
test('renders link to standalone viewer with custom config', async () => {
|
||||
const { findByText, getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<EntityProvider entity={entity}>
|
||||
<CatalogGraphCard maxDepth={2} mergeRelations={false} />
|
||||
@@ -151,8 +152,8 @@ describe('<CatalogGraphCard/>', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
const button = getByText('View graph');
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
const button = screen.getByText('View graph');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
@@ -167,7 +168,7 @@ describe('<CatalogGraphCard/>', () => {
|
||||
}));
|
||||
|
||||
const analyticsSpy = new MockAnalyticsApi();
|
||||
const { findByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsSpy]]}>
|
||||
{wrapper}
|
||||
</TestApiProvider>,
|
||||
@@ -179,8 +180,8 @@ describe('<CatalogGraphCard/>', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
await userEvent.click(await findByText('b:d/c'));
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByText('b:d/c'));
|
||||
|
||||
expect(analyticsSpy.getEvents()[0]).toMatchObject({
|
||||
action: 'click',
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
} from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { CatalogGraphPage } from './CatalogGraphPage';
|
||||
@@ -108,19 +109,16 @@ describe('<CatalogGraphPage/>', () => {
|
||||
n === 'b:d/e' ? entityE : entityC,
|
||||
);
|
||||
|
||||
const { getByText, findByText, findAllByTestId } = await renderInTestApp(
|
||||
wrapper,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/entity/{kind}/{namespace}/{name}': entityRouteRef,
|
||||
},
|
||||
await renderInTestApp(wrapper, {
|
||||
mountedRoutes: {
|
||||
'/entity/{kind}/{namespace}/{name}': entityRouteRef,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(getByText('Catalog Graph')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/e')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(2);
|
||||
expect(screen.getByText('Catalog Graph')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/e')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(2);
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -129,17 +127,17 @@ describe('<CatalogGraphPage/>', () => {
|
||||
n === 'b:d/e' ? entityE : entityC,
|
||||
);
|
||||
|
||||
const { getByText, queryByText } = await renderInTestApp(wrapper, {
|
||||
await renderInTestApp(wrapper, {
|
||||
mountedRoutes: {
|
||||
'/entity/{kind}/{namespace}/{name}': entityRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByText('Max Depth')).toBeNull();
|
||||
expect(screen.queryByText('Max Depth')).toBeNull();
|
||||
|
||||
await userEvent.click(getByText('Filters'));
|
||||
await userEvent.click(screen.getByText('Filters'));
|
||||
|
||||
expect(getByText('Max Depth')).toBeInTheDocument();
|
||||
expect(screen.getByText('Max Depth')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should select other entity', async () => {
|
||||
@@ -147,20 +145,17 @@ describe('<CatalogGraphPage/>', () => {
|
||||
n === 'b:d/e' ? entityE : entityC,
|
||||
);
|
||||
|
||||
const { getByText, findByText, findAllByTestId } = await renderInTestApp(
|
||||
wrapper,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/entity/{kind}/{namespace}/{name}': entityRouteRef,
|
||||
},
|
||||
await renderInTestApp(wrapper, {
|
||||
mountedRoutes: {
|
||||
'/entity/{kind}/{namespace}/{name}': entityRouteRef,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(await findAllByTestId('node')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(2);
|
||||
|
||||
await userEvent.click(getByText('b:d/e'));
|
||||
await userEvent.click(screen.getByText('b:d/e'));
|
||||
|
||||
expect(await findByText('hasPart')).toBeInTheDocument();
|
||||
expect(await screen.findByText('hasPart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should navigate to entity', async () => {
|
||||
@@ -168,17 +163,17 @@ describe('<CatalogGraphPage/>', () => {
|
||||
n === 'b:d/e' ? entityE : entityC,
|
||||
);
|
||||
|
||||
const { getByText, findAllByTestId } = await renderInTestApp(wrapper, {
|
||||
await renderInTestApp(wrapper, {
|
||||
mountedRoutes: {
|
||||
'/entity/{kind}/{namespace}/{name}': entityRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await findAllByTestId('node')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(2);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.keyboard('{Shift>}');
|
||||
await user.click(getByText('b:d/e'));
|
||||
await user.click(screen.getByText('b:d/e'));
|
||||
expect(navigate).toHaveBeenCalledWith('/entity/{kind}/{namespace}/{name}');
|
||||
});
|
||||
|
||||
@@ -188,7 +183,7 @@ describe('<CatalogGraphPage/>', () => {
|
||||
);
|
||||
|
||||
const analyticsSpy = new MockAnalyticsApi();
|
||||
const { getByText, findAllByTestId } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsSpy]]}>
|
||||
{wrapper}
|
||||
</TestApiProvider>,
|
||||
@@ -199,12 +194,12 @@ describe('<CatalogGraphPage/>', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(await findAllByTestId('node')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(2);
|
||||
|
||||
// We wait a bit here to reliably reproduce an issue where that requires the `baseVal` and `view` mocks
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
await userEvent.click(getByText('b:d/e'));
|
||||
await userEvent.click(screen.getByText('b:d/e'));
|
||||
|
||||
expect(analyticsSpy.getEvents()[0]).toMatchObject({
|
||||
action: 'click',
|
||||
@@ -218,7 +213,7 @@ describe('<CatalogGraphPage/>', () => {
|
||||
);
|
||||
|
||||
const analyticsSpy = new MockAnalyticsApi();
|
||||
const { getByText, findAllByTestId } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsSpy]]}>
|
||||
{wrapper}
|
||||
</TestApiProvider>,
|
||||
@@ -229,11 +224,11 @@ describe('<CatalogGraphPage/>', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(await findAllByTestId('node')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(2);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.keyboard('{Shift>}');
|
||||
await user.click(getByText('b:d/e'));
|
||||
await user.click(screen.getByText('b:d/e'));
|
||||
|
||||
expect(analyticsSpy.getEvents()[0]).toMatchObject({
|
||||
action: 'click',
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
|
||||
import { render, waitFor, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { CurveFilter } from './CurveFilter';
|
||||
@@ -21,26 +22,22 @@ import { CurveFilter } from './CurveFilter';
|
||||
describe('<CurveFilter/>', () => {
|
||||
test('should display current curve label', () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByText } = render(
|
||||
<CurveFilter value="curveMonotoneX" onChange={onChange} />,
|
||||
);
|
||||
render(<CurveFilter value="curveMonotoneX" onChange={onChange} />);
|
||||
|
||||
expect(getByText('Monotone X')).toBeInTheDocument();
|
||||
expect(screen.getByText('Monotone X')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should select an alternative curve factory', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByText, getByTestId } = render(
|
||||
<CurveFilter value="curveStepBefore" onChange={onChange} />,
|
||||
);
|
||||
render(<CurveFilter value="curveStepBefore" onChange={onChange} />);
|
||||
|
||||
expect(getByText('Step Before')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step Before')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(getByTestId('select'));
|
||||
await userEvent.click(getByText('Monotone X'));
|
||||
await userEvent.click(screen.getByTestId('select'));
|
||||
await userEvent.click(screen.getByText('Monotone X'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Monotone X')).toBeInTheDocument();
|
||||
expect(screen.getByText('Monotone X')).toBeInTheDocument();
|
||||
expect(onChange).toHaveBeenCalledWith('curveMonotoneX');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
|
||||
import { render, waitFor, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { Direction } from '../EntityRelationsGraph';
|
||||
@@ -21,26 +22,26 @@ import { DirectionFilter } from './DirectionFilter';
|
||||
|
||||
describe('<DirectionFilter/>', () => {
|
||||
test('should display current value', () => {
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<DirectionFilter value={Direction.LEFT_RIGHT} onChange={() => {}} />,
|
||||
);
|
||||
|
||||
expect(getByText('Left to right')).toBeInTheDocument();
|
||||
expect(screen.getByText('Left to right')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should select direction', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByText, getByTestId } = render(
|
||||
render(
|
||||
<DirectionFilter value={Direction.RIGHT_LEFT} onChange={onChange} />,
|
||||
);
|
||||
|
||||
expect(getByText('Right to left')).toBeInTheDocument();
|
||||
expect(screen.getByText('Right to left')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(getByTestId('select'));
|
||||
await userEvent.click(getByText('Top to bottom'));
|
||||
await userEvent.click(screen.getByTestId('select'));
|
||||
await userEvent.click(screen.getByText('Top to bottom'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Top to bottom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Top to bottom')).toBeInTheDocument();
|
||||
expect(onChange).toHaveBeenCalledWith(Direction.TOP_BOTTOM);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,64 +13,65 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import user from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { MaxDepthFilter } from './MaxDepthFilter';
|
||||
|
||||
describe('<MaxDepthFilter/>', () => {
|
||||
test('should display current value', () => {
|
||||
const { getByLabelText } = render(
|
||||
<MaxDepthFilter value={5} onChange={() => {}} />,
|
||||
);
|
||||
render(<MaxDepthFilter value={5} onChange={() => {}} />);
|
||||
|
||||
expect(getByLabelText('maxp')).toBeInTheDocument();
|
||||
expect(getByLabelText('maxp')).toHaveValue(5);
|
||||
expect(screen.getByLabelText('maxp')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('maxp')).toHaveValue(5);
|
||||
});
|
||||
|
||||
test('should display infinite if non finite', () => {
|
||||
const { getByPlaceholderText, getByLabelText } = render(
|
||||
render(
|
||||
<MaxDepthFilter value={Number.POSITIVE_INFINITY} onChange={() => {}} />,
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText(/Infinite/)).toBeInTheDocument();
|
||||
expect(getByLabelText('maxp')).toHaveValue(null);
|
||||
expect(screen.getByPlaceholderText(/Infinite/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('maxp')).toHaveValue(null);
|
||||
});
|
||||
|
||||
test('should clear max depth', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByLabelText } = render(
|
||||
<MaxDepthFilter value={10} onChange={onChange} />,
|
||||
);
|
||||
render(<MaxDepthFilter value={10} onChange={onChange} />);
|
||||
|
||||
await userEvent.click(getByLabelText('clear max depth'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByLabelText('clear max depth'));
|
||||
expect(onChange).toHaveBeenCalledWith(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
test('should set max depth to undefined if below one', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByLabelText } = render(
|
||||
<MaxDepthFilter value={1} onChange={onChange} />,
|
||||
);
|
||||
render(<MaxDepthFilter value={1} onChange={onChange} />);
|
||||
|
||||
await userEvent.clear(getByLabelText('maxp'));
|
||||
await userEvent.type(getByLabelText('maxp'), '0');
|
||||
await user.clear(screen.getByLabelText('maxp'));
|
||||
await user.type(screen.getByLabelText('maxp'), '0');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
test('should select direction', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByLabelText } = render(
|
||||
<MaxDepthFilter value={5} onChange={onChange} />,
|
||||
let value = 5;
|
||||
render(
|
||||
<MaxDepthFilter
|
||||
value={value}
|
||||
onChange={v => {
|
||||
value = v;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByLabelText('maxp')).toHaveValue(5);
|
||||
expect(screen.getByLabelText('maxp')).toHaveValue(5);
|
||||
expect(value).toBe(5);
|
||||
|
||||
await userEvent.clear(getByLabelText('maxp'));
|
||||
await userEvent.type(getByLabelText('maxp'), '10');
|
||||
waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(10);
|
||||
});
|
||||
await user.clear(screen.getByLabelText('maxp'));
|
||||
expect(value).toBe(Number.POSITIVE_INFINITY);
|
||||
await user.type(screen.getByLabelText('maxp'), '10');
|
||||
expect(value).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import ClearIcon from '@material-ui/icons/Clear';
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type Props = {
|
||||
value: number;
|
||||
@@ -42,18 +42,37 @@ const useStyles = makeStyles(
|
||||
|
||||
export const MaxDepthFilter = ({ value, onChange }: Props) => {
|
||||
const classes = useStyles();
|
||||
const onChangeRef = useRef(onChange);
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
// Keep a fresh reference to the latest callback
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
// If the value changes externally, update ourselves
|
||||
useEffect(() => {
|
||||
setCurrentValue(value);
|
||||
}, [value]);
|
||||
|
||||
// When the entered text changes, update ourselves and communicate externally
|
||||
const handleChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = Number(event.target.value);
|
||||
onChange(v <= 0 ? Number.POSITIVE_INFINITY : v);
|
||||
const newValueNumeric = Number(event.target.value);
|
||||
const newValue =
|
||||
Number.isFinite(newValueNumeric) && newValueNumeric > 0
|
||||
? newValueNumeric
|
||||
: Number.POSITIVE_INFINITY;
|
||||
setCurrentValue(newValue);
|
||||
onChangeRef.current(newValue);
|
||||
},
|
||||
[onChange],
|
||||
[],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
onChange(Number.POSITIVE_INFINITY);
|
||||
}, [onChange]);
|
||||
setCurrentValue(Number.POSITIVE_INFINITY);
|
||||
onChangeRef.current(Number.POSITIVE_INFINITY);
|
||||
}, [onChangeRef]);
|
||||
|
||||
return (
|
||||
<Box pb={1} pt={1}>
|
||||
@@ -62,7 +81,7 @@ export const MaxDepthFilter = ({ value, onChange }: Props) => {
|
||||
<OutlinedInput
|
||||
type="number"
|
||||
placeholder="∞ Infinite"
|
||||
value={isFinite(value) ? value : ''}
|
||||
value={Number.isFinite(currentValue) ? String(currentValue) : ''}
|
||||
onChange={handleChange}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
|
||||
+19
-16
@@ -13,12 +13,13 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { waitFor, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { SelectedKindsFilter } from './SelectedKindsFilter';
|
||||
@@ -42,37 +43,37 @@ const apis = TestApiRegistry.from(
|
||||
|
||||
describe('<SelectedKindsFilter/>', () => {
|
||||
it('should not explode while loading', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
const { baseElement } = await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<SelectedKindsFilter value={['api', 'component']} onChange={() => {}} />
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(rendered.baseElement).toBeInTheDocument();
|
||||
expect(baseElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render current value', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<SelectedKindsFilter value={['api', 'component']} onChange={() => {}} />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('API')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Component')).toBeInTheDocument();
|
||||
expect(screen.getByText('API')).toBeInTheDocument();
|
||||
expect(screen.getByText('Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should select value', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByLabelText, getByText } = await renderWithEffects(
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<SelectedKindsFilter value={['api', 'component']} onChange={onChange} />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(getByLabelText('Open'));
|
||||
await waitFor(() => expect(getByText('System')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByLabelText('Open'));
|
||||
await waitFor(() => expect(screen.getByText('System')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(getByText('System'));
|
||||
await userEvent.click(screen.getByText('System'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(['api', 'component', 'system']);
|
||||
@@ -81,7 +82,7 @@ describe('<SelectedKindsFilter/>', () => {
|
||||
|
||||
it('should return undefined if all values are selected', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByLabelText, getByText } = await renderWithEffects(
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<SelectedKindsFilter
|
||||
value={['api', 'component', 'system', 'domain']}
|
||||
@@ -89,11 +90,13 @@ describe('<SelectedKindsFilter/>', () => {
|
||||
/>
|
||||
</ApiProvider>,
|
||||
);
|
||||
await userEvent.click(getByLabelText('Open'));
|
||||
await userEvent.click(screen.getByLabelText('Open'));
|
||||
|
||||
await waitFor(() => expect(getByText('Resource')).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Resource')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await userEvent.click(getByText('Resource'));
|
||||
await userEvent.click(screen.getByText('Resource'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(undefined);
|
||||
@@ -102,13 +105,13 @@ describe('<SelectedKindsFilter/>', () => {
|
||||
|
||||
it('should return all values when cleared', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByRole } = await renderWithEffects(
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<SelectedKindsFilter value={[]} onChange={onChange} />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(getByRole('combobox'));
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await userEvent.tab();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
+15
-14
@@ -13,12 +13,13 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
RELATION_CHILD_OF,
|
||||
RELATION_HAS_MEMBER,
|
||||
RELATION_OWNED_BY,
|
||||
} from '@backstage/catalog-model';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render, waitFor, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { ALL_RELATION_PAIRS } from '../EntityRelationsGraph';
|
||||
@@ -26,7 +27,7 @@ import { SelectedRelationsFilter } from './SelectedRelationsFilter';
|
||||
|
||||
describe('<SelectedRelationsFilter/>', () => {
|
||||
test('should render current value', () => {
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<SelectedRelationsFilter
|
||||
relationPairs={ALL_RELATION_PAIRS}
|
||||
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
|
||||
@@ -34,13 +35,13 @@ describe('<SelectedRelationsFilter/>', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText(RELATION_OWNED_BY)).toBeInTheDocument();
|
||||
expect(getByText(RELATION_CHILD_OF)).toBeInTheDocument();
|
||||
expect(screen.getByText(RELATION_OWNED_BY)).toBeInTheDocument();
|
||||
expect(screen.getByText(RELATION_CHILD_OF)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should select value', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByText, getByLabelText } = render(
|
||||
render(
|
||||
<SelectedRelationsFilter
|
||||
relationPairs={ALL_RELATION_PAIRS}
|
||||
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
|
||||
@@ -48,13 +49,13 @@ describe('<SelectedRelationsFilter/>', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(getByLabelText('Open'));
|
||||
await userEvent.click(screen.getByLabelText('Open'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText(RELATION_HAS_MEMBER)).toBeInTheDocument(),
|
||||
expect(screen.getByText(RELATION_HAS_MEMBER)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await userEvent.click(getByText(RELATION_HAS_MEMBER));
|
||||
await userEvent.click(screen.getByText(RELATION_HAS_MEMBER));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
@@ -67,7 +68,7 @@ describe('<SelectedRelationsFilter/>', () => {
|
||||
|
||||
test('should return undefined if all values are selected', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByText, getByLabelText } = render(
|
||||
render(
|
||||
<SelectedRelationsFilter
|
||||
relationPairs={ALL_RELATION_PAIRS}
|
||||
value={ALL_RELATION_PAIRS.flatMap(p => p).filter(
|
||||
@@ -77,13 +78,13 @@ describe('<SelectedRelationsFilter/>', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(getByLabelText('Open'));
|
||||
await userEvent.click(screen.getByLabelText('Open'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText(RELATION_HAS_MEMBER)).toBeInTheDocument(),
|
||||
expect(screen.getByText(RELATION_HAS_MEMBER)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await userEvent.click(getByText(RELATION_HAS_MEMBER));
|
||||
await userEvent.click(screen.getByText(RELATION_HAS_MEMBER));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(undefined);
|
||||
@@ -92,7 +93,7 @@ describe('<SelectedRelationsFilter/>', () => {
|
||||
|
||||
test('should return all values when cleared', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByRole } = render(
|
||||
render(
|
||||
<SelectedRelationsFilter
|
||||
relationPairs={ALL_RELATION_PAIRS}
|
||||
value={[]}
|
||||
@@ -100,7 +101,7 @@ describe('<SelectedRelationsFilter/>', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(getByRole('combobox'));
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
await userEvent.tab();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -13,31 +13,28 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { SwitchFilter } from './SwitchFilter';
|
||||
|
||||
describe('<SwitchFilter/>', () => {
|
||||
test('should render value', () => {
|
||||
const { getByLabelText } = render(
|
||||
<SwitchFilter label="My label" value={false} onChange={() => {}} />,
|
||||
);
|
||||
render(<SwitchFilter label="My label" value={false} onChange={() => {}} />);
|
||||
|
||||
expect(getByLabelText('My label')).toBeInTheDocument();
|
||||
expect(getByLabelText('My label')).not.toBeChecked();
|
||||
expect(screen.getByLabelText('My label')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('My label')).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('should toggle value', async () => {
|
||||
const onChange = jest.fn();
|
||||
const { getByLabelText } = render(
|
||||
<SwitchFilter label="My label" value onChange={onChange} />,
|
||||
);
|
||||
render(<SwitchFilter label="My label" value onChange={onChange} />);
|
||||
|
||||
expect(getByLabelText('My label')).toBeInTheDocument();
|
||||
expect(getByLabelText('My label')).toBeChecked();
|
||||
expect(screen.getByLabelText('My label')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('My label')).toBeChecked();
|
||||
|
||||
await userEvent.click(getByLabelText('My label'));
|
||||
await userEvent.click(screen.getByLabelText('My label'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -13,17 +13,18 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
RELATION_CHILD_OF,
|
||||
RELATION_PARENT_OF,
|
||||
} from '@backstage/catalog-model';
|
||||
import { render } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { CustomLabel } from './CustomLabel';
|
||||
|
||||
describe('<CustomLabel />', () => {
|
||||
test('renders label', () => {
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<CustomLabel
|
||||
edge={{
|
||||
@@ -36,11 +37,11 @@ describe('<CustomLabel />', () => {
|
||||
</svg>,
|
||||
);
|
||||
|
||||
expect(getByText(RELATION_PARENT_OF)).toBeInTheDocument();
|
||||
expect(screen.getByText(RELATION_PARENT_OF)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders label with multiple relations', () => {
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<CustomLabel
|
||||
edge={{
|
||||
@@ -53,7 +54,7 @@ describe('<CustomLabel />', () => {
|
||||
</svg>,
|
||||
);
|
||||
|
||||
expect(getByText(RELATION_PARENT_OF)).toBeInTheDocument();
|
||||
expect(getByText(RELATION_CHILD_OF)).toBeInTheDocument();
|
||||
expect(screen.getByText(RELATION_PARENT_OF)).toBeInTheDocument();
|
||||
expect(screen.getByText(RELATION_CHILD_OF)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,14 +13,16 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { CustomNode } from './CustomNode';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('<CustomNode />', () => {
|
||||
test('renders node', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<CustomNode
|
||||
node={{
|
||||
@@ -35,11 +37,11 @@ describe('<CustomNode />', () => {
|
||||
</svg>,
|
||||
);
|
||||
|
||||
expect(getByText('kind:namespace/name')).toBeInTheDocument();
|
||||
expect(screen.getByText('kind:namespace/name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders node, skips default namespace', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<CustomNode
|
||||
node={{
|
||||
@@ -53,12 +55,12 @@ describe('<CustomNode />', () => {
|
||||
</svg>,
|
||||
);
|
||||
|
||||
expect(getByText('kind:name')).toBeInTheDocument();
|
||||
expect(screen.getByText('kind:name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders node with onClick', async () => {
|
||||
const onClick = jest.fn();
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<CustomNode
|
||||
node={{
|
||||
@@ -73,13 +75,13 @@ describe('<CustomNode />', () => {
|
||||
</svg>,
|
||||
);
|
||||
|
||||
expect(getByText('kind:namespace/name')).toBeInTheDocument();
|
||||
await userEvent.click(getByText('kind:namespace/name'));
|
||||
expect(screen.getByText('kind:namespace/name')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText('kind:namespace/name'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders title if entity has one', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<CustomNode
|
||||
node={{
|
||||
@@ -94,6 +96,6 @@ describe('<CustomNode />', () => {
|
||||
</svg>,
|
||||
);
|
||||
|
||||
expect(getByText('Custom Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { EntityKindIcon } from './EntityKindIcon';
|
||||
|
||||
+116
-121
@@ -24,6 +24,7 @@ import {
|
||||
import { DependencyGraphTypes } from '@backstage/core-components';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import { EntityRelationsGraph } from './EntityRelationsGraph';
|
||||
@@ -142,7 +143,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
relations: [],
|
||||
});
|
||||
|
||||
const { findByText, findAllByTestId } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
@@ -150,15 +151,15 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(1);
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(1);
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders a progress indicator while loading', async () => {
|
||||
catalog.getEntityByRef.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { findByRole } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
@@ -166,7 +167,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await findByRole('progressbar')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('progressbar')).toBeInTheDocument();
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -197,7 +198,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { findByText, findAllByTestId } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
@@ -205,32 +206,31 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(1);
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(1);
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('renders at max depth of one', async () => {
|
||||
catalog.getEntityByRef.mockImplementation(async n => entities[n as string]);
|
||||
|
||||
const { findByText, findAllByTestId, findAllByText } =
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
maxDepth={1}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
maxDepth={1}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(3);
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(3);
|
||||
|
||||
expect(await findAllByText('ownerOf')).toHaveLength(1);
|
||||
expect(await findAllByText('hasPart')).toHaveLength(1);
|
||||
expect(await findAllByTestId('label')).toHaveLength(2);
|
||||
expect(await screen.findAllByText('ownerOf')).toHaveLength(1);
|
||||
expect(await screen.findAllByText('hasPart')).toHaveLength(1);
|
||||
expect(await screen.findAllByTestId('label')).toHaveLength(2);
|
||||
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
@@ -238,26 +238,25 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
test('renders simplified graph at full depth', async () => {
|
||||
catalog.getEntityByRef.mockImplementation(async n => entities[n as string]);
|
||||
|
||||
const { findByText, findAllByText, findAllByTestId } =
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
unidirectional
|
||||
maxDepth={Number.POSITIVE_INFINITY}
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
unidirectional
|
||||
maxDepth={Number.POSITIVE_INFINITY}
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c2')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(4);
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c2')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(4);
|
||||
|
||||
expect(await findAllByText('ownerOf')).toHaveLength(1);
|
||||
expect(await findAllByText('hasPart')).toHaveLength(2);
|
||||
expect(await findAllByTestId('label')).toHaveLength(3);
|
||||
expect(await screen.findAllByText('ownerOf')).toHaveLength(1);
|
||||
expect(await screen.findAllByText('hasPart')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('label')).toHaveLength(3);
|
||||
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
@@ -265,28 +264,27 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
test('renders full graph at full depth', async () => {
|
||||
catalog.getEntityByRef.mockImplementation(async n => entities[n as string]);
|
||||
|
||||
const { findAllByText, findByText, findAllByTestId } =
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
unidirectional={false}
|
||||
mergeRelations={false}
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
unidirectional={false}
|
||||
mergeRelations={false}
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c2')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(4);
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c2')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(4);
|
||||
|
||||
expect(await findAllByText('ownerOf')).toHaveLength(2);
|
||||
expect(await findAllByText('ownedBy')).toHaveLength(2);
|
||||
expect(await findAllByText('hasPart')).toHaveLength(2);
|
||||
expect(await findAllByText('partOf')).toHaveLength(2);
|
||||
expect(await findAllByTestId('label')).toHaveLength(8);
|
||||
expect(await screen.findAllByText('ownerOf')).toHaveLength(2);
|
||||
expect(await screen.findAllByText('ownedBy')).toHaveLength(2);
|
||||
expect(await screen.findAllByText('hasPart')).toHaveLength(2);
|
||||
expect(await screen.findAllByText('partOf')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('label')).toHaveLength(8);
|
||||
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
@@ -294,26 +292,25 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
test('renders full graph at full depth with merged relations', async () => {
|
||||
catalog.getEntityByRef.mockImplementation(async n => entities[n as string]);
|
||||
|
||||
const { findAllByText, findByText, findAllByTestId } =
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
unidirectional={false}
|
||||
mergeRelations
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
unidirectional={false}
|
||||
mergeRelations
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c2')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(4);
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c2')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(4);
|
||||
|
||||
expect(await findAllByText('ownerOf')).toHaveLength(2);
|
||||
expect(await findAllByText('hasPart')).toHaveLength(2);
|
||||
expect(await findAllByTestId('label')).toHaveLength(4);
|
||||
expect(await screen.findAllByText('ownerOf')).toHaveLength(2);
|
||||
expect(await screen.findAllByText('hasPart')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('label')).toHaveLength(4);
|
||||
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
@@ -321,27 +318,26 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
test('renders a graph with multiple root nodes', async () => {
|
||||
catalog.getEntityByRef.mockImplementation(async n => entities[n as string]);
|
||||
|
||||
const { findAllByText, findByText, findAllByTestId } =
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={[
|
||||
{ kind: 'b', namespace: 'd', name: 'c' },
|
||||
{ kind: 'b', namespace: 'd', name: 'c2' },
|
||||
]}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={[
|
||||
{ kind: 'b', namespace: 'd', name: 'c' },
|
||||
{ kind: 'b', namespace: 'd', name: 'c2' },
|
||||
]}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await findByText('b:d/c2')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(4);
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('b:d/c2')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(4);
|
||||
|
||||
expect(await findAllByText('ownerOf')).toHaveLength(1);
|
||||
expect(await findAllByText('partOf')).toHaveLength(2);
|
||||
expect(await findAllByTestId('label')).toHaveLength(3);
|
||||
expect(await screen.findAllByText('ownerOf')).toHaveLength(1);
|
||||
expect(await screen.findAllByText('partOf')).toHaveLength(2);
|
||||
expect(await screen.findAllByTestId('label')).toHaveLength(3);
|
||||
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
@@ -349,23 +345,22 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
test('renders a graph with filtered kinds and relations', async () => {
|
||||
catalog.getEntityByRef.mockImplementation(async n => entities[n as string]);
|
||||
|
||||
const { findAllByText, findByText, findAllByTestId } =
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
relations={['ownerOf', 'ownedBy']}
|
||||
kinds={['k']}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
relations={['ownerOf', 'ownedBy']}
|
||||
kinds={['k']}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(await findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await findAllByTestId('node')).toHaveLength(2);
|
||||
expect(await screen.findByText('b:d/c')).toBeInTheDocument();
|
||||
expect(await screen.findByText('k:d/a1')).toBeInTheDocument();
|
||||
expect(await screen.findAllByTestId('node')).toHaveLength(2);
|
||||
|
||||
expect(await findAllByText('ownerOf')).toHaveLength(1);
|
||||
expect(await findAllByTestId('label')).toHaveLength(1);
|
||||
expect(await screen.findAllByText('ownerOf')).toHaveLength(1);
|
||||
expect(await screen.findAllByTestId('label')).toHaveLength(1);
|
||||
|
||||
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -374,7 +369,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
catalog.getEntityByRef.mockImplementation(async n => entities[n as string]);
|
||||
|
||||
const onNodeClick = jest.fn();
|
||||
const { findByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
@@ -383,7 +378,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await userEvent.click(await findByText('k:d/a1'));
|
||||
await userEvent.click(await screen.findByText('k:d/a1'));
|
||||
expect(onNodeClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -397,7 +392,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
</g>
|
||||
);
|
||||
|
||||
const { findAllByTestId, container } = await renderInTestApp(
|
||||
const { container } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
@@ -406,7 +401,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const node = await findAllByTestId(CUSTOM_TEST_ID);
|
||||
const node = await screen.findAllByTestId(CUSTOM_TEST_ID);
|
||||
expect(node[0]).toBeInTheDocument();
|
||||
expect(container.querySelector('circle')).toBeInTheDocument();
|
||||
});
|
||||
@@ -421,7 +416,7 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
</g>
|
||||
);
|
||||
|
||||
const { findAllByTestId, findAllByText, container } = await renderInTestApp(
|
||||
const { container } = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<EntityRelationsGraph
|
||||
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
|
||||
@@ -429,10 +424,10 @@ describe('<EntityRelationsGraph/>', () => {
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
const node = await findAllByTestId(CUSTOM_TEST_ID);
|
||||
const node = await screen.findAllByTestId(CUSTOM_TEST_ID);
|
||||
expect(node[0]).toBeInTheDocument();
|
||||
expect(container.querySelector('circle')).toBeInTheDocument();
|
||||
const labels = await findAllByText('Test-Labelvisible');
|
||||
const labels = await screen.findAllByText('Test-Labelvisible');
|
||||
expect(labels[0]).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
|
||||
rules: {
|
||||
'testing-library/prefer-screen-queries': 'error',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { catalogImportApiRef, CatalogImportClient } from '../../api';
|
||||
import { DefaultImportPage } from './DefaultImportPage';
|
||||
@@ -65,14 +66,14 @@ describe('<DefaultImportPage />', () => {
|
||||
});
|
||||
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<DefaultImportPage />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByText('Start tracking your component in Backstage'),
|
||||
screen.getByText('Start tracking your component in Backstage'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
TestApiProvider,
|
||||
TestApiRegistry,
|
||||
} from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { CatalogImportApi, catalogImportApiRef } from '../../api';
|
||||
import { ImportInfoCard } from './ImportInfoCard';
|
||||
@@ -49,7 +50,7 @@ describe('<ImportInfoCard />', () => {
|
||||
});
|
||||
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, new ConfigReader({ integrations: {} })],
|
||||
@@ -60,32 +61,34 @@ describe('<ImportInfoCard />', () => {
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(getByText('Register an existing component')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Register an existing component'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders section on GitHub discovery if supported', async () => {
|
||||
catalogImportApi.preparePullRequest = async () => ({ title: '', body: '' });
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<ImportInfoCard />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(getByText(/The wizard discovers all/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/The wizard discovers all/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders section on pull requests if supported', async () => {
|
||||
catalogImportApi.preparePullRequest = async () => ({ title: '', body: '' });
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<ImportInfoCard />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByText(/the wizard will prepare a Pull Request/),
|
||||
screen.getByText(/the wizard will prepare a Pull Request/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { useOutlet } from 'react-router';
|
||||
import { catalogImportApiRef, CatalogImportClient } from '../../api';
|
||||
@@ -71,26 +72,26 @@ describe('<ImportPage />', () => {
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<ImportPage />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
getByText('Start tracking your component in Backstage'),
|
||||
screen.getByText('Start tracking your component in Backstage'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with custom children', async () => {
|
||||
(useOutlet as jest.Mock).mockReturnValue(<div>Hello World</div>);
|
||||
|
||||
const { getByText } = await renderInTestApp(
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<ImportPage />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(getByText('Hello World')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+62
-83
@@ -16,7 +16,7 @@
|
||||
|
||||
import { errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { AnalyzeResult, catalogImportApiRef } from '../../api/';
|
||||
@@ -60,19 +60,20 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
});
|
||||
|
||||
it('renders without exploding', async () => {
|
||||
const { getByRole } = render(
|
||||
<StepInitAnalyzeUrl onAnalysis={() => undefined} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
render(<StepInitAnalyzeUrl onAnalysis={() => undefined} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(getByRole('textbox', { name: /Repository/i })).toBeInTheDocument();
|
||||
expect(getByRole('textbox', { name: /Repository/i })).toHaveValue('');
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: /Repository/i })).toHaveValue(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default analysis url', async () => {
|
||||
const { getByRole } = render(
|
||||
render(
|
||||
<StepInitAnalyzeUrl
|
||||
onAnalysis={() => undefined}
|
||||
analysisUrl="https://default"
|
||||
@@ -82,8 +83,10 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByRole('textbox', { name: /Repository/i })).toBeInTheDocument();
|
||||
expect(getByRole('textbox', { name: /Repository/i })).toHaveValue(
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: /Repository/i })).toHaveValue(
|
||||
'https://default',
|
||||
);
|
||||
});
|
||||
@@ -91,16 +94,13 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
it('should not analyze without url', async () => {
|
||||
const onAnalysisFn = jest.fn();
|
||||
|
||||
const { getByRole } = render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await userEvent.click(getByRole('button', { name: /Analyze/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Analyze/i }));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -114,26 +114,23 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
it('should not analyze invalid value', async () => {
|
||||
const onAnalysisFn = jest.fn();
|
||||
|
||||
const { getByRole, getByText } = render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
getByRole('textbox', { name: /Repository/i }),
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
'http:/',
|
||||
);
|
||||
await userEvent.click(getByRole('button', { name: /Analyze/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Analyze/i }));
|
||||
});
|
||||
|
||||
expect(catalogImportApi.analyzeUrl).toHaveBeenCalledTimes(0);
|
||||
expect(onAnalysisFn).toHaveBeenCalledTimes(0);
|
||||
expect(errorApi.post).toHaveBeenCalledTimes(0);
|
||||
expect(
|
||||
getByText('Must start with http:// or https://.'),
|
||||
screen.getByText('Must start with http:// or https://.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -145,12 +142,9 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
locations: [location],
|
||||
} as AnalyzeResult;
|
||||
|
||||
const { getByRole } = render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -158,10 +152,10 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
getByRole('textbox', { name: /Repository/i }),
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
'https://my-repository',
|
||||
);
|
||||
await userEvent.click(getByRole('button', { name: /Analyze/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Analyze/i }));
|
||||
});
|
||||
|
||||
expect(onAnalysisFn).toHaveBeenCalledTimes(1);
|
||||
@@ -182,12 +176,9 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
locations: [location, location],
|
||||
} as AnalyzeResult;
|
||||
|
||||
const { getByRole } = render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -195,10 +186,10 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
getByRole('textbox', { name: /Repository/i }),
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
'https://my-repository-1',
|
||||
);
|
||||
await userEvent.click(getByRole('button', { name: /Analyze/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Analyze/i }));
|
||||
});
|
||||
|
||||
expect(onAnalysisFn).toHaveBeenCalledTimes(1);
|
||||
@@ -218,12 +209,9 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
locations: [],
|
||||
} as AnalyzeResult;
|
||||
|
||||
const { getByRole, getByText } = render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -231,15 +219,15 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
getByRole('textbox', { name: /Repository/i }),
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
'https://my-repository-1',
|
||||
);
|
||||
await userEvent.click(getByRole('button', { name: /Analyze/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Analyze/i }));
|
||||
});
|
||||
|
||||
expect(onAnalysisFn).toHaveBeenCalledTimes(0);
|
||||
expect(
|
||||
getByText('There are no entities at this location'),
|
||||
screen.getByText('There are no entities at this location'),
|
||||
).toBeInTheDocument();
|
||||
expect(errorApi.post).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -262,12 +250,9 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
],
|
||||
} as AnalyzeResult;
|
||||
|
||||
const { getByRole } = render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -275,10 +260,10 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
getByRole('textbox', { name: /Repository/i }),
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
'https://my-repository-2',
|
||||
);
|
||||
await userEvent.click(getByRole('button', { name: /Analyze/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Analyze/i }));
|
||||
});
|
||||
|
||||
expect(onAnalysisFn).toHaveBeenCalledTimes(1);
|
||||
@@ -300,12 +285,9 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
generatedEntities: [],
|
||||
} as AnalyzeResult;
|
||||
|
||||
const { getByRole, getByText } = render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve(analyzeResult),
|
||||
@@ -313,15 +295,15 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
getByRole('textbox', { name: /Repository/i }),
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
'https://my-repository-2',
|
||||
);
|
||||
await userEvent.click(getByRole('button', { name: /Analyze/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Analyze/i }));
|
||||
});
|
||||
|
||||
expect(onAnalysisFn).toHaveBeenCalledTimes(0);
|
||||
expect(
|
||||
getByText("Couldn't generate entities for your repository"),
|
||||
screen.getByText("Couldn't generate entities for your repository"),
|
||||
).toBeInTheDocument();
|
||||
expect(errorApi.post).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -344,7 +326,7 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
],
|
||||
} as AnalyzeResult;
|
||||
|
||||
const { getByRole, getByText } = render(
|
||||
render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} disablePullRequest />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
@@ -357,15 +339,15 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
getByRole('textbox', { name: /Repository/i }),
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
'https://my-repository-2',
|
||||
);
|
||||
await userEvent.click(getByRole('button', { name: /Analyze/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Analyze/i }));
|
||||
});
|
||||
|
||||
expect(onAnalysisFn).toHaveBeenCalledTimes(0);
|
||||
expect(
|
||||
getByText("Couldn't generate entities for your repository"),
|
||||
screen.getByText("Couldn't generate entities for your repository"),
|
||||
).toBeInTheDocument();
|
||||
expect(errorApi.post).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -373,12 +355,9 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
it('should report unknown type to the errorapi', async () => {
|
||||
const onAnalysisFn = jest.fn();
|
||||
|
||||
const { getByRole, getByText } = render(
|
||||
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
|
||||
{
|
||||
wrapper: Wrapper,
|
||||
},
|
||||
);
|
||||
render(<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
catalogImportApi.analyzeUrl.mockReturnValueOnce(
|
||||
Promise.resolve({ type: 'unknown' } as any as AnalyzeResult),
|
||||
@@ -386,15 +365,15 @@ describe('<StepInitAnalyzeUrl />', () => {
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.type(
|
||||
getByRole('textbox', { name: /Repository/i }),
|
||||
screen.getByRole('textbox', { name: /Repository/i }),
|
||||
'https://my-repository-2',
|
||||
);
|
||||
await userEvent.click(getByRole('button', { name: /Analyze/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Analyze/i }));
|
||||
});
|
||||
|
||||
expect(onAnalysisFn).toHaveBeenCalledTimes(0);
|
||||
expect(
|
||||
getByText(
|
||||
screen.getByText(
|
||||
'Received unknown analysis result of type unknown. Please contact the support team.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
+15
-11
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { FormHelperText, TextField } from '@material-ui/core';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { asInputRef } from '../helpers';
|
||||
@@ -25,7 +25,7 @@ describe('<PreparePullRequestForm />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const onSubmitFn = jest.fn();
|
||||
|
||||
const { getByRole } = render(
|
||||
render(
|
||||
<PreparePullRequestForm<{ main: string }>
|
||||
defaultValues={{ main: 'default' }}
|
||||
render={({ register }) => (
|
||||
@@ -39,7 +39,7 @@ describe('<PreparePullRequestForm />', () => {
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(getByRole('button', { name: /submit/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
|
||||
expect(onSubmitFn).toHaveBeenCalledTimes(1);
|
||||
@@ -49,7 +49,7 @@ describe('<PreparePullRequestForm />', () => {
|
||||
it('should register a text field', async () => {
|
||||
const onSubmitFn = jest.fn();
|
||||
|
||||
const { getByRole, getByLabelText } = render(
|
||||
render(
|
||||
<PreparePullRequestForm<{ main: string }>
|
||||
defaultValues={{ main: 'default' }}
|
||||
render={({ register }) => (
|
||||
@@ -67,9 +67,9 @@ describe('<PreparePullRequestForm />', () => {
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.clear(getByLabelText('Main Field'));
|
||||
await userEvent.type(getByLabelText('Main Field'), 'My Text');
|
||||
await userEvent.click(getByRole('button', { name: /submit/i }));
|
||||
await userEvent.clear(screen.getByLabelText('Main Field'));
|
||||
await userEvent.type(screen.getByLabelText('Main Field'), 'My Text');
|
||||
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
|
||||
expect(onSubmitFn).toHaveBeenCalledTimes(1);
|
||||
@@ -79,7 +79,7 @@ describe('<PreparePullRequestForm />', () => {
|
||||
it('registers required attribute', async () => {
|
||||
const onSubmitFn = jest.fn();
|
||||
|
||||
const { queryByText, getByRole } = render(
|
||||
render(
|
||||
<PreparePullRequestForm<{ main: string }>
|
||||
defaultValues={{}}
|
||||
render={({ formState, register }) => (
|
||||
@@ -100,13 +100,17 @@ describe('<PreparePullRequestForm />', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(queryByText('Error in required main field')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Error in required main field'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(getByRole('button', { name: /submit/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||
});
|
||||
|
||||
expect(onSubmitFn).not.toHaveBeenCalled();
|
||||
expect(queryByText('Error in required main field')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Error in required main field'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+10
-10
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { render } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { PreviewPullRequestComponent } from './PreviewPullRequestComponent';
|
||||
@@ -28,15 +28,15 @@ const useStyles = makeStyles({
|
||||
|
||||
describe('<PreviewPullRequestComponent />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<PreviewPullRequestComponent
|
||||
title="My Title"
|
||||
description="My **description**"
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = getByText('My Title');
|
||||
const description = getByText('description', { selector: 'strong' });
|
||||
const title = screen.getByText('My Title');
|
||||
const description = screen.getByText('description', { selector: 'strong' });
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).toBeVisible();
|
||||
expect(description).toBeInTheDocument();
|
||||
@@ -46,7 +46,7 @@ describe('<PreviewPullRequestComponent />', () => {
|
||||
it('renders card with custom styles', async () => {
|
||||
const { result } = renderHook(() => useStyles());
|
||||
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<PreviewPullRequestComponent
|
||||
title="My Title"
|
||||
description="My **description**"
|
||||
@@ -54,8 +54,8 @@ describe('<PreviewPullRequestComponent />', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = getByText('My Title');
|
||||
const description = getByText('description', { selector: 'strong' });
|
||||
const title = screen.getByText('My Title');
|
||||
const description = screen.getByText('description', { selector: 'strong' });
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).not.toBeVisible();
|
||||
expect(description).toBeInTheDocument();
|
||||
@@ -65,7 +65,7 @@ describe('<PreviewPullRequestComponent />', () => {
|
||||
it('renders with custom styles', async () => {
|
||||
const { result } = renderHook(() => useStyles());
|
||||
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<PreviewPullRequestComponent
|
||||
title="My Title"
|
||||
description="My **description**"
|
||||
@@ -73,8 +73,8 @@ describe('<PreviewPullRequestComponent />', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = getByText('My Title');
|
||||
const description = getByText('description', { selector: 'strong' });
|
||||
const title = screen.getByText('My Title');
|
||||
const description = screen.getByText('description', { selector: 'strong' });
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title).toBeVisible();
|
||||
expect(description).toBeInTheDocument();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user