diff --git a/.changeset/angry-dingos-lick.md b/.changeset/angry-dingos-lick.md new file mode 100644 index 0000000000..933f19a839 --- /dev/null +++ b/.changeset/angry-dingos-lick.md @@ -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 diff --git a/.changeset/chilly-ads-lay.md b/.changeset/chilly-ads-lay.md new file mode 100644 index 0000000000..844eaa8dc7 --- /dev/null +++ b/.changeset/chilly-ads-lay.md @@ -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. diff --git a/.changeset/clever-pillows-drive.md b/.changeset/clever-pillows-drive.md new file mode 100644 index 0000000000..f4e60fb869 --- /dev/null +++ b/.changeset/clever-pillows-drive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Do not use deprecated `LocationSpec` from the `@backstage/plugin-catalog-node` package diff --git a/.changeset/early-hairs-switch.md b/.changeset/early-hairs-switch.md new file mode 100644 index 0000000000..16f4298db5 --- /dev/null +++ b/.changeset/early-hairs-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +changed tests created by create-plugin to follow eslint-rules best practices particularly testing-library/prefer-screen-queries and testing-library/render-result-naming-convention diff --git a/.changeset/fast-lies-remain.md b/.changeset/fast-lies-remain.md new file mode 100644 index 0000000000..74be117920 --- /dev/null +++ b/.changeset/fast-lies-remain.md @@ -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. diff --git a/.changeset/fluffy-walls-approve.md b/.changeset/fluffy-walls-approve.md new file mode 100644 index 0000000000..968f5cc402 --- /dev/null +++ b/.changeset/fluffy-walls-approve.md @@ -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); +``` diff --git a/.changeset/funny-numbers-compete.md b/.changeset/funny-numbers-compete.md new file mode 100644 index 0000000000..604ffe7294 --- /dev/null +++ b/.changeset/funny-numbers-compete.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Use Json types from @backstage/types diff --git a/.changeset/gorgeous-hairs-applaud.md b/.changeset/gorgeous-hairs-applaud.md new file mode 100644 index 0000000000..7793c6591e --- /dev/null +++ b/.changeset/gorgeous-hairs-applaud.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend-node': patch +--- + +Wait for indexer initialization before finalizing indexing. diff --git a/.changeset/hip-stingrays-kneel.md b/.changeset/hip-stingrays-kneel.md new file mode 100644 index 0000000000..171e9257f2 --- /dev/null +++ b/.changeset/hip-stingrays-kneel.md @@ -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. diff --git a/.changeset/itchy-walls-boil.md b/.changeset/itchy-walls-boil.md new file mode 100644 index 0000000000..2afd0287fe --- /dev/null +++ b/.changeset/itchy-walls-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': minor +--- + +Added the ability to fully customize settings page. Deprecated UserSettingsTab in favour of SettingsLayout.Route diff --git a/.changeset/new-bugs-march.md b/.changeset/new-bugs-march.md new file mode 100644 index 0000000000..346f5a151e --- /dev/null +++ b/.changeset/new-bugs-march.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-explore': patch +--- + +Adds styling to graph forcing it to always fill out the available space. diff --git a/.changeset/nine-ears-whisper.md b/.changeset/nine-ears-whisper.md new file mode 100644 index 0000000000..a1427158af --- /dev/null +++ b/.changeset/nine-ears-whisper.md @@ -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`. diff --git a/.changeset/old-keys-leave.md b/.changeset/old-keys-leave.md new file mode 100644 index 0000000000..d80574b2e4 --- /dev/null +++ b/.changeset/old-keys-leave.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Added `lifecycleFactory` implementation. diff --git a/.changeset/renovate-4bb70f3.md b/.changeset/renovate-4bb70f3.md new file mode 100644 index 0000000000..3a9054011f --- /dev/null +++ b/.changeset/renovate-4bb70f3.md @@ -0,0 +1,17 @@ +--- +'@backstage/core-app-api': patch +'@backstage/core-components': patch +'@backstage/core-plugin-api': patch +'@backstage/dev-utils': patch +'@backstage/test-utils': patch +'@backstage/types': patch +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-config-schema': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-shortcuts': patch +'@backstage/plugin-user-settings': patch +--- + +Updated dependency `zen-observable` to `^0.9.0`. diff --git a/.changeset/renovate-778b2fa.md b/.changeset/renovate-778b2fa.md new file mode 100644 index 0000000000..f4f57e448b --- /dev/null +++ b/.changeset/renovate-778b2fa.md @@ -0,0 +1,7 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-codescene': patch +'@backstage/plugin-sonarqube': patch +--- + +Updated dependency `rc-progress` to `3.4.1`. diff --git a/.changeset/search-om-manniskan-ginge.md b/.changeset/search-om-manniskan-ginge.md new file mode 100644 index 0000000000..c36972f191 --- /dev/null +++ b/.changeset/search-om-manniskan-ginge.md @@ -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). diff --git a/.changeset/serious-windows-occur.md b/.changeset/serious-windows-occur.md new file mode 100644 index 0000000000..37f778e846 --- /dev/null +++ b/.changeset/serious-windows-occur.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': patch +'@backstage/core-components': patch +--- + +Internal refactor to avoid usage of deprecated symbols. diff --git a/.changeset/silly-wolves-remember.md b/.changeset/silly-wolves-remember.md new file mode 100644 index 0000000000..f39812db88 --- /dev/null +++ b/.changeset/silly-wolves-remember.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Registered shutdown hook in experimental catalog plugin. diff --git a/.changeset/slow-dragons-promise.md b/.changeset/slow-dragons-promise.md new file mode 100644 index 0000000000..045b4f34e3 --- /dev/null +++ b/.changeset/slow-dragons-promise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-github': patch +--- + +Fix incorrectly exported GithubOrgEntityProvider as a type diff --git a/.changeset/soft-nails-arrive.md b/.changeset/soft-nails-arrive.md new file mode 100644 index 0000000000..e9c0a40a05 --- /dev/null +++ b/.changeset/soft-nails-arrive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Implement Custom Field Explorer to view and play around with available installed custom field extensions diff --git a/.changeset/sour-flowers-care.md b/.changeset/sour-flowers-care.md new file mode 100644 index 0000000000..8524c4981c --- /dev/null +++ b/.changeset/sour-flowers-care.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Adds the ability to supply a `transformErrors` function to the `Stepper` for `/next` diff --git a/.changeset/sour-plums-grow.md b/.changeset/sour-plums-grow.md new file mode 100644 index 0000000000..4070dc5e50 --- /dev/null +++ b/.changeset/sour-plums-grow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +fixed `headerOptions` not passed to `TemplatePage` component diff --git a/.changeset/thin-donuts-join.md b/.changeset/thin-donuts-join.md new file mode 100644 index 0000000000..84705711d2 --- /dev/null +++ b/.changeset/thin-donuts-join.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Fixed Entity kind pluralisation in the `CatalogKindHeader` component. diff --git a/.changeset/twelve-meals-smell.md b/.changeset/twelve-meals-smell.md new file mode 100644 index 0000000000..a9d64823a3 --- /dev/null +++ b/.changeset/twelve-meals-smell.md @@ -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. diff --git a/.changeset/twenty-dodos-wash.md b/.changeset/twenty-dodos-wash.md new file mode 100644 index 0000000000..d21088a953 --- /dev/null +++ b/.changeset/twenty-dodos-wash.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Added `lifecycleFactory` to default service factories. diff --git a/.changeset/weak-ears-jam.md b/.changeset/weak-ears-jam.md new file mode 100644 index 0000000000..8f20937998 --- /dev/null +++ b/.changeset/weak-ears-jam.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Added `integrations.github.apps.allowedInstallationOwners` to the configuration schema. diff --git a/.changeset/young-turkeys-relax.md b/.changeset/young-turkeys-relax.md new file mode 100644 index 0000000000..c53cd984a6 --- /dev/null +++ b/.changeset/young-turkeys-relax.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added initial support for registering shutdown hooks via `lifecycleServiceRef`. diff --git a/.eslintrc.js b/.eslintrc.js index 47a47daee6..6d07fab291 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', }, }; diff --git a/.github/vale/Vocab/Backstage/accept.txt b/.github/vale/Vocab/Backstage/accept.txt index 58f5b57004..f2b3ac0b09 100644 --- a/.github/vale/Vocab/Backstage/accept.txt +++ b/.github/vale/Vocab/Backstage/accept.txt @@ -241,6 +241,7 @@ pageview parallelization parseable Patrik +pattison Peloton performant Performant @@ -316,6 +317,7 @@ Spotifiers spotify Spotify sqlite +sqs squidfunk src statefulsets @@ -406,3 +408,4 @@ zod Zolotusky zoomable zsh +Lainfiesta diff --git a/.github/workflows/automate_merge_message.yml b/.github/workflows/automate_merge_message.yml new file mode 100644 index 0000000000..9d8deffd8a --- /dev/null +++ b/.github/workflows/automate_merge_message.yml @@ -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, + }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d826bc8bb4..eef5058690 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/verify_microsite.yml b/.github/workflows/verify_microsite.yml index 3a24befa84..4085f334f3 100644 --- a/.github/workflows/verify_microsite.yml +++ b/.github/workflows/verify_microsite.yml @@ -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 diff --git a/REVIEWING.md b/REVIEWING.md index 469ac249f7..500e15701d 100644 --- a/REVIEWING.md +++ b/REVIEWING.md @@ -171,6 +171,37 @@ Exported types can be marked with either `@public`, `@alpha` or `@beta` release If a package does not have this configuration, then all exported types are considered stable, even if they are marked as `@alpha` or `@beta`. +#### Changes that are Not Considered Breaking + +There are a few exceptions that are not considered breaking in the [versioning policy](https://backstage.io/docs/overview/versioning-policy#changes-that-are-not-considered-breaking), +primarily regarding Utility APIs and Backend Service interfaces (referred to "contracts" below) that are supplied by the +Backstage core packages. + +Example of a non-breaking change to a contract which has a default +implementation, since consumers typically only interact with that contract as +callers of existing methods: + +```diff + export interface MyService { + oldMethod(): void; ++ newMethod(): void; + } +``` + +Changes such as these must still be marked with `**BREAKING PRODUCERS**:` in the +changelog, to highlight them for those who might be implementing custom +implementations of those contracts or putting mocks of the contract in tests. + +Example of a breaking change to a contract, which affects existing consumers and +therefore makes it NOT fall under these exceptions: + +```diff + export interface MyService { +- oldMethod(): void; ++ oldMethod(): Promise; + } +``` + #### Type Contract Direction An important distinction to make when looking at changes to an API Report is the direction of the contract of a changed type, that is, whether it's used as input or output from the user's point of view. In the next two sections we'll dive into the different directions of a type contract, and how it affects whether a change is breaking or not. diff --git a/docs/assets/getting-started/sidebar-submenu-example.png b/docs/assets/getting-started/sidebar-submenu-example.png new file mode 100644 index 0000000000..0b30f0ca6a Binary files /dev/null and b/docs/assets/getting-started/sidebar-submenu-example.png differ diff --git a/docs/assets/software-templates/custom-field-explorer.png b/docs/assets/software-templates/custom-field-explorer.png new file mode 100644 index 0000000000..1af897ac8c Binary files /dev/null and b/docs/assets/software-templates/custom-field-explorer.png differ diff --git a/docs/auth/index.md b/docs/auth/index.md index ab04d3ed87..5702e96cee 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -258,9 +258,58 @@ Passport-supported authentication method. ## Custom ScmAuthApi Implementation -If you are using any custom authentication providers, like for example one for GitHub Enterprise, then you are likely to need a custom implementation of the [`ScmAuthApi`](https://backstage.io/docs/reference/integration-react.scmauthapi). It is an API used to authenticate towards different SCM systems in a generic way, based on what resource is being accessed, and is used for example by the Scaffolder (Software Templates) and Catalog Import plugins. +The default `ScmAuthAPi` provides integrations for `github`, `gitlab`, `azure` and `bitbucket` and is created by the following code in `packages/app/src/apis.ts`: -To set up a custom `ScmAuthApi` implementation, you'll need to add an API factory entry to `packages/app/src/apis.ts`. The following example shows an implementation that supports both public GitHub via `githubAuthApi` as well as a GitHub Enterprise installation hosted at `ghe.example.com` via `gheAuthApi`: +```ts +ScmAuth.createDefaultApiFactory(); +``` + +If you require only a subset of these integrations, then you will need a custom implementation of the [`ScmAuthApi`](https://backstage.io/docs/reference/integration-react.scmauthapi). It is an API used to authenticate different SCM systems generically, based on what resource is being accessed, and is used for example, by the Scaffolder (Software Templates) and Catalog Import plugins. + +The first step is to remove the code that creates the default providers. + +```diff + import { + ScmIntegrationsApi, + scmIntegrationsApiRef, ++ ScmAuth, + } from '@backstage/integration-react'; + + export const apis: AnyApiFactory[] = [ +... ++ ScmAuth.createDefaultApiFactory(), +... + ]; +``` + +Then replace it with something like this, which will create an `ApiFactory` with only a github provider. + +```ts +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: scmAuthApiRef, + deps: { + githubAuthApi: githubAuthApiRef, + }, + factory: ({ githubAuthApi }) => + ScmAuth.merge( + ScmAuth.forGithub(githubAuthApi), + ), + }); +``` + +If you use any custom authentication integrations, a new provider can be added to the `ApiFactory`. + +The first step is to create a new authentication ref, which follows the naming convention of `xxxAuthApiRef`. The example below is for a new GitHub enterprise integration which can be defined either inside the app itself if it's only used for this purpose or inside a common internal package for APIs, such as `@internal/apis`: + +```ts +const gheAuthApiRef: ApiRef = + createApiRef({ + id: 'internal.auth.ghe', + }); +``` + +The new ref is then used to add a new provider to the ApiFactory: ```ts createApiFactory({ @@ -278,3 +327,14 @@ createApiFactory({ ), }); ``` + +Finally, you also need to add and configure another provider to the `auth-backend` using the provider ID, which in this example is `ghe`: + +```ts +import { providers } from '@backstage/plugin-auth-backend'; + +// Add the following options to `createRouter` in packages/backend/src/plugins/auth.ts +providerFactories: { + ghe: providers.github.create(), +}, +``` diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index 71b9c276d9..726d1557d0 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -68,7 +68,7 @@ export const searchPage = ( switch (result.type) { case 'software-catalog': return ( - { It's good practice for search results to highlight information that was used to return it in the first place! The code below highlights how you might specify a -custom result item component, using the `` component as +custom result item component, using the `` component as an example: ```tsx {7-13} @@ -265,7 +265,7 @@ an example: switch (result.type) { case 'software-catalog': return ( - ) => { + return ( + 0 && !formData} + onChange={onChange} + focused={focused} + /> + ); +}; +``` + +```tsx +// packages/app/src/scaffolder/MyCustomExtensionWithOptions/extensions.ts +... +import { MyCustomExtensionWithOptions, MyCustomExtensionWithOptionsSchema } from './MyCustomExtensionWithOptions'; + +export const MyCustomFieldWithOptionsExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + name: 'MyCustomExtensionWithOptions', + component: MyCustomExtensionWithOptions, + schema: MyCustomExtensionWithOptionsSchema, + }), +); +``` + +We recommend using a library like [zod](https://github.com/colinhacks/zod) to define your schema +and the provided `makeFieldSchemaFromZod` helper utility function to generate both the JSON schema +and type for your field props to preventing having to duplicate the definitions: + +```tsx +//packages/app/src/scaffolder/MyCustomExtensionWithOptions/MyCustomExtensionWithOptions.tsx +... +import { z } from 'zod'; +import { makeFieldSchemaFromZod } from '@backstage/plugin-scaffolder'; + +const MyCustomExtensionWithOptionsFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + focused: z + .boolean() + .optional() + .describe('Whether to focus this field'), + }), +); + +export const MyCustomExtensionWithOptionsSchema = MyCustomExtensionWithOptionsFieldSchema.schema; + +type MyCustomExtensionWithOptionsProps = typeof MyCustomExtensionWithOptionsFieldSchema.type; + +export const MyCustomExtensionWithOptions = ({ + onChange, + rawErrors, + required, + formData, +}: MyCustomExtensionWithOptionsProps) => { + return ( + 0 && !formData} + onChange={onChange} + focused={focused} + /> + ); +}; +``` diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index 146f160d71..5ec0d6eeab 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -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 `` with this: + + ```ts + + + + + + + + + + + + + + ``` + +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: + +![Sidebar sub-menu example](./../assets/getting-started/sidebar-submenu-example.png) + +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 diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 477467bb88..16e361a19f 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -34,6 +34,7 @@ guide to do a repository-based installation. - [Package manager](https://nodejs.org/en/download/package-manager/) - [Using NodeSource packages](https://github.com/nodesource/distributions/blob/master/README.md) - `yarn` [Installation](https://classic.yarnpkg.com/en/docs/install) + - You will need to use Yarn classic to create a new project, but it can then be [migrated to Yarn 3](../tutorials/yarn-migration.md) - `docker` [installation](https://docs.docker.com/engine/install/) - `git` [installation](https://github.com/git-guides/install-git) - If the system is not directly accessible over your network the following ports diff --git a/docs/overview/versioning-policy.md b/docs/overview/versioning-policy.md index a989f104b5..10ffc82188 100644 --- a/docs/overview/versioning-policy.md +++ b/docs/overview/versioning-policy.md @@ -99,7 +99,7 @@ This versioning is completely decoupled from the Backstage release versioning, meaning you might for example have `@backstage/core-plugin-api` version `3.1.4` be part of the `1.12` Backstage release. -Following versioning policy applies to all packages: +The following versioning policy applies to all packages: - Breaking changes are noted in the changelog, and documentation is updated. - Breaking changes are prefixed with `**BREAKING**: ` in the changelog. @@ -120,6 +120,25 @@ For packages at version `1.0.0` or above, the following policy also applies: changes without a deprecation period, but the changes must still adhere to semver. +### Changes that are Not Considered Breaking + +There are a few changes that would typically be considered breaking changes, but +that we make exceptions for. This is both to be able to evolve the project more +rapidly, also because the alternative ends up having a bigger impact on users. + +For all Utility APIs and Backend Services that _have_ a built-in implementation, +we only consider the API stability for consumers of those interfaces. This means +that it is not considered a breaking change to break the contract for producers +of the interface. + +Changes that fall under the above rule, must be marked with +`**BREAKING PRODUCERS**:` in the changelog. + +For any case of dependency injection, it is not considered a breaking change to +add a dependency on a Utility API or Backend Service that is provided by the +framework. This includes any dependency that is provided by the +`@backstage/app-defaults` and `@backstage/backend-defaults` packages. + ### Release Stages The release stages(`@alpha`, `@beta` `@public`) refers to the diff --git a/microsite/blog/2022-11-15-linux-foundation-introduction-to-backstage-course.md b/microsite/blog/2022-11-15-linux-foundation-introduction-to-backstage-course.md new file mode 100644 index 0000000000..b762bd7e97 --- /dev/null +++ b/microsite/blog/2022-11-15-linux-foundation-introduction-to-backstage-course.md @@ -0,0 +1,16 @@ +--- +title: The Linux Foundation launches its Introduction to Backstage course +description: The new Linux Foundation course is available for free on edX and is designed to help individuals understand how to map Backstage to their organization needs +author: Jorge Lainfiesta, Roadie +authorURL: https://www.linkedin.com/in/jrlainfiesta/ +--- + +Backstage continues to grow in [popularity and maturity](https://roadie.io/blog/backstage-consolidating-its-role-in-the-cloud-native-ecosystem/), with industry leaders not only adopting the framework but actively participating in the community through contributions and commercial offerings. One of the most recent contributions comes from the Linux Foundation, [launching an introductory course](https://training.linuxfoundation.org/blog/23107/) aimed at DevOps engineers or professionals working on Developer Productivity or Developer Experience. The course is available for free on edX, with a paid option for the certified track. + +[![Introduction to Backstage: Developer Portals Made Easy (LFS142x): Enroll!](assets/22-11-15/Introduction-to-Backstage-Developer-Portals-Made-Easy-2-768x432.png)](https://www.edx.org/course/introduction-to-backstage-developer-portals-made-easy) + + + +The course, [Introduction to Backstage: Developer Portals Made Easy (LFS142x)](https://www.edx.org/course/introduction-to-backstage-developer-portals-made-easy), starts discussing the benefits of adopting a Developer Portal. Then, it dives into what is Backstage, its main features (Catalog, Scaffolder, TechDocs), and how to map its capabilities to your organization. At last, it provides tips on how to connect with the community to ease up your adoption journey. + +The course author, [Jorge Lainfiesta](https://www.linkedin.com/in/jrlainfiesta/), is a Technical Marketing Manager at [Roadie](https://roadie.io). Jorge has a background in software engineering (PayPal) and digital communication (UCLA). He’s been working around Backstage since it was open sourced by Spotify and co-hosts community initiatives like the Backstage Users Unconference. diff --git a/microsite/blog/assets/22-11-15/Introduction-to-Backstage-Developer-Portals-Made-Easy-2-768x432.png b/microsite/blog/assets/22-11-15/Introduction-to-Backstage-Developer-Portals-Made-Easy-2-768x432.png new file mode 100644 index 0000000000..a789762cc1 Binary files /dev/null and b/microsite/blog/assets/22-11-15/Introduction-to-Backstage-Developer-Portals-Made-Easy-2-768x432.png differ diff --git a/microsite/data/plugins/cloudsmith.yaml b/microsite/data/plugins/cloudsmith.yaml new file mode 100644 index 0000000000..5fc417eec1 --- /dev/null +++ b/microsite/data/plugins/cloudsmith.yaml @@ -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' diff --git a/microsite/data/plugins/humanitec.yaml b/microsite/data/plugins/humanitec.yaml index 5b3c459116..1b79270582 100644 --- a/microsite/data/plugins/humanitec.yaml +++ b/microsite/data/plugins/humanitec.yaml @@ -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' diff --git a/microsite/pages/en/live.js b/microsite/pages/en/live.js index 2accd6d11c..11945fcd3b 100644 --- a/microsite/pages/en/live.js +++ b/microsite/pages/en/live.js @@ -34,7 +34,7 @@ const Background = props => { diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 0a943bbe83..1fa6e492cb 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -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": +"resolve@patch:resolve@^1.1.6#~builtin, resolve@patch:resolve@^1.10.0#~builtin": version: 1.21.0 resolution: "resolve@patch:resolve@npm%3A1.21.0#~builtin::version=1.21.0&hash=07638b" dependencies: @@ -8003,16 +7945,6 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@^1.10.0#~builtin": - version: 1.19.0 - resolution: "resolve@patch:resolve@npm%3A1.19.0#~builtin::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 diff --git a/package.json b/package.json index 7b0fc5cddd..2f20eeeee3 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/app/cypress/integration/app.js b/packages/app/cypress/integration/app.js index 7b874907be..6371bcc4f8 100644 --- a/packages/app/cypress/integration/app.js +++ b/packages/app/cypress/integration/app.js @@ -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 }); diff --git a/packages/app/package.json b/packages/app/package.json index 1d4735441a..9978a0d515 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -80,7 +80,7 @@ "react-router": "^6.3.0", "react-router-dom": "^6.3.0", "react-use": "^17.2.4", - "zen-observable": "^0.8.15" + "zen-observable": "^0.9.0" }, "devDependencies": { "@backstage/test-utils": "workspace:^", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 2526f6740f..fd73e99bcb 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -80,8 +80,8 @@ import { TextSize, } from '@backstage/plugin-techdocs-module-addons-contrib'; import { + SettingsLayout, UserSettingsPage, - UserSettingsTab, } from '@backstage/plugin-user-settings'; import { AdvancedSettings } from './components/advancedSettings'; import AlarmIcon from '@material-ui/icons/Alarm'; @@ -267,9 +267,9 @@ const routes = ( element={} /> }> - + - + } /> } /> diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 41e5b15db9..8cec54326a 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -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<{}>) => ( }> {/* Global nav, not org-specific */} - + + + + + + + + + + + + + ServiceFactory; + // @public (undocumented) export const loggerFactory: (options?: undefined) => ServiceFactory; diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index 608399bad9..2c26097f48 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -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'; diff --git a/packages/backend-app-api/src/services/implementations/lifecycleService.test.ts b/packages/backend-app-api/src/services/implementations/lifecycleService.test.ts new file mode 100644 index 0000000000..c0b69a3bef --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/lifecycleService.test.ts @@ -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(); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/lifecycleService.ts b/packages/backend-app-api/src/services/implementations/lifecycleService.ts new file mode 100644 index 0000000000..7d41a7a276 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/lifecycleService.ts @@ -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 = + []; + + addShutdownHook( + options: BackendLifecycleShutdownHook & { pluginId: string }, + ): void { + this.#shutdownTasks.push(options); + } + + async shutdown(): Promise { + 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()); + }; + }, +}); diff --git a/packages/backend-common/src/database/connectors/mysql.ts b/packages/backend-common/src/database/connectors/mysql.ts index b75dc3d99c..4283ff42bd 100644 --- a/packages/backend-common/src/database/connectors/mysql.ts +++ b/packages/backend-common/src/database/connectors/mysql.ts @@ -115,7 +115,7 @@ export function parseMysqlConnectionString( password, host: hostname, port: Number(port || 3306), - database: decodeURIComponent(pathname.substr(1)), + database: decodeURIComponent(pathname.substring(1)), }; const ssl = searchParams.get('ssl'); diff --git a/packages/backend-common/src/reading/AwsS3UrlReader.test.ts b/packages/backend-common/src/reading/AwsS3UrlReader.test.ts index 454b8a2c5a..4a2be1fc74 100644 --- a/packages/backend-common/src/reading/AwsS3UrlReader.test.ts +++ b/packages/backend-common/src/reading/AwsS3UrlReader.test.ts @@ -31,7 +31,6 @@ import AWSMock from 'aws-sdk-mock'; import aws from 'aws-sdk'; import path from 'path'; import { NotModifiedError } from '@backstage/errors'; -import getRawBody from 'raw-body'; const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), @@ -247,15 +246,16 @@ describe('AwsS3UrlReader', () => { }); it('returns contents of an object in a bucket', async () => { - const response = await reader.read( + const { buffer } = await reader.readUrl( 'https://test-bucket.s3.us-east-2.amazonaws.com/awsS3-mock-object.yaml', ); + const response = await buffer(); expect(response.toString().trim()).toBe('site_name: Test'); }); it('rejects unknown targets', async () => { await expect( - reader.read( + reader.readUrl( 'https://test-bucket.s3.us-east-2.NOTamazonaws.com/file.yaml', ), ).rejects.toThrow( @@ -294,21 +294,21 @@ describe('AwsS3UrlReader', () => { }); it('returns contents of an object in a bucket via buffer', async () => { - const response = await reader.readUrl!( + const { buffer, etag } = await reader.readUrl( 'https://test-bucket.s3.us-east-2.amazonaws.com/awsS3-mock-object.yaml', ); - expect(response.etag).toBe('123abc'); - const buffer = await response.buffer(); - expect(buffer.toString().trim()).toBe('site_name: Test'); + expect(etag).toBe('123abc'); + const response = await buffer(); + expect(response.toString().trim()).toBe('site_name: Test'); }); it('returns contents of an object in a bucket via stream', async () => { - const response = await reader.readUrl!( + const { buffer, etag } = await reader.readUrl( 'https://test-bucket.s3.us-east-2.amazonaws.com/awsS3-mock-object.yaml', ); - expect(response.etag).toBe('123abc'); - const fromStream = await getRawBody(response.stream!()); - expect(fromStream.toString().trim()).toBe('site_name: Test'); + expect(etag).toBe('123abc'); + const response = await buffer(); + expect(response.toString().trim()).toBe('site_name: Test'); }); it('rejects unknown targets', async () => { @@ -354,12 +354,12 @@ describe('AwsS3UrlReader', () => { }); it('returns contents of an object in a bucket via buffer', async () => { - const response = await reader.readUrl!( + const { buffer, etag } = await reader.readUrl( 'http://localhost:4566/test-bucket/awsS3-mock-object.yaml', ); - expect(response.etag).toBe('123abc'); - const buffer = await response.buffer(); - expect(buffer.toString().trim()).toBe('site_name: Test'); + expect(etag).toBe('123abc'); + const response = await buffer(); + expect(response.toString().trim()).toBe('site_name: Test'); }); }); diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index bc088d8bc9..f969b3b7eb 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -116,8 +116,9 @@ describe('AzureUrlReader', () => { treeResponseFactory, }); - const data = await reader.read(url); - const res = await JSON.parse(data.toString('utf-8')); + const { buffer } = await reader.readUrl(url); + const fromStream = await buffer(); + const res = await JSON.parse(fromStream.toString()); expect(res).toEqual(response); }); @@ -145,7 +146,7 @@ describe('AzureUrlReader', () => { logger, treeResponseFactory, }); - await reader.read(url); + await reader.readUrl(url); }).rejects.toThrow(error); }); }); diff --git a/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts index 12ace2755b..ca6e15a005 100644 --- a/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketCloudUrlReader.test.ts @@ -161,7 +161,7 @@ describe('BitbucketCloudUrlReader', () => { describe('read', () => { it('rejects unknown targets', async () => { await expect( - reader.read('https://not.bitbucket.com/apa'), + reader.readUrl('https://not.bitbucket.com/apa'), ).rejects.toThrow( 'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket Cloud URL or file path', ); diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts index 75172e83b0..e45117a106 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.test.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -171,21 +171,22 @@ describe('FetchUrlReader', () => { describe('read', () => { it('should return etag from the response', async () => { - const buffer = await fetchUrlReader.read( + const { buffer } = await fetchUrlReader.readUrl( 'https://backstage.io/some-resource', ); - expect(buffer.toString()).toBe('content foo'); + const response = await buffer(); + expect(response.toString()).toBe('content foo'); }); it('should throw NotFound if server responds with 404', async () => { await expect( - fetchUrlReader.read('https://backstage.io/not-exists'), + fetchUrlReader.readUrl('https://backstage.io/not-exists'), ).rejects.toThrow(NotFoundError); }); it('should throw Error if server responds with 500', async () => { await expect( - fetchUrlReader.read('https://backstage.io/error'), + fetchUrlReader.readUrl('https://backstage.io/error'), ).rejects.toThrow(Error); }); }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index a5f9d26de5..2315d1f9e8 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -92,7 +92,7 @@ describe('GithubUrlReader', () => { describe('implementation', () => { it('rejects unknown targets', async () => { await expect( - githubProcessor.read('https://not.github.com/apa'), + githubProcessor.readUrl('https://not.github.com/apa'), ).rejects.toThrow( 'Incorrect URL: https://not.github.com/apa, Error: Invalid GitHub URL or file path', ); @@ -135,7 +135,7 @@ describe('GithubUrlReader', () => { ), ); - await gheProcessor.read( + await gheProcessor.readUrl( 'https://github.com/backstage/mock/tree/blob/main', ); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 29ef3f4ce8..012f8978ae 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -150,8 +150,9 @@ describe('GitlabUrlReader', () => { treeResponseFactory, }); - const data = await reader.read(url); - const res = await JSON.parse(data.toString('utf-8')); + const { buffer } = await reader.readUrl(url); + const fromStream = await buffer(); + const res = await JSON.parse(fromStream.toString()); expect(res).toEqual(response); }); @@ -169,7 +170,7 @@ describe('GitlabUrlReader', () => { logger, treeResponseFactory, }); - await reader.read(url); + await reader.readUrl(url); }).rejects.toThrow(error); }); }); diff --git a/packages/backend-common/src/reading/UrlReaderPredicateMux.test.ts b/packages/backend-common/src/reading/UrlReaderPredicateMux.test.ts index 5a11e068df..38473557e0 100644 --- a/packages/backend-common/src/reading/UrlReaderPredicateMux.test.ts +++ b/packages/backend-common/src/reading/UrlReaderPredicateMux.test.ts @@ -41,8 +41,8 @@ describe('UrlReaderPredicateMux', () => { reader: barReader, }); - await mux.read('http://foo/1'); - expect(fooReader.read).toHaveBeenCalledWith('http://foo/1'); + await mux.readUrl('http://foo/1'); + expect(fooReader.readUrl).toHaveBeenCalledWith('http://foo/1', undefined); await mux.readUrl('http://foo/2'); expect(fooReader.readUrl).toHaveBeenCalledWith('http://foo/2', undefined); await mux.readTree('http://foo/3'); @@ -50,8 +50,8 @@ describe('UrlReaderPredicateMux', () => { await mux.search('http://foo/4'); expect(fooReader.search).toHaveBeenCalledWith('http://foo/4', undefined); - await mux.read('http://bar/1'); - expect(barReader.read).toHaveBeenCalledWith('http://bar/1'); + await mux.readUrl('http://bar/1'); + expect(barReader.readUrl).toHaveBeenCalledWith('http://bar/1', undefined); await mux.readUrl('http://bar/2'); expect(barReader.readUrl).toHaveBeenCalledWith('http://bar/2', undefined); await mux.readTree('http://bar/3'); diff --git a/packages/backend-common/src/reading/integration.test.ts b/packages/backend-common/src/reading/integration.test.ts deleted file mode 100644 index 951df29605..0000000000 --- a/packages/backend-common/src/reading/integration.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright 2020 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 { ConfigReader } from '@backstage/config'; -import { isError } from '@backstage/errors'; -import { getVoidLogger } from '../logging'; -import { UrlReaders } from './UrlReaders'; - -const reader = UrlReaders.default({ - logger: getVoidLogger(), - config: new ConfigReader({ - // The tokens in this config provide read only access to the backstage-verification repos - integrations: { - github: [ - { - host: 'github.com', - token: - process.env.INTEGRATION_TEST_GITHUB_TOKEN || - `${86}af${617}d9c3c8bf958b37a${630691452765}bb0b0a`, - }, - ], - gitlab: [ - { - host: 'gitlab.com', - token: - process.env.INTEGRATION_TEST_GITLAB_TOKEN || 'tveGtSHDBJM9ZRHZNRfm', - }, - ], - bitbucket: [ - { - host: 'bitbucket.org', - username: 'backstage-verification', - appPassword: - process.env.INTEGRATION_TEST_BITBUCKET_TOKEN || - 'H79MAAhtbZwCafkVTrrQ', - }, - ], - azure: [ - { - host: 'dev.azure.com', - // lasts until 2022-01-28 - token: - process.env.INTEGRATION_TEST_AZURE_TOKEN || - `myvyavvfojh6wvw4ose4bfywqttqx${5}z${5}zs${5}bdxauqaek3yinkazq`, - }, - ], - }, - }), -}); - -function withRetries(count: number, fn: () => Promise) { - return async () => { - let error; - for (let i = 0; i < count; i++) { - try { - await fn(); - return; - } catch (err) { - error = err; - } - } - if ( - isError(error) && - !error.message.match(/rate limit|Too Many Requests/) - ) { - throw error; - } else { - console.warn('Request was rate limited', error); - } - }; -} -/* eslint-disable jest/no-disabled-tests */ -describe.skip('UrlReaders', () => { - jest.setTimeout(30_000); - - it( - 'should read data from azure', - withRetries(3, async () => { - const data = await reader.read( - 'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2Ftemplate.yaml', - ); - expect(data.toString()).toContain('test-template-azure'); - - const res = await reader.readTree( - 'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2F{{cookiecutter.name}}', - ); - const files = await res.files(); - expect(files).toEqual([ - { - path: 'catalog-info.yaml', - content: expect.any(Function), - }, - ]); - }), - ); - - it( - 'should read data from gitlab', - withRetries(3, async () => { - const data = await reader.read( - 'https://gitlab.com/backstage-verification/test-templates/-/blob/master/template.yaml', - ); - expect(data.toString()).toContain('test-template-gitlab'); - - const res = await reader.readTree( - 'https://gitlab.com/backstage-verification/test-templates/-/tree/master/{{cookiecutter.name}}', - ); - const files = await res.files(); - expect(files).toEqual([ - { - path: 'catalog-info.yaml', - content: expect.any(Function), - }, - ]); - }), - ); - - it( - 'should read data from bitbucket', - withRetries(3, async () => { - const data = await reader.read( - 'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml', - ); - expect(data.toString()).toContain('test-template-bitbucket'); - - const res = await reader.readTree( - 'https://bitbucket.org/backstage-verification/test-template/src/master/{{cookiecutter.name}}', - ); - const files = await res.files(); - expect(files).toEqual([ - { - path: 'catalog-info.yaml', - content: expect.any(Function), - }, - ]); - }), - ); - - it( - 'should read data from github', - withRetries(3, async () => { - const data = await reader.read( - 'https://github.com/backstage-verification/test-templates/blob/master/template.yaml', - ); - expect(data.toString()).toContain('test-template-github'); - - const res = await reader.readTree( - 'https://github.com/backstage-verification/test-templates/tree/master/{{cookiecutter.name}}', - ); - const files = await res.files(); - expect(files).toEqual([ - { - path: 'catalog-info.yaml', - content: expect.any(Function), - }, - ]); - }), - ); -}); diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index b95bd6f35b..9fde31e3bd 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -22,6 +22,7 @@ import { databaseFactory, discoveryFactory, httpRouterFactory, + lifecycleFactory, loggerFactory, permissionsFactory, rootLoggerFactory, @@ -43,6 +44,7 @@ export const defaultServiceFactories = [ tokenManagerFactory, urlReaderFactory, httpRouterFactory, + lifecycleFactory, ]; /** diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 70ab168053..b73a85c32e 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -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; +}; + // @public (undocumented) export interface BackendModuleConfig { // (undocumented) @@ -158,6 +168,9 @@ export interface HttpRouterService { // @public (undocumented) export const httpRouterServiceRef: ServiceRef; +// @public (undocumented) +export const lifecycleServiceRef: ServiceRef; + // @public (undocumented) export interface Logger { // (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index e5f032ef60..cf997a344d 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -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'; diff --git a/packages/backend-plugin-api/src/services/definitions/lifecycleServiceRef.ts b/packages/backend-plugin-api/src/services/definitions/lifecycleServiceRef.ts new file mode 100644 index 0000000000..15610e2643 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/lifecycleServiceRef.ts @@ -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; +}; + +/** + * @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({ + id: 'core.lifecycle', + scope: 'plugin', +}); diff --git a/packages/backend/src/plugins/events.ts b/packages/backend/src/plugins/events.ts index 2ada92a5b6..7427d34217 100644 --- a/packages/backend/src/plugins/events.ts +++ b/packages/backend/src/plugins/events.ts @@ -27,14 +27,12 @@ export default async function createPlugin( subscribers: EventSubscriber[], ): Promise { 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) diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 85fc389af7..2f76be79e2 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -34,6 +34,10 @@ export interface CatalogApi { request?: GetEntitiesRequest, options?: CatalogRequestOptions, ): Promise; + getEntitiesByRefs( + request: GetEntitiesByRefsRequest, + options?: CatalogRequestOptions, + ): Promise; getEntityAncestors( request: GetEntityAncestorsRequest, options?: CatalogRequestOptions, @@ -91,6 +95,10 @@ export class CatalogClient implements CatalogApi { request?: GetEntitiesRequest, options?: CatalogRequestOptions, ): Promise; + getEntitiesByRefs( + request: GetEntitiesByRefsRequest, + options?: CatalogRequestOptions, + ): Promise; 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[] + | Record; + +// @public +export interface GetEntitiesByRefsRequest { + entityRefs: string[]; + fields?: EntityFieldsQuery | undefined; +} + +// @public +export interface GetEntitiesByRefsResponse { + items: Array; +} + // @public export interface GetEntitiesRequest { after?: string; - fields?: string[] | undefined; - filter?: - | Record[] - | Record - | undefined; + fields?: EntityFieldsQuery; + filter?: EntityFilterQuery; limit?: number; offset?: number; } diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 5a344f49a3..1206d557c1 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -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', diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index d67392d80d..d15f382ddd 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -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 { + 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} */ diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index d3d02022f3..771adaaeee 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -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[] + | Record; + +/** + * 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[] - | Record - | 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; +} + /** * The request type for {@link CatalogClient.getEntityAncestors}. * @@ -296,6 +351,23 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise; + /** + * 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; + /** * Gets entity ancestor information, i.e. the hierarchy of parent entities * whose processing resulted in a given entity appearing in the catalog. diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 86f7b18ab0..5ac058e349 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -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'; diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts index 9a9ec8bcda..a887a9aa2e 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts @@ -224,7 +224,7 @@ describe('CommonValidatorFunctions', () => { ['abc xyz', true], ['abc xyz abc.', true], ])(`isValidString %p ? %p`, (value, result) => { - expect(CommonValidatorFunctions.isValidString(value)).toBe(result); + expect(CommonValidatorFunctions.isNonEmptyString(value)).toBe(result); }); it.each([ diff --git a/packages/cli/config/eslint-factory.js b/packages/cli/config/eslint-factory.js index 04e23b2077..e213f58e4e 100644 --- a/packages/cli/config/eslint-factory.js +++ b/packages/cli/config/eslint-factory.js @@ -239,17 +239,6 @@ function createConfigForRole(dir, role, extraConfig = {}) { name: '@material-ui/icons/', // because this is possible too ._. message: "Please import '@material-ui/icons/' instead.", }, - { - name: '@material-ui/core', - importNames: ['Link'], - message: - 'Prefer using `Link` from `@backstage/core-components` rather than material-UI', - }, - { - name: '@material-ui/core/Link', - message: - 'Prefer using `Link` from `@backstage/core-components` rather than material-UI', - }, ...require('module').builtinModules, ...(extraConfig.restrictedImports ?? []), ], diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs index ccbebcac40..ae626c0d57 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs @@ -4,6 +4,7 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { screen } from '@testing-library/react'; import { setupRequestMockHandlers, renderInTestApp, @@ -22,11 +23,11 @@ describe('ExampleComponent', () => { }); it('should render', async () => { - const rendered = await renderInTestApp( + await renderInTestApp( , ); - expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument(); + expect(screen.getByText('Welcome to {{ id }}!')).toBeInTheDocument(); }); }); diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs index 95533c899d..a553ecd0b0 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { ExampleFetchComponent } from './ExampleFetchComponent'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -19,7 +19,7 @@ describe('ExampleFetchComponent', () => { ); }); it('should render', async () => { - const rendered = render(); - expect(await rendered.findByTestId('progress')).toBeInTheDocument(); + await render(); + expect(await screen.findByTestId('progress')).toBeInTheDocument(); }); }); diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 95e76b4782..b5972bf308 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -39,7 +39,7 @@ "@types/prop-types": "^15.7.3", "prop-types": "^15.7.2", "react-use": "^17.2.4", - "zen-observable": "^0.8.15", + "zen-observable": "^0.9.0", "zod": "^3.11.6" }, "peerDependencies": { diff --git a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx index d1b45c39d1..410540f77e 100644 --- a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx +++ b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx @@ -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', () => { , ); - 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', () => { , ); - expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument(); + expect(queryByText('BACKSTAGE!')).not.toBeInTheDocument(); }); }); describe('without', () => { @@ -80,7 +81,7 @@ describe('FeatureFlagged', () => { , ); - 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', () => { , ); - expect(await queryByText('BACKSTAGE!')).toBeInTheDocument(); + expect(queryByText('BACKSTAGE!')).toBeInTheDocument(); }); }); }); diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index fc3ab4e5fc..248b9e227c 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -250,6 +250,7 @@ export interface DependencyGraphProps edgeRanks?: number; edges: DependencyEdge[]; 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; diff --git a/packages/core-components/package.json b/packages/core-components/package.json index b47473819d..93ccbfb98a 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -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", @@ -67,7 +67,7 @@ "react-virtualized-auto-sizer": "^1.0.6", "react-window": "^1.8.6", "remark-gfm": "^3.0.1", - "zen-observable": "^0.8.15", + "zen-observable": "^0.9.0", "zod": "^3.11.6" }, "peerDependencies": { diff --git a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx index 5159f67d9e..94d152ce1e 100644 --- a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx +++ b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx @@ -170,6 +170,14 @@ export interface DependencyGraphProps * 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( defs, zoom = 'enabled', curve = 'curveMonotoneX', + fit = 'grow', ...svgProps } = props; const theme: BackstageTheme = useTheme(); @@ -223,6 +232,9 @@ export function DependencyGraph( 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( ref={containerRef} {...svgProps} width="100%" - height={maxHeight} + height={scalableHeight} viewBox={`0 0 ${maxWidth} ${maxHeight}`} > diff --git a/packages/core-components/src/components/FeatureDiscovery/FeatureCalloutCircular.test.tsx b/packages/core-components/src/components/FeatureDiscovery/FeatureCalloutCircular.test.tsx index 35ea30ffa6..f72e33ac4b 100644 --- a/packages/core-components/src/components/FeatureDiscovery/FeatureCalloutCircular.test.tsx +++ b/packages/core-components/src/components/FeatureDiscovery/FeatureCalloutCircular.test.tsx @@ -70,8 +70,8 @@ describe('', () => { 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('', () => { 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('', () => { 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( diff --git a/packages/core-components/src/components/Select/Select.test.tsx b/packages/core-components/src/components/Select/Select.test.tsx index 41e787a5b4..df29983222 100644 --- a/packages/core-components/src/components/Select/Select.test.tsx +++ b/packages/core-components/src/components/Select/Select.test.tsx @@ -41,7 +41,7 @@ describe('); expect(getByText('Default')).toBeInTheDocument(); - const input = await getByTestId('select'); + const input = getByTestId('select'); expect(input.textContent).toBe('All results'); // Simulate click on input diff --git a/packages/core-components/src/components/SupportButton/SupportButton.tsx b/packages/core-components/src/components/SupportButton/SupportButton.tsx index 01de79bdee..4e0bb866f5 100644 --- a/packages/core-components/src/components/SupportButton/SupportButton.tsx +++ b/packages/core-components/src/components/SupportButton/SupportButton.tsx @@ -68,7 +68,7 @@ const SupportListItem = ({ item }: { item: SupportItem }) => { ( + secondary={item.links?.reduce( (prev, link, idx) => [ ...prev, idx > 0 &&
, diff --git a/packages/core-components/src/components/WarningPanel/WarningPanel.test.tsx b/packages/core-components/src/components/WarningPanel/WarningPanel.test.tsx index 25cf642c28..5d00784fd1 100644 --- a/packages/core-components/src/components/WarningPanel/WarningPanel.test.tsx +++ b/packages/core-components/src/components/WarningPanel/WarningPanel.test.tsx @@ -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('', () => { it('renders title', async () => { await renderInTestApp(); - 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(); diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 099b259ba7..ae9914fc77 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -38,7 +38,7 @@ "@backstage/version-bridge": "workspace:^", "history": "^5.0.0", "prop-types": "^15.7.2", - "zen-observable": "^0.8.15" + "zen-observable": "^0.9.0" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 7f567e61a9..08c2a2c305 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -47,7 +47,7 @@ "@testing-library/react": "^12.1.3", "@testing-library/user-event": "^14.0.0", "react-use": "^17.2.4", - "zen-observable": "^0.8.15" + "zen-observable": "^0.9.0" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index 49a121aff3..a1eb7aceb9 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -189,6 +189,14 @@ export interface Config { * @visibility secret */ clientSecret: string; + /** + * List of installation owners allowed to be used by this GitHub app. The GitHub UI does not provide a way to list the installations. + * However you can list the installations with the GitHub API. You can find the list of installations here: + * https://api.github.com/app/installations + * The relevant documentation for this is here. + * https://docs.github.com/en/rest/reference/apps#list-installations-for-the-authenticated-app--code-samples + */ + allowedInstallationOwners?: string[]; }>; }>; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 757eddd449..fb8dff4192 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -45,7 +45,7 @@ "@testing-library/react": "^12.1.3", "@testing-library/user-event": "^14.0.0", "cross-fetch": "^3.1.5", - "zen-observable": "^0.8.15" + "zen-observable": "^0.9.0" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/packages/types/package.json b/packages/types/package.json index 2cc3b0e5b8..eaf2723155 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -35,7 +35,7 @@ "@backstage/cli": "workspace:^", "@types/zen-observable": "^0.8.0", "luxon": "^3.0.0", - "zen-observable": "^0.8.15" + "zen-observable": "^0.9.0" }, "files": [ "dist" diff --git a/plugins/bitrise/src/components/BitriseBuildDetailsDialog/BitriseBuildDetailsDialog.test.tsx b/plugins/bitrise/src/components/BitriseBuildDetailsDialog/BitriseBuildDetailsDialog.test.tsx index 2dcffc3bb3..7992e08f37 100644 --- a/plugins/bitrise/src/components/BitriseBuildDetailsDialog/BitriseBuildDetailsDialog.test.tsx +++ b/plugins/bitrise/src/components/BitriseBuildDetailsDialog/BitriseBuildDetailsDialog.test.tsx @@ -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(); diff --git a/plugins/catalog-backend-module-github/src/index.ts b/plugins/catalog-backend-module-github/src/index.ts index 5e2e55fc24..b005780758 100644 --- a/plugins/catalog-backend-module-github/src/index.ts +++ b/plugins/catalog-backend-module-github/src/index.ts @@ -26,10 +26,8 @@ export { GithubDiscoveryProcessor } from './processors/GithubDiscoveryProcessor' export { GithubMultiOrgReaderProcessor } from './processors/GithubMultiOrgReaderProcessor'; export { GithubOrgReaderProcessor } from './processors/GithubOrgReaderProcessor'; export { GithubEntityProvider } from './providers/GithubEntityProvider'; -export type { - GithubOrgEntityProvider, - GithubOrgEntityProviderOptions, -} from './providers/GithubOrgEntityProvider'; +export { GithubOrgEntityProvider } from './providers/GithubOrgEntityProvider'; +export type { GithubOrgEntityProviderOptions } from './providers/GithubOrgEntityProvider'; export { githubEntityProviderCatalogModule } from './service/GithubEntityProviderCatalogModule'; export { type GithubMultiOrgConfig, diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 10b9afc291..e5d464c29a 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -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; } @@ -100,7 +100,7 @@ export class AnnotateScmSlugEntityProcessor implements CatalogProcessor { // (undocumented) getProcessorName(): string; // (undocumented) - preProcessEntity(entity: Entity, location: LocationSpec): Promise; + preProcessEntity(entity: Entity, location: LocationSpec_2): Promise; } // @public (undocumented) @@ -110,7 +110,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { // (undocumented) postProcessEntity( entity: Entity, - _location: LocationSpec, + _location: LocationSpec_2, emit: CatalogProcessorEmit, ): Promise; // (undocumented) @@ -284,7 +284,7 @@ export class CodeOwnersProcessor implements CatalogProcessor { // (undocumented) getProcessorName(): string; // (undocumented) - preProcessEntity(entity: Entity, location: LocationSpec): Promise; + preProcessEntity(entity: Entity, location: LocationSpec_2): Promise; } // @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; } @@ -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; // @alpha @@ -518,7 +519,7 @@ export class PlaceholderProcessor implements CatalogProcessor { // (undocumented) preProcessEntity( entity: Entity, - location: LocationSpec, + location: LocationSpec_2, emit: CatalogProcessorEmit, ): Promise; } @@ -574,7 +575,7 @@ export class UrlReaderProcessor implements CatalogProcessor { getProcessorName(): string; // (undocumented) readLocation( - location: LocationSpec, + location: LocationSpec_2, optional: boolean, emit: CatalogProcessorEmit, parser: CatalogProcessorParser, diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 34060814aa..84673a3900 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -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; +} + export type EntityAncestryResponse = { rootEntityRef: string; items: Array<{ @@ -130,6 +160,11 @@ export interface EntitiesCatalog { */ entities(request?: EntitiesRequest): Promise; + /** + * Fetches a batch of entities. + */ + entitiesBatch(request: EntitiesBatchRequest): Promise; + /** * Removes a single entity. * diff --git a/plugins/catalog-backend/src/index.ts b/plugins/catalog-backend/src/index.ts index 88696498d0..000dd94e8b 100644 --- a/plugins/catalog-backend/src/index.ts +++ b/plugins/catalog-backend/src/index.ts @@ -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; diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.ts index ce226bd09d..548c71d833 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.ts @@ -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. diff --git a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts index 63f3d406f5..186b732271 100644 --- a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts @@ -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']; diff --git a/plugins/catalog-backend/src/modules/core/AnnotateLocationEntityProcessor.ts b/plugins/catalog-backend/src/modules/core/AnnotateLocationEntityProcessor.ts index a86525429e..d3329fa86c 100644 --- a/plugins/catalog-backend/src/modules/core/AnnotateLocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/modules/core/AnnotateLocationEntityProcessor.ts @@ -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 */ diff --git a/plugins/catalog-backend/src/modules/core/AnnotateScmSlugEntityProcessor.ts b/plugins/catalog-backend/src/modules/core/AnnotateScmSlugEntityProcessor.ts index 41e75e5e8e..2c18ba1d7d 100644 --- a/plugins/catalog-backend/src/modules/core/AnnotateScmSlugEntityProcessor.ts +++ b/plugins/catalog-backend/src/modules/core/AnnotateScmSlugEntityProcessor.ts @@ -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'; diff --git a/plugins/catalog-backend/src/modules/core/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/modules/core/BuiltinKindsEntityProcessor.ts index 6e05daf591..4dbb23addc 100644 --- a/plugins/catalog-backend/src/modules/core/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/modules/core/BuiltinKindsEntityProcessor.ts @@ -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'; diff --git a/plugins/catalog-backend/src/modules/core/FileReaderProcessor.ts b/plugins/catalog-backend/src/modules/core/FileReaderProcessor.ts index af6fba52c0..e0cd11fa3b 100644 --- a/plugins/catalog-backend/src/modules/core/FileReaderProcessor.ts +++ b/plugins/catalog-backend/src/modules/core/FileReaderProcessor.ts @@ -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'; diff --git a/plugins/catalog-backend/src/modules/core/LocationEntityProcessor.ts b/plugins/catalog-backend/src/modules/core/LocationEntityProcessor.ts index 3676b4f727..1db74b3b56 100644 --- a/plugins/catalog-backend/src/modules/core/LocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/modules/core/LocationEntityProcessor.ts @@ -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( diff --git a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.ts b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.ts index e6b5ee279d..ec432c5fdc 100644 --- a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.ts +++ b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.ts @@ -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'; diff --git a/plugins/catalog-backend/src/modules/core/UrlReaderProcessor.ts b/plugins/catalog-backend/src/modules/core/UrlReaderProcessor.ts index fe9f21ebb4..5e31dcbd1a 100644 --- a/plugins/catalog-backend/src/modules/core/UrlReaderProcessor.ts +++ b/plugins/catalog-backend/src/modules/core/UrlReaderProcessor.ts @@ -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'; diff --git a/plugins/catalog-backend/src/modules/util/parse.ts b/plugins/catalog-backend/src/modules/util/parse.ts index d3aa915e49..778c176323 100644 --- a/plugins/catalog-backend/src/modules/util/parse.ts +++ b/plugins/catalog-backend/src/modules/util/parse.ts @@ -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'; diff --git a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingOrchestrator.ts b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingOrchestrator.ts index 79876f70d8..e95fede8b7 100644 --- a/plugins/catalog-backend/src/processing/DefaultCatalogProcessingOrchestrator.ts +++ b/plugins/catalog-backend/src/processing/DefaultCatalogProcessingOrchestrator.ts @@ -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 { diff --git a/plugins/catalog-backend/src/processing/util.ts b/plugins/catalog-backend/src/processing/util.ts index bd96763a54..cd93be3c8b 100644 --- a/plugins/catalog-backend/src/processing/util.ts +++ b/plugins/catalog-backend/src/processing/util.ts @@ -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'; diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 66764dc89f..cbaf876071 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -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({ diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index 0e861a39e1..c62e9f9a84 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -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 { + 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 }, diff --git a/plugins/catalog-backend/src/service/CatalogPlugin.ts b/plugins/catalog-backend/src/service/CatalogPlugin.ts index 64e1e52494..84cad86d9a 100644 --- a/plugins/catalog-backend/src/service/CatalogPlugin.ts +++ b/plugins/catalog-backend/src/service/CatalogPlugin.ts @@ -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); }, }); diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 49d4fe9e19..515a862515 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -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', diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index dc84b12484..81de6484c3 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -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 { + const lookup = new Map(); + + for (const chunk of lodash.chunk(request.entityRefs, 200)) { + let query = this.database('final_entities') + .innerJoin('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 { // Clear the hashed state of the immediate parents of the deleted entity. // This makes sure that when they get reprocessed, their output is written diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index f383df7364..541359feb6 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -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(), diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 5267519c33..4680dbb899 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -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), diff --git a/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts new file mode 100644 index 0000000000..b3a91f9491 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/entitiesBatchRequest.ts @@ -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}`, + ); + } +} diff --git a/plugins/catalog-backend/src/service/request/index.ts b/plugins/catalog-backend/src/service/request/index.ts index 4ab579fbb6..0b09355e72 100644 --- a/plugins/catalog-backend/src/service/request/index.ts +++ b/plugins/catalog-backend/src/service/request/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export { entitiesBatchRequest } from './entitiesBatchRequest'; export { basicEntityFilter } from './basicEntityFilter'; export { parseEntityFilterParams } from './parseEntityFilterParams'; export { parseEntityPaginationParams } from './parseEntityPaginationParams'; diff --git a/plugins/catalog-backend/src/util/conversion.ts b/plugins/catalog-backend/src/util/conversion.ts index 83302722d2..0b92b88d90 100644 --- a/plugins/catalog-backend/src/util/conversion.ts +++ b/plugins/catalog-backend/src/util/conversion.ts @@ -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') diff --git a/plugins/catalog-graph/.eslintrc.js b/plugins/catalog-graph/.eslintrc.js index e2a53a6ad2..45bb6db521 100644 --- a/plugins/catalog-graph/.eslintrc.js +++ b/plugins/catalog-graph/.eslintrc.js @@ -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', + }, +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index c5940be395..27b67102e4 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -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('', () => { 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('', () => { relations: [], })); - const { findByText } = await renderInTestApp( + await renderInTestApp( @@ -111,7 +112,7 @@ describe('', () => { }, ); - 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('', () => { 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('', () => { }); test('renders link to standalone viewer with custom config', async () => { - const { findByText, getByText } = await renderInTestApp( + await renderInTestApp( @@ -151,8 +152,8 @@ describe('', () => { }, ); - 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('', () => { })); const analyticsSpy = new MockAnalyticsApi(); - const { findByText } = await renderInTestApp( + await renderInTestApp( {wrapper} , @@ -179,8 +180,8 @@ describe('', () => { }, ); - 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', diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx index 9a5d1f833e..f7e1a9fbe7 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { ); const analyticsSpy = new MockAnalyticsApi(); - const { getByText, findAllByTestId } = await renderInTestApp( + await renderInTestApp( {wrapper} , @@ -199,12 +194,12 @@ describe('', () => { }, ); - 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('', () => { ); const analyticsSpy = new MockAnalyticsApi(); - const { getByText, findAllByTestId } = await renderInTestApp( + await renderInTestApp( {wrapper} , @@ -229,11 +224,11 @@ describe('', () => { }, ); - 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', diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.test.tsx index 0be0fca66b..85d9d97c7f 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CurveFilter.test.tsx @@ -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('', () => { test('should display current curve label', () => { const onChange = jest.fn(); - const { getByText } = render( - , - ); + render(); - 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( - , - ); + render(); - 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'); }); }); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx index 05c071f1f9..cb660b5a25 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx @@ -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('', () => { test('should display current value', () => { - const { getByText } = render( + render( {}} />, ); - 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( , ); - 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); }); }); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.test.tsx index 3f3a7f82a6..89f3169981 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.test.tsx @@ -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('', () => { test('should display current value', () => { - const { getByLabelText } = render( - {}} />, - ); + render( {}} />); - 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( {}} />, ); - 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( - , - ); + render(); - 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( - , - ); + render(); - 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( - , + let value = 5; + render( + { + 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); }); }); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.tsx index b6b03d8831..e6fb2fbab4 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/MaxDepthFilter.tsx @@ -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) => { - 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 ( @@ -62,7 +81,7 @@ export const MaxDepthFilter = ({ value, onChange }: Props) => { diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx index 580c118b27..511592af0c 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx @@ -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('', () => { it('should not explode while loading', async () => { - const rendered = await renderWithEffects( + const { baseElement } = await renderWithEffects( {}} /> , ); - expect(rendered.baseElement).toBeInTheDocument(); + expect(baseElement).toBeInTheDocument(); }); it('should render current value', async () => { - const rendered = await renderWithEffects( + await renderWithEffects( {}} /> , ); - 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( , ); - 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('', () => { it('should return undefined if all values are selected', async () => { const onChange = jest.fn(); - const { getByLabelText, getByText } = await renderWithEffects( + await renderWithEffects( ', () => { /> , ); - 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('', () => { it('should return all values when cleared', async () => { const onChange = jest.fn(); - const { getByRole } = await renderWithEffects( + await renderWithEffects( , ); - await userEvent.click(getByRole('combobox')); + await userEvent.click(screen.getByRole('combobox')); await userEvent.tab(); await waitFor(() => { diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx index bcc67edc3d..8baa7865b9 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx @@ -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('', () => { test('should render current value', () => { - const { getByText } = render( + render( ', () => { />, ); - 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( ', () => { />, ); - 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('', () => { test('should return undefined if all values are selected', async () => { const onChange = jest.fn(); - const { getByText, getByLabelText } = render( + render( p).filter( @@ -77,13 +78,13 @@ describe('', () => { />, ); - 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('', () => { test('should return all values when cleared', async () => { const onChange = jest.fn(); - const { getByRole } = render( + render( ', () => { />, ); - await userEvent.click(getByRole('combobox')); + await userEvent.click(screen.getByRole('combobox')); await userEvent.tab(); await waitFor(() => { diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SwitchFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SwitchFilter.test.tsx index 08c7304989..322232d774 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SwitchFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SwitchFilter.test.tsx @@ -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('', () => { test('should render value', () => { - const { getByLabelText } = render( - {}} />, - ); + render( {}} />); - 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( - , - ); + render(); - 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); }); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/CustomLabel.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/CustomLabel.test.tsx index 5b8f50b983..ab280aea81 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/CustomLabel.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/CustomLabel.test.tsx @@ -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('', () => { test('renders label', () => { - const { getByText } = render( + render( ', () => { , ); - expect(getByText(RELATION_PARENT_OF)).toBeInTheDocument(); + expect(screen.getByText(RELATION_PARENT_OF)).toBeInTheDocument(); }); test('renders label with multiple relations', () => { - const { getByText } = render( + render( ', () => { , ); - 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(); }); }); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/CustomNode.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/CustomNode.test.tsx index ff9fc9377e..7a34bbf688 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/CustomNode.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/CustomNode.test.tsx @@ -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('', () => { test('renders node', async () => { - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { , ); - 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( ', () => { , ); - 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( ', () => { , ); - 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( ', () => { , ); - expect(getByText('Custom Title')).toBeInTheDocument(); + expect(screen.getByText('Custom Title')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityKindIcon.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityKindIcon.test.tsx index 5d5f9dbf3c..6592c6195e 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityKindIcon.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityKindIcon.test.tsx @@ -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'; diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx index d7b9f9e3eb..8a3352abb4 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx @@ -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('', () => { relations: [], }); - const { findByText, findAllByTestId } = await renderInTestApp( + await renderInTestApp( ', () => { , ); - 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( ', () => { , ); - expect(await findByRole('progressbar')).toBeInTheDocument(); + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1); }); @@ -197,7 +198,7 @@ describe('', () => { return undefined; }); - const { findByText, findAllByTestId } = await renderInTestApp( + await renderInTestApp( ', () => { , ); - 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( - - - , - ); + await renderInTestApp( + + + , + ); - 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('', () => { test('renders simplified graph at full depth', async () => { catalog.getEntityByRef.mockImplementation(async n => entities[n as string]); - const { findByText, findAllByText, findAllByTestId } = - await renderInTestApp( - - - , - ); + await renderInTestApp( + + + , + ); - 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('', () => { test('renders full graph at full depth', async () => { catalog.getEntityByRef.mockImplementation(async n => entities[n as string]); - const { findAllByText, findByText, findAllByTestId } = - await renderInTestApp( - - - , - ); + await renderInTestApp( + + + , + ); - 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('', () => { 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( - - - , - ); + await renderInTestApp( + + + , + ); - 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('', () => { test('renders a graph with multiple root nodes', async () => { catalog.getEntityByRef.mockImplementation(async n => entities[n as string]); - const { findAllByText, findByText, findAllByTestId } = - await renderInTestApp( - - - , - ); + await renderInTestApp( + + + , + ); - 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('', () => { 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( - - - , - ); + await renderInTestApp( + + + , + ); - 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('', () => { catalog.getEntityByRef.mockImplementation(async n => entities[n as string]); const onNodeClick = jest.fn(); - const { findByText } = await renderInTestApp( + await renderInTestApp( ', () => { , ); - 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('', () => { ); - const { findAllByTestId, container } = await renderInTestApp( + const { container } = await renderInTestApp( ', () => { , ); - 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('', () => { ); - const { findAllByTestId, findAllByText, container } = await renderInTestApp( + const { container } = await renderInTestApp( ', () => { /> , ); - 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(); }); }); diff --git a/plugins/catalog-import/.eslintrc.js b/plugins/catalog-import/.eslintrc.js index e2a53a6ad2..45bb6db521 100644 --- a/plugins/catalog-import/.eslintrc.js +++ b/plugins/catalog-import/.eslintrc.js @@ -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', + }, +}); diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx index d6132b249e..a231563484 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx @@ -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('', () => { }); it('renders without exploding', async () => { - const { getByText } = await renderInTestApp( + await renderInTestApp( , ); expect( - getByText('Start tracking your component in Backstage'), + screen.getByText('Start tracking your component in Backstage'), ).toBeInTheDocument(); }); }); diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx index 9a27532c57..c33de77ffd 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx @@ -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('', () => { }); it('renders without exploding', async () => { - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { , ); - 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( , ); - 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( , ); expect( - getByText(/the wizard will prepare a Pull Request/), + screen.getByText(/the wizard will prepare a Pull Request/), ).toBeInTheDocument(); }); }); diff --git a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx index 498e786eb3..a38b801607 100644 --- a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx @@ -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('', () => { afterEach(() => jest.resetAllMocks()); it('renders without exploding', async () => { - const { getByText } = await renderInTestApp( + await renderInTestApp( , ); 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(
Hello World
); - const { getByText } = await renderInTestApp( + await renderInTestApp( , ); - expect(getByText('Hello World')).toBeInTheDocument(); + expect(screen.getByText('Hello World')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx index 5a8110eb6c..a9d662cab0 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx @@ -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('', () => { }); it('renders without exploding', async () => { - const { getByRole } = render( - undefined} />, - { - wrapper: Wrapper, - }, - ); + render( 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( undefined} analysisUrl="https://default" @@ -82,8 +83,10 @@ describe('', () => { }, ); - 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('', () => { it('should not analyze without url', async () => { const onAnalysisFn = jest.fn(); - const { getByRole } = render( - , - { - wrapper: Wrapper, - }, - ); + render(, { + 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('', () => { it('should not analyze invalid value', async () => { const onAnalysisFn = jest.fn(); - const { getByRole, getByText } = render( - , - { - wrapper: Wrapper, - }, - ); + render(, { + 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('', () => { locations: [location], } as AnalyzeResult; - const { getByRole } = render( - , - { - wrapper: Wrapper, - }, - ); + render(, { + wrapper: Wrapper, + }); catalogImportApi.analyzeUrl.mockReturnValueOnce( Promise.resolve(analyzeResult), @@ -158,10 +152,10 @@ describe('', () => { 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('', () => { locations: [location, location], } as AnalyzeResult; - const { getByRole } = render( - , - { - wrapper: Wrapper, - }, - ); + render(, { + wrapper: Wrapper, + }); catalogImportApi.analyzeUrl.mockReturnValueOnce( Promise.resolve(analyzeResult), @@ -195,10 +186,10 @@ describe('', () => { 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('', () => { locations: [], } as AnalyzeResult; - const { getByRole, getByText } = render( - , - { - wrapper: Wrapper, - }, - ); + render(, { + wrapper: Wrapper, + }); catalogImportApi.analyzeUrl.mockReturnValueOnce( Promise.resolve(analyzeResult), @@ -231,15 +219,15 @@ describe('', () => { 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('', () => { ], } as AnalyzeResult; - const { getByRole } = render( - , - { - wrapper: Wrapper, - }, - ); + render(, { + wrapper: Wrapper, + }); catalogImportApi.analyzeUrl.mockReturnValueOnce( Promise.resolve(analyzeResult), @@ -275,10 +260,10 @@ describe('', () => { 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('', () => { generatedEntities: [], } as AnalyzeResult; - const { getByRole, getByText } = render( - , - { - wrapper: Wrapper, - }, - ); + render(, { + wrapper: Wrapper, + }); catalogImportApi.analyzeUrl.mockReturnValueOnce( Promise.resolve(analyzeResult), @@ -313,15 +295,15 @@ describe('', () => { 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('', () => { ], } as AnalyzeResult; - const { getByRole, getByText } = render( + render( , { wrapper: Wrapper, @@ -357,15 +339,15 @@ describe('', () => { 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('', () => { it('should report unknown type to the errorapi', async () => { const onAnalysisFn = jest.fn(); - const { getByRole, getByText } = render( - , - { - wrapper: Wrapper, - }, - ); + render(, { + wrapper: Wrapper, + }); catalogImportApi.analyzeUrl.mockReturnValueOnce( Promise.resolve({ type: 'unknown' } as any as AnalyzeResult), @@ -386,15 +365,15 @@ describe('', () => { 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(); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx index 3acb7390a9..02b67090fa 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx @@ -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('', () => { it('renders without exploding', async () => { const onSubmitFn = jest.fn(); - const { getByRole } = render( + render( defaultValues={{ main: 'default' }} render={({ register }) => ( @@ -39,7 +39,7 @@ describe('', () => { ); 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('', () => { it('should register a text field', async () => { const onSubmitFn = jest.fn(); - const { getByRole, getByLabelText } = render( + render( defaultValues={{ main: 'default' }} render={({ register }) => ( @@ -67,9 +67,9 @@ describe('', () => { ); 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('', () => { it('registers required attribute', async () => { const onSubmitFn = jest.fn(); - const { queryByText, getByRole } = render( + render( defaultValues={{}} render={({ formState, register }) => ( @@ -100,13 +100,17 @@ describe('', () => { />, ); - 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(); }); }); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx index 8d0d085b8a..d983b6d359 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx @@ -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('', () => { it('renders without exploding', async () => { - const { getByText } = render( + render( , ); - 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('', () => { it('renders card with custom styles', async () => { const { result } = renderHook(() => useStyles()); - const { getByText } = render( + render( ', () => { />, ); - 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('', () => { it('renders with custom styles', async () => { const { result } = renderHook(() => useStyles()); - const { getByText } = render( + render( ', () => { />, ); - 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(); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index ae6225f912..36f1217c37 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -102,7 +102,7 @@ describe('', () => { catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] })); await act(async () => { - const { findByText } = render( + render( ', () => { }, ); - const title = await findByText('My title'); - const description = await findByText('body', { selector: 'strong' }); + const title = await screen.findByText('My title'); + const description = await screen.findByText('body', { + selector: 'strong', + }); expect(title).toBeInTheDocument(); expect(title).toBeVisible(); expect(description).toBeInTheDocument(); diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx index 1817ece1b8..5a30c7735a 100644 --- a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx @@ -15,7 +15,7 @@ */ import { renderInTestApp } from '@backstage/test-utils'; -import { act } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { AnalyzeResult } from '../../api'; @@ -58,7 +58,7 @@ describe('', () => { }); it('renders display locations to be added', async () => { - const rendered = await renderInTestApp( + await renderInTestApp( undefined} @@ -66,15 +66,15 @@ describe('', () => { />, ); - expect(rendered.getByText('url-1')).toBeInTheDocument(); - expect(rendered.getByText('url-2')).toBeInTheDocument(); + expect(screen.getByText('url-1')).toBeInTheDocument(); + expect(screen.getByText('url-2')).toBeInTheDocument(); expect( - rendered.queryByText(/Select one or more locations/), + screen.queryByText(/Select one or more locations/), ).toBeInTheDocument(); expect( - rendered.queryByText(/locations already exist/), + screen.queryByText(/locations already exist/), ).not.toBeInTheDocument(); - expect(rendered.getByRole('button', { name: /Review/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Review/i })).toBeDisabled(); }); it('should display existing locations only', async () => { @@ -95,7 +95,7 @@ describe('', () => { ], } as Extract; - const rendered = await renderInTestApp( + await renderInTestApp( undefined} @@ -103,15 +103,15 @@ describe('', () => { />, ); - expect(rendered.getByText(/my-target/)).toBeInTheDocument(); - expect(rendered.queryByText(/locations already exist/)).toBeInTheDocument(); + expect(screen.getByText(/my-target/)).toBeInTheDocument(); + expect(screen.queryByText(/locations already exist/)).toBeInTheDocument(); expect( - rendered.queryByText(/Select one or more locations/), + screen.queryByText(/Select one or more locations/), ).not.toBeInTheDocument(); }); it('should select and deselect all', async () => { - const { getByRole, getAllByRole } = await renderInTestApp( + await renderInTestApp( undefined} @@ -119,27 +119,31 @@ describe('', () => { />, ); - const checkboxes = getAllByRole('checkbox'); + const checkboxes = screen.getAllByRole('checkbox'); checkboxes.forEach(c => expect(c).not.toBeChecked()); - expect(getByRole('button', { name: /Review/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Review/i })).toBeDisabled(); await act(async () => { - await userEvent.click(getByRole('button', { name: /Select All/i })); + await userEvent.click( + screen.getByRole('button', { name: /Select All/i }), + ); }); checkboxes.forEach(c => expect(c).toBeChecked()); - expect(getByRole('button', { name: /Review/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /Review/i })).not.toBeDisabled(); await act(async () => { - await userEvent.click(getByRole('button', { name: /Select All/i })); + await userEvent.click( + screen.getByRole('button', { name: /Select All/i }), + ); }); checkboxes.forEach(c => expect(c).not.toBeChecked()); - expect(getByRole('button', { name: /Review/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Review/i })).toBeDisabled(); }); it('should preselect prepared locations', async () => { - const { getAllByRole } = await renderInTestApp( + await renderInTestApp( ', () => { />, ); - const checkboxes = getAllByRole('checkbox'); + const checkboxes = screen.getAllByRole('checkbox'); expect(checkboxes[0]).not.toBeChecked(); expect(checkboxes[1]).toBeChecked(); @@ -159,7 +163,7 @@ describe('', () => { }); it('should select items', async () => { - const { getAllByRole } = await renderInTestApp( + await renderInTestApp( undefined} @@ -167,7 +171,7 @@ describe('', () => { />, ); - const checkboxes = getAllByRole('checkbox'); + const checkboxes = screen.getAllByRole('checkbox'); checkboxes.forEach(c => expect(c).not.toBeChecked()); await act(async () => { @@ -188,7 +192,7 @@ describe('', () => { it('should go back', async () => { const onGoBack = jest.fn(); - const { getByRole } = await renderInTestApp( + await renderInTestApp( undefined} @@ -197,7 +201,7 @@ describe('', () => { ); await act(async () => { - await userEvent.click(getByRole('button', { name: /Back/i })); + await userEvent.click(screen.getByRole('button', { name: /Back/i })); }); expect(onGoBack).toHaveBeenCalledTimes(1); @@ -206,7 +210,7 @@ describe('', () => { it('should submit', async () => { const onPrepare = jest.fn(); - const { getAllByRole, getByRole } = await renderInTestApp( + await renderInTestApp( ', () => { />, ); - const checkboxes = getAllByRole('checkbox'); + const checkboxes = screen.getAllByRole('checkbox'); await act(async () => { await userEvent.click(checkboxes[1]); }); await act(async () => { - await userEvent.click(getByRole('button', { name: /Review/i })); + await userEvent.click(screen.getByRole('button', { name: /Review/i })); }); expect(onPrepare).toHaveBeenCalledTimes(1); diff --git a/plugins/catalog-react/.eslintrc.js b/plugins/catalog-react/.eslintrc.js index e2a53a6ad2..45bb6db521 100644 --- a/plugins/catalog-react/.eslintrc.js +++ b/plugins/catalog-react/.eslintrc.js @@ -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', + }, +}); diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 9b8de0ce69..b4e40d8546 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -54,7 +54,7 @@ "qs": "^6.9.4", "react-use": "^17.2.4", "yaml": "^2.0.0", - "zen-observable": "^0.8.15" + "zen-observable": "^0.9.0" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index 65ad3f227a..8a6b7206c6 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -19,7 +19,7 @@ import { Entity } from '@backstage/catalog-model'; import { ApiProvider } from '@backstage/core-app-api'; import { alertApiRef } from '@backstage/core-plugin-api'; import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; -import { fireEvent, waitFor } from '@testing-library/react'; +import { fireEvent, waitFor, screen } from '@testing-library/react'; import { capitalize } from 'lodash'; import { default as React } from 'react'; import { catalogApiRef } from '../../api'; @@ -75,7 +75,7 @@ describe('', () => { ); it('renders available entity kinds', async () => { - const rendered = await renderWithEffects( + await renderWithEffects( ', () => { , ); - expect(rendered.getByText('Kind')).toBeInTheDocument(); + expect(screen.getByText('Kind')).toBeInTheDocument(); - const input = rendered.getByTestId('select'); + const input = screen.getByTestId('select'); fireEvent.click(input); - await waitFor(() => rendered.getByText('Domain')); + await waitFor(() => screen.getByText('Domain')); entities.forEach(entity => { expect( - rendered.getByRole('option', { + screen.getByRole('option', { name: capitalize(entity.kind as string), }), ).toBeInTheDocument(); @@ -102,7 +102,7 @@ describe('', () => { it('sets the selected kind filter', async () => { const updateFilters = jest.fn(); - const rendered = await renderWithEffects( + await renderWithEffects( ', () => { , ); - const input = rendered.getByTestId('select'); + const input = screen.getByTestId('select'); fireEvent.click(input); - await waitFor(() => rendered.getByText('Domain')); - fireEvent.click(rendered.getByText('Domain')); + await waitFor(() => screen.getByText('Domain')); + fireEvent.click(screen.getByText('Domain')); expect(updateFilters).toHaveBeenLastCalledWith({ kind: new EntityKindFilter('domain'), diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx index 47e85292e2..f925625b23 100644 --- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { MockEntityListContextProvider } from '../../testUtils/providers'; import { EntityLifecycleFilter } from '../../filters'; @@ -56,36 +56,36 @@ const sampleEntities: Entity[] = [ describe('', () => { it('renders all lifecycles', () => { - const rendered = render( + render( , ); - expect(rendered.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); - fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); + fireEvent.click(screen.getByTestId('lifecycle-picker-expand')); sampleEntities .map(e => e.spec?.lifecycle!) .forEach(lifecycle => { - expect(rendered.getByText(lifecycle as string)).toBeInTheDocument(); + expect(screen.getByText(lifecycle as string)).toBeInTheDocument(); }); }); it('renders unique lifecycles in alphabetical order', () => { - const rendered = render( + render( , ); - expect(rendered.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); - fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); + fireEvent.click(screen.getByTestId('lifecycle-picker-expand')); - expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([ + expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([ 'experimental', 'production', ]); @@ -114,7 +114,7 @@ describe('', () => { it('adds lifecycles to filters', () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { lifecycles: undefined, }); - fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); - fireEvent.click(rendered.getByText('production')); + fireEvent.click(screen.getByTestId('lifecycle-picker-expand')); + fireEvent.click(screen.getByText('production')); expect(updateFilters).toHaveBeenLastCalledWith({ lifecycles: new EntityLifecycleFilter(['production']), }); @@ -138,7 +138,7 @@ describe('', () => { it('removes lifecycles from filters', () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { expect(updateFilters).toHaveBeenLastCalledWith({ lifecycles: new EntityLifecycleFilter(['production']), }); - fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); - expect(rendered.getByLabelText('production')).toBeChecked(); + fireEvent.click(screen.getByTestId('lifecycle-picker-expand')); + expect(screen.getByLabelText('production')).toBeChecked(); - fireEvent.click(rendered.getByLabelText('production')); + fireEvent.click(screen.getByLabelText('production')); expect(updateFilters).toHaveBeenLastCalledWith({ lifecycles: undefined, }); diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx index f648b51456..c816810b10 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx @@ -15,7 +15,7 @@ */ import { Entity, parseEntityRef } from '@backstage/catalog-model'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { MockEntityListContextProvider } from '../../testUtils/providers'; import { EntityOwnerFilter } from '../../filters'; @@ -69,36 +69,36 @@ const sampleEntities: Entity[] = [ describe('', () => { it('renders all owners', () => { - const rendered = render( + render( , ); - expect(rendered.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner')).toBeInTheDocument(); - fireEvent.click(rendered.getByTestId('owner-picker-expand')); + fireEvent.click(screen.getByTestId('owner-picker-expand')); sampleEntities .flatMap(e => e.relations?.map(r => parseEntityRef(r.targetRef).name)) .forEach(owner => { - expect(rendered.getByText(owner as string)).toBeInTheDocument(); + expect(screen.getByText(owner as string)).toBeInTheDocument(); }); }); it('renders unique owners in alphabetical order', () => { - const rendered = render( + render( , ); - expect(rendered.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner')).toBeInTheDocument(); - fireEvent.click(rendered.getByTestId('owner-picker-expand')); + fireEvent.click(screen.getByTestId('owner-picker-expand')); - expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([ + expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([ 'another-owner', 'some-owner', 'some-owner-2', @@ -128,7 +128,7 @@ describe('', () => { it('adds owners to filters', () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { owners: undefined, }); - fireEvent.click(rendered.getByTestId('owner-picker-expand')); - fireEvent.click(rendered.getByText('some-owner')); + fireEvent.click(screen.getByTestId('owner-picker-expand')); + fireEvent.click(screen.getByText('some-owner')); expect(updateFilters).toHaveBeenLastCalledWith({ owners: new EntityOwnerFilter(['some-owner']), }); @@ -152,7 +152,7 @@ describe('', () => { it('removes owners from filters', () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { expect(updateFilters).toHaveBeenLastCalledWith({ owners: new EntityOwnerFilter(['some-owner']), }); - fireEvent.click(rendered.getByTestId('owner-picker-expand')); - expect(rendered.getByLabelText('some-owner')).toBeChecked(); + fireEvent.click(screen.getByTestId('owner-picker-expand')); + expect(screen.getByLabelText('some-owner')).toBeChecked(); - fireEvent.click(rendered.getByLabelText('some-owner')); + fireEvent.click(screen.getByLabelText('some-owner')); expect(updateFilters).toHaveBeenLastCalledWith({ owner: undefined, }); diff --git a/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.test.tsx b/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.test.tsx index 8e90e04148..00d36e5e13 100644 --- a/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.test.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { EntityErrorFilter, EntityOrphanFilter } from '../../filters'; import { MockEntityListContextProvider } from '../../testUtils/providers'; @@ -52,23 +52,23 @@ const sampleEntities: Entity[] = [ describe('', () => { it('renders all processing status options', () => { - const rendered = render( + render( , ); - expect(rendered.getByText('Processing Status')).toBeInTheDocument(); + expect(screen.getByText('Processing Status')).toBeInTheDocument(); - fireEvent.click(rendered.getByTestId('processing-status-picker-expand')); - expect(rendered.getByText('Is Orphan')).toBeInTheDocument(); - expect(rendered.getByText('Has Error')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('processing-status-picker-expand')); + expect(screen.getByText('Is Orphan')).toBeInTheDocument(); + expect(screen.getByText('Has Error')).toBeInTheDocument(); }); it('adds orphan to orphan filter', () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { , ); - fireEvent.click(rendered.getByTestId('processing-status-picker-expand')); - fireEvent.click(rendered.getByText('Is Orphan')); + fireEvent.click(screen.getByTestId('processing-status-picker-expand')); + fireEvent.click(screen.getByText('Is Orphan')); expect(updateFilters).toHaveBeenCalledWith({ orphan: new EntityOrphanFilter(true), }); @@ -89,7 +89,7 @@ describe('', () => { it('adds error to error filter', () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { , ); - fireEvent.click(rendered.getByTestId('processing-status-picker-expand')); - fireEvent.click(rendered.getByText('Has Error')); + fireEvent.click(screen.getByTestId('processing-status-picker-expand')); + fireEvent.click(screen.getByText('Has Error')); expect(updateFilters).toHaveBeenCalledWith({ error: new EntityErrorFilter(true), }); @@ -110,7 +110,7 @@ describe('', () => { it('remove orphan from orphan filter', () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { , ); - fireEvent.click(rendered.getByTestId('processing-status-picker-expand')); - fireEvent.click(rendered.getByText('Is Orphan')); + fireEvent.click(screen.getByTestId('processing-status-picker-expand')); + fireEvent.click(screen.getByText('Is Orphan')); expect(updateFilters).toHaveBeenCalledWith({ orphan: undefined, }); @@ -131,7 +131,7 @@ describe('', () => { it('remove error from error filter', () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { , ); - fireEvent.click(rendered.getByTestId('processing-status-picker-expand')); - fireEvent.click(rendered.getByText('Has Error')); + fireEvent.click(screen.getByTestId('processing-status-picker-expand')); + fireEvent.click(screen.getByText('Has Error')); expect(updateFilters).toHaveBeenCalledWith({ error: undefined, }); diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx index eaaaab39bc..b0a6b46bf5 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx @@ -15,6 +15,7 @@ */ import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import React from 'react'; import { entityRouteRef } from '../../routes'; import { EntityRefLink } from './EntityRefLink'; @@ -33,16 +34,13 @@ describe('', () => { lifecycle: 'production', }, }; - const { getByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name/*': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, - ); + }); - expect(getByText('component:software')).toHaveAttribute( + expect(screen.getByText('component:software')).toHaveAttribute( 'href', '/catalog/default/component/software', ); @@ -62,15 +60,12 @@ describe('', () => { lifecycle: 'production', }, }; - const { getByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name/*': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, - ); - expect(getByText('component:test/software')).toHaveAttribute( + }); + expect(screen.getByText('component:test/software')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -90,7 +85,7 @@ describe('', () => { lifecycle: 'production', }, }; - const { getByText } = await renderInTestApp( + await renderInTestApp( , { mountedRoutes: { @@ -98,7 +93,7 @@ describe('', () => { }, }, ); - expect(getByText('test/software')).toHaveAttribute( + expect(screen.getByText('test/software')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -110,15 +105,12 @@ describe('', () => { namespace: 'default', name: 'software', }; - const { getByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name/*': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, - ); - expect(getByText('component:software')).toHaveAttribute( + }); + expect(screen.getByText('component:software')).toHaveAttribute( 'href', '/catalog/default/component/software', ); @@ -130,15 +122,12 @@ describe('', () => { namespace: 'test', name: 'software', }; - const { getByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name/*': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, - ); - expect(getByText('component:test/software')).toHaveAttribute( + }); + expect(screen.getByText('component:test/software')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -150,7 +139,7 @@ describe('', () => { namespace: 'test', name: 'software', }; - const { getByText } = await renderInTestApp( + await renderInTestApp( , { mountedRoutes: { @@ -158,7 +147,7 @@ describe('', () => { }, }, ); - expect(getByText('test/software')).toHaveAttribute( + expect(screen.getByText('test/software')).toHaveAttribute( 'href', '/catalog/test/component/software', ); @@ -170,7 +159,7 @@ describe('', () => { namespace: 'test', name: 'software', }; - const { getByText } = await renderInTestApp( + await renderInTestApp( Custom Children , @@ -180,7 +169,7 @@ describe('', () => { }, }, ); - expect(getByText('Custom Children')).toHaveAttribute( + expect(screen.getByText('Custom Children')).toHaveAttribute( 'href', '/catalog/test/component/software', ); diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx index 147e139749..4e0d52ef4e 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx @@ -15,6 +15,7 @@ */ import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import React from 'react'; import { entityRouteRef } from '../../routes'; import { EntityRefLinks } from './EntityRefLinks'; @@ -28,15 +29,12 @@ describe('', () => { name: 'software', }, ]; - const { getByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name/*': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, - ); - expect(getByText('component:software')).toHaveAttribute( + }); + expect(screen.getByText('component:software')).toHaveAttribute( 'href', '/catalog/default/component/software', ); @@ -55,20 +53,17 @@ describe('', () => { name: 'interface', }, ]; - const { getByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name/*': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name/*': entityRouteRef, }, - ); - expect(getByText(',')).toBeInTheDocument(); - expect(getByText('component:software')).toHaveAttribute( + }); + expect(screen.getByText(',')).toBeInTheDocument(); + expect(screen.getByText('component:software')).toHaveAttribute( 'href', '/catalog/default/component/software', ); - expect(getByText('api:interface')).toHaveAttribute( + expect(screen.getByText('api:interface')).toHaveAttribute( 'href', '/catalog/default/api/interface', ); diff --git a/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.test.tsx index 36e2c0450d..9db6511662 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.test.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/FetchedEntityRefLinks.test.tsx @@ -16,6 +16,7 @@ import { FetchedEntityRefLinks } from './FetchedEntityRefLinks'; import { entityRouteRef } from '../../routes'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import { Entity } from '@backstage/catalog-model'; import React from 'react'; import { JsonObject } from '@backstage/types'; @@ -60,7 +61,7 @@ describe('', () => { }), }; - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -71,12 +72,12 @@ describe('', () => { }, ); - expect(rendered.getByText('SOFTWARE')).toHaveAttribute( + expect(screen.getByText('SOFTWARE')).toHaveAttribute( 'href', '/catalog/default/component/software', ); - expect(rendered.getByText('INTERFACE')).toHaveAttribute( + expect(screen.getByText('INTERFACE')).toHaveAttribute( 'href', '/catalog/default/api/interface', ); @@ -111,7 +112,7 @@ describe('', () => { const catalogApi: Partial = {}; - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -122,12 +123,12 @@ describe('', () => { }, ); - expect(rendered.getByText('TOOL')).toHaveAttribute( + expect(screen.getByText('TOOL')).toHaveAttribute( 'href', '/catalog/default/component/tool', ); - expect(rendered.getByText('IMPLEMENTATION')).toHaveAttribute( + expect(screen.getByText('IMPLEMENTATION')).toHaveAttribute( 'href', '/catalog/default/api/implementation', ); @@ -189,7 +190,7 @@ describe('', () => { }), }; - const rendered = await renderInTestApp( + await renderInTestApp( , @@ -200,17 +201,17 @@ describe('', () => { }, ); - expect(rendered.getByText('TOOL')).toHaveAttribute( + expect(screen.getByText('TOOL')).toHaveAttribute( 'href', '/catalog/default/component/tool', ); - expect(rendered.getByText('IMPLEMENTATION')).toHaveAttribute( + expect(screen.getByText('IMPLEMENTATION')).toHaveAttribute( 'href', '/catalog/default/api/implementation', ); - expect(rendered.getByText('INTERFACE')).toHaveAttribute( + expect(screen.getByText('INTERFACE')).toHaveAttribute( 'href', '/catalog/default/component/interface', ); diff --git a/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.test.tsx b/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.test.tsx index 0b9ea9cb98..94db377e26 100644 --- a/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.test.tsx +++ b/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor, screen } from '@testing-library/react'; import { EntitySearchBar } from './EntitySearchBar'; import { DefaultEntityFilters } from '../../hooks/useEntityListProvider'; import { EntityTextFilter } from '../../filters'; @@ -29,13 +29,13 @@ describe('EntitySearchBar', () => { text: new EntityTextFilter('hello'), }; - const { getByDisplayValue } = render( + render( , ); - const searchInput = getByDisplayValue('hello'); + const searchInput = screen.getByDisplayValue('hello'); expect(searchInput).toBeInTheDocument(); fireEvent.change(searchInput, { target: { value: 'world' } }); diff --git a/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx b/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx index 99f998118f..d5f1dc20d3 100644 --- a/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx +++ b/plugins/catalog-react/src/components/EntityTable/EntityTable.test.tsx @@ -16,13 +16,13 @@ import { Entity } from '@backstage/catalog-model'; import { renderInTestApp } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { EntityTable } from './EntityTable'; describe('', () => { it('shows empty table', async () => { - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { />, ); - expect(getByText('Entities')).toBeInTheDocument(); - expect(getByText('EMPTY')).toBeInTheDocument(); + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByText('EMPTY')).toBeInTheDocument(); }); it('shows entities', async () => { @@ -47,7 +47,7 @@ describe('', () => { }, ]; - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { ); await waitFor(() => { - expect(getByText('my-entity')).toBeInTheDocument(); + expect(screen.getByText('my-entity')).toBeInTheDocument(); }); }); }); diff --git a/plugins/catalog-react/src/components/EntityTable/presets.test.tsx b/plugins/catalog-react/src/components/EntityTable/presets.test.tsx index cca9c7d494..82075df5e9 100644 --- a/plugins/catalog-react/src/components/EntityTable/presets.test.tsx +++ b/plugins/catalog-react/src/components/EntityTable/presets.test.tsx @@ -21,7 +21,7 @@ import { SystemEntity, } from '@backstage/catalog-model'; import { renderInTestApp } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { entityRouteRef } from '../../routes'; import { EntityTable } from './EntityTable'; @@ -54,7 +54,7 @@ describe('systemEntityColumns', () => { }, ]; - const { getByText } = await renderInTestApp( + await renderInTestApp( { ); await waitFor(() => { - expect(getByText('my-namespace/my-system')).toBeInTheDocument(); - expect(getByText('my-namespace/my-domain')).toBeInTheDocument(); - expect(getByText('test')).toBeInTheDocument(); - expect(getByText(/Some/)).toBeInTheDocument(); + expect(screen.getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(screen.getByText('my-namespace/my-domain')).toBeInTheDocument(); + expect(screen.getByText('test')).toBeInTheDocument(); + expect(screen.getByText(/Some/)).toBeInTheDocument(); }); }); }); @@ -106,7 +106,7 @@ describe('componentEntityColumns', () => { }, ]; - const { getByText } = await renderInTestApp( + await renderInTestApp( { ); await waitFor(() => { - expect(getByText('my-namespace/my-component')).toBeInTheDocument(); - expect(getByText('my-namespace/my-system')).toBeInTheDocument(); - expect(getByText('test')).toBeInTheDocument(); - expect(getByText('production')).toBeInTheDocument(); - expect(getByText('service')).toBeInTheDocument(); - expect(getByText(/Some/)).toBeInTheDocument(); + expect(screen.getByText('my-namespace/my-component')).toBeInTheDocument(); + expect(screen.getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(screen.getByText('test')).toBeInTheDocument(); + expect(screen.getByText('production')).toBeInTheDocument(); + expect(screen.getByText('service')).toBeInTheDocument(); + expect(screen.getByText(/Some/)).toBeInTheDocument(); }); }); }); diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index 78bdde1994..7da357d806 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor, screen } from '@testing-library/react'; import React from 'react'; import { MockEntityListContextProvider } from '../../testUtils/providers'; import { EntityTagFilter } from '../../filters'; @@ -35,34 +35,34 @@ describe('', () => { } as unknown as CatalogApi; it('renders all tags', async () => { - const rendered = render( + render( , ); - await waitFor(() => expect(rendered.getByText('Tags')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Tags')).toBeInTheDocument()); - fireEvent.click(rendered.getByTestId('tag-picker-expand')); + fireEvent.click(screen.getByTestId('tag-picker-expand')); tags.forEach(tag => { - expect(rendered.getByText(tag)).toBeInTheDocument(); + expect(screen.getByText(tag)).toBeInTheDocument(); }); }); it('renders unique tags in alphabetical order', async () => { - const rendered = render( + render( , ); - await waitFor(() => expect(rendered.getByText('Tags')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Tags')).toBeInTheDocument()); - fireEvent.click(rendered.getByTestId('tag-picker-expand')); + fireEvent.click(screen.getByTestId('tag-picker-expand')); - expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([ + expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([ 'tag1', 'tag2', 'tag3', @@ -71,18 +71,18 @@ describe('', () => { }); it('renders tags with counts', async () => { - const rendered = render( + render( , ); - await waitFor(() => expect(rendered.getByText('Tags')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('Tags')).toBeInTheDocument()); - fireEvent.click(rendered.getByTestId('tag-picker-expand')); + fireEvent.click(screen.getByTestId('tag-picker-expand')); - expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([ + expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([ 'tag1 (0)', 'tag2 (1)', 'tag3 (2)', @@ -115,7 +115,7 @@ describe('', () => { it('adds tags to filters', async () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { }), ); - fireEvent.click(rendered.getByTestId('tag-picker-expand')); - fireEvent.click(rendered.getByText('tag1')); + fireEvent.click(screen.getByTestId('tag-picker-expand')); + fireEvent.click(screen.getByText('tag1')); expect(updateFilters).toHaveBeenLastCalledWith({ tags: new EntityTagFilter(['tag1']), }); @@ -141,7 +141,7 @@ describe('', () => { it('removes tags from filters', async () => { const updateFilters = jest.fn(); - const rendered = render( + render( ', () => { tags: new EntityTagFilter(['tag1']), }), ); - fireEvent.click(rendered.getByTestId('tag-picker-expand')); - expect(rendered.getByLabelText('tag1')).toBeChecked(); + fireEvent.click(screen.getByTestId('tag-picker-expand')); + expect(screen.getByLabelText('tag1')).toBeChecked(); - fireEvent.click(rendered.getByLabelText('tag1')); + fireEvent.click(screen.getByLabelText('tag1')); expect(updateFilters).toHaveBeenLastCalledWith({ tags: undefined, }); diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx index 525c757dea..d61a6c480f 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx @@ -15,13 +15,12 @@ */ import React from 'react'; -import { fireEvent, waitFor } from '@testing-library/react'; +import { fireEvent, waitFor, screen } from '@testing-library/react'; import { Entity } from '@backstage/catalog-model'; import { EntityTypePicker } from './EntityTypePicker'; import { MockEntityListContextProvider } from '../../testUtils/providers'; import { catalogApiRef } from '../../api'; import { EntityKindFilter, EntityTypeFilter } from '../../filters'; - import { alertApiRef } from '@backstage/core-plugin-api'; import { ApiProvider } from '@backstage/core-app-api'; import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; @@ -84,7 +83,7 @@ const apis = TestApiRegistry.from( describe('', () => { it('renders available entity types', async () => { - const rendered = await renderWithEffects( + await renderWithEffects( ', () => { , ); - expect(rendered.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); - const input = rendered.getByTestId('select'); + const input = screen.getByTestId('select'); fireEvent.click(input); - await waitFor(() => rendered.getByText('service')); + await waitFor(() => screen.getByText('service')); entities.forEach(entity => { - expect( - rendered.getByText(entity.spec!.type as string), - ).toBeInTheDocument(); + expect(screen.getByText(entity.spec!.type as string)).toBeInTheDocument(); }); }); it('sets the selected type filter', async () => { const updateFilters = jest.fn(); - const rendered = await renderWithEffects( + await renderWithEffects( ', () => { , ); - const input = rendered.getByTestId('select'); + const input = screen.getByTestId('select'); fireEvent.click(input); - await waitFor(() => rendered.getByText('service')); - fireEvent.click(rendered.getByText('service')); + await waitFor(() => screen.getByText('service')); + fireEvent.click(screen.getByText('service')); expect(updateFilters).toHaveBeenLastCalledWith({ type: new EntityTypeFilter(['service']), }); fireEvent.click(input); - fireEvent.click(rendered.getByText('all')); + fireEvent.click(screen.getByText('all')); expect(updateFilters).toHaveBeenLastCalledWith({ type: undefined }); }); diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx index 3db2fab0fb..d5309a383d 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor, screen } from '@testing-library/react'; import { Entity, RELATION_OWNED_BY, @@ -27,7 +27,6 @@ import { EntityTagFilter, UserListFilter } from '../../filters'; import { CatalogApi } from '@backstage/catalog-client'; import { catalogApiRef } from '../../api'; import { MockStorageApi, TestApiRegistry } from '@backstage/test-utils'; - import { ApiProvider } from '@backstage/core-app-api'; import { ConfigApi, @@ -144,7 +143,7 @@ const backendEntities: Entity[] = [ describe('', () => { it('renders filter groups', () => { - const { queryByText } = render( + render( @@ -152,12 +151,12 @@ describe('', () => { , ); - expect(queryByText('Personal')).toBeInTheDocument(); - expect(queryByText('Test Company')).toBeInTheDocument(); + expect(screen.queryByText('Personal')).toBeInTheDocument(); + expect(screen.queryByText('Test Company')).toBeInTheDocument(); }); it('renders filters', () => { - const { getAllByRole } = render( + render( @@ -166,12 +165,12 @@ describe('', () => { ); expect( - getAllByRole('menuitem').map(({ textContent }) => textContent), + screen.getAllByRole('menuitem').map(({ textContent }) => textContent), ).toEqual(['Owned 1', 'Starred 1', 'All 4']); }); it('includes counts alongside each filter', async () => { - const { getAllByRole } = render( + render( @@ -183,13 +182,13 @@ describe('', () => { // menuitem itself, so we pick off the next sibling. await waitFor(() => { expect( - getAllByRole('menuitem').map(({ textContent }) => textContent), + screen.getAllByRole('menuitem').map(({ textContent }) => textContent), ).toEqual(['Owned 1', 'Starred 1', 'All 4']); }); }); it('respects other frontend filters in counts', async () => { - const { getAllByRole } = render( + render( ', () => { await waitFor(() => { expect( - getAllByRole('menuitem').map(({ textContent }) => textContent), + screen.getAllByRole('menuitem').map(({ textContent }) => textContent), ).toEqual(['Owned 1', 'Starred 0', 'All 2']); }); }); @@ -229,7 +228,7 @@ describe('', () => { it('updates user filter when a menuitem is selected', () => { const updateFilters = jest.fn(); - const { getByText } = render( + render( ', () => { , ); - fireEvent.click(getByText('Starred')); + fireEvent.click(screen.getByText('Starred')); expect(updateFilters).toHaveBeenLastCalledWith({ user: new UserListFilter( diff --git a/plugins/catalog/.eslintrc.js b/plugins/catalog/.eslintrc.js index e2a53a6ad2..45bb6db521 100644 --- a/plugins/catalog/.eslintrc.js +++ b/plugins/catalog/.eslintrc.js @@ -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', + }, +}); diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 0220e5c244..244aab8626 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -49,9 +49,10 @@ "@material-ui/lab": "4.0.0-alpha.57", "history": "^5.0.0", "lodash": "^4.17.21", + "pluralize": "^8.0.0", "react-helmet": "6.1.0", "react-use": "^17.2.4", - "zen-observable": "^0.8.15" + "zen-observable": "^0.9.0" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index c77787f717..869d6f1757 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -28,6 +28,7 @@ import { } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; import React from 'react'; import { viewTechDocRouteRef } from '../../routes'; import { AboutCard } from './AboutCard'; @@ -73,7 +74,7 @@ describe('', () => { ], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { }, ); - expect(getByText('service')).toBeInTheDocument(); - expect(getByText('user:guest')).toBeInTheDocument(); - expect(getByText('production')).toBeInTheDocument(); - expect(getByText('This is the description')).toBeInTheDocument(); + expect(screen.getByText('service')).toBeInTheDocument(); + expect(screen.getByText('user:guest')).toBeInTheDocument(); + expect(screen.getByText('production')).toBeInTheDocument(); + expect(screen.getByText('This is the description')).toBeInTheDocument(); }); it('renders "view source" link', async () => { @@ -122,7 +123,7 @@ describe('', () => { }, }; - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { }, }, ); - expect(getByText('View Source').closest('a')).toHaveAttribute( + expect(screen.getByText('View Source').closest('a')).toHaveAttribute( 'href', 'https://github.com/backstage/backstage/blob/master/software.yaml', ); @@ -177,7 +178,7 @@ describe('', () => { }, }; - const { getByTitle } = await renderInTestApp( + await renderInTestApp( ', () => { }, ); - const editLink = getByTitle('Edit Metadata').closest('a'); + const editLink = screen.getByTitle('Edit Metadata').closest('a'); expect(editLink).toHaveAttribute( 'href', 'https://github.com/backstage/backstage/edit/master/software.yaml', @@ -230,7 +231,7 @@ describe('', () => { }, }; - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { }, }, ); - expect(getByText('View Source')).toBeVisible(); - expect(getByText('View Source').closest('a')).toBeNull(); + expect(screen.getByText('View Source')).toBeVisible(); + expect(screen.getByText('View Source').closest('a')).toBeNull(); }); it.each([ @@ -274,7 +275,7 @@ describe('', () => { }, }; - const { getByTitle } = await renderInTestApp( + await renderInTestApp( ', () => { 'component:default/software', ); - await userEvent.click(getByTitle('Schedule entity refresh')); + await userEvent.click(screen.getByTitle('Schedule entity refresh')); expect(catalogApi.refreshEntity).toHaveBeenCalledWith( 'component:default/software', @@ -320,7 +321,7 @@ describe('', () => { }, }; - const { queryByTitle } = await renderInTestApp( + await renderInTestApp( ', () => { }, ); - expect(queryByTitle('Schedule entity refresh')).not.toBeInTheDocument(); + expect( + screen.queryByTitle('Schedule entity refresh'), + ).not.toBeInTheDocument(); }); it('renders techdocs link', async () => { @@ -361,7 +364,7 @@ describe('', () => { }, }; - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { }, ); - expect(getByText('View TechDocs').closest('a')).toHaveAttribute( + expect(screen.getByText('View TechDocs').closest('a')).toHaveAttribute( 'href', '/docs/default/Component/software', ); @@ -414,7 +417,7 @@ describe('', () => { }, }; - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { }, ); - expect(getByText('View TechDocs')).toBeVisible(); - expect(getByText('View TechDocs').closest('a')).toBeNull(); + expect(screen.getByText('View TechDocs')).toBeVisible(); + expect(screen.getByText('View TechDocs').closest('a')).toBeNull(); }); it('renders disabled techdocs link when route is not bound', async () => { @@ -467,7 +470,7 @@ describe('', () => { }, }; - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { }, ); - expect(getByText('View TechDocs')).toBeVisible(); - expect(getByText('View TechDocs').closest('a')).toBeNull(); + expect(screen.getByText('View TechDocs')).toBeVisible(); + expect(screen.getByText('View TechDocs').closest('a')).toBeNull(); }); }); diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.test.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.test.tsx index f4dbb73b45..2a89f2fd92 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.test.tsx @@ -21,6 +21,7 @@ import { } from '@backstage/catalog-model'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import React from 'react'; import { AboutContent } from './AboutContent'; @@ -62,32 +63,29 @@ describe('', () => { }); it('renders info', async () => { - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('user:o'); - expect(getByText('Domain')).toBeInTheDocument(); - expect(getByText('Domain').nextSibling).toHaveTextContent('d'); - expect(getByText('System')).toBeInTheDocument(); - expect(getByText('System').nextSibling).toHaveTextContent('s'); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(getByText('Type')).toBeInTheDocument(); - expect(getByText('Type').nextSibling).toHaveTextContent('t'); - expect(getByText('Lifecycle')).toBeInTheDocument(); - expect(getByText('Lifecycle').nextSibling).toHaveTextContent('l'); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('tag-1'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent('user:o'); + expect(screen.getByText('Domain')).toBeInTheDocument(); + expect(screen.getByText('Domain').nextSibling).toHaveTextContent('d'); + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getByText('System').nextSibling).toHaveTextContent('s'); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type').nextSibling).toHaveTextContent('t'); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle').nextSibling).toHaveTextContent('l'); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('tag-1'); }); it('highlights missing required fields', async () => { @@ -95,26 +93,25 @@ describe('', () => { entity.spec = {}; entity.relations = []; - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('No Owner'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(queryByText('System')).not.toBeInTheDocument(); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(queryByText('Type')).not.toBeInTheDocument(); - expect(queryByText('Lifecycle')).not.toBeInTheDocument(); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'No Owner', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.queryByText('System')).not.toBeInTheDocument(); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.queryByText('Type')).not.toBeInTheDocument(); + expect(screen.queryByText('Lifecycle')).not.toBeInTheDocument(); }); }); @@ -151,33 +148,34 @@ describe('', () => { }); it('renders info', async () => { - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('user:guest'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(getByText('System')).toBeInTheDocument(); - expect(getByText('System').nextSibling).toHaveTextContent('system'); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(getByText('Type')).toBeInTheDocument(); - expect(getByText('Type').nextSibling).toHaveTextContent('openapi'); - expect(getByText('Lifecycle')).toBeInTheDocument(); - expect(getByText('Lifecycle').nextSibling).toHaveTextContent( + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'user:guest', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getByText('System').nextSibling).toHaveTextContent( + 'system', + ); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type').nextSibling).toHaveTextContent('openapi'); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle').nextSibling).toHaveTextContent( 'production', ); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('tag-1'); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('tag-1'); }); it('highlights missing required fields', async () => { @@ -187,31 +185,34 @@ describe('', () => { delete entity.spec!.system; entity.relations = []; - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('No Owner'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(getByText('System')).toBeInTheDocument(); - expect(getByText('System').nextSibling).toHaveTextContent('No System'); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(getByText('Type')).toBeInTheDocument(); - expect(getByText('Type').nextSibling).toHaveTextContent('unknown'); - expect(getByText('Lifecycle')).toBeInTheDocument(); - expect(getByText('Lifecycle').nextSibling).toHaveTextContent('unknown'); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('No Tags'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'No Owner', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getByText('System').nextSibling).toHaveTextContent( + 'No System', + ); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type').nextSibling).toHaveTextContent('unknown'); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle').nextSibling).toHaveTextContent( + 'unknown', + ); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('No Tags'); }); }); @@ -252,36 +253,37 @@ describe('', () => { }); it('renders info', async () => { - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('user:guest'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(getByText('System')).toBeInTheDocument(); - expect(getByText('System').nextSibling).toHaveTextContent('system'); - expect(getByText('Parent Component')).toBeInTheDocument(); - expect(getByText('Parent Component').nextSibling).toHaveTextContent( - 'parent-software', + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'user:guest', ); - expect(getByText('Type')).toBeInTheDocument(); - expect(getByText('Type').nextSibling).toHaveTextContent('service'); - expect(getByText('Lifecycle')).toBeInTheDocument(); - expect(getByText('Lifecycle').nextSibling).toHaveTextContent( + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getByText('System').nextSibling).toHaveTextContent( + 'system', + ); + expect(screen.getByText('Parent Component')).toBeInTheDocument(); + expect( + screen.getByText('Parent Component').nextSibling, + ).toHaveTextContent('parent-software'); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type').nextSibling).toHaveTextContent('service'); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle').nextSibling).toHaveTextContent( 'production', ); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('tag-1'); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('tag-1'); }); it('highlights missing required fields', async () => { @@ -291,31 +293,34 @@ describe('', () => { delete entity.spec!.system; entity.relations = []; - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('No Owner'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(getByText('System')).toBeInTheDocument(); - expect(getByText('System').nextSibling).toHaveTextContent('No System'); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(getByText('Type')).toBeInTheDocument(); - expect(getByText('Type').nextSibling).toHaveTextContent('unknown'); - expect(getByText('Lifecycle')).toBeInTheDocument(); - expect(getByText('Lifecycle').nextSibling).toHaveTextContent('unknown'); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('No Tags'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'No Owner', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getByText('System').nextSibling).toHaveTextContent( + 'No System', + ); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type').nextSibling).toHaveTextContent('unknown'); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle').nextSibling).toHaveTextContent( + 'unknown', + ); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('No Tags'); }); }); @@ -344,56 +349,54 @@ describe('', () => { }); it('renders info', async () => { - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('user:guest'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(queryByText('System')).not.toBeInTheDocument(); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(queryByText('Type')).not.toBeInTheDocument(); - expect(queryByText('Lifecycle')).not.toBeInTheDocument(); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('tag-1'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'user:guest', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.queryByText('System')).not.toBeInTheDocument(); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.queryByText('Type')).not.toBeInTheDocument(); + expect(screen.queryByText('Lifecycle')).not.toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('tag-1'); }); it('highlights missing required fields', async () => { delete entity.metadata.tags; entity.relations = []; - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('No Owner'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(queryByText('System')).not.toBeInTheDocument(); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(queryByText('Type')).not.toBeInTheDocument(); - expect(queryByText('Lifecycle')).not.toBeInTheDocument(); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('No Tags'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'No Owner', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.queryByText('System')).not.toBeInTheDocument(); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.queryByText('Type')).not.toBeInTheDocument(); + expect(screen.queryByText('Lifecycle')).not.toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('No Tags'); }); }); @@ -418,31 +421,30 @@ describe('', () => { }); it('renders info', async () => { - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('No Owner'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(queryByText('System')).not.toBeInTheDocument(); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(getByText('Type')).toBeInTheDocument(); - expect(getByText('Type').nextSibling).toHaveTextContent('root'); - expect(queryByText('Lifecycle')).not.toBeInTheDocument(); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('tag-1'); - expect(getByText('Targets')).toBeInTheDocument(); - expect(getByText('Targets').nextSibling).toHaveTextContent( + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'No Owner', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.queryByText('System')).not.toBeInTheDocument(); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type').nextSibling).toHaveTextContent('root'); + expect(screen.queryByText('Lifecycle')).not.toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('tag-1'); + expect(screen.getByText('Targets')).toBeInTheDocument(); + expect(screen.getByText('Targets').nextSibling).toHaveTextContent( 'https://backstage.io', ); }); @@ -451,29 +453,28 @@ describe('', () => { delete entity.metadata.tags; delete entity.spec!.type; - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('No Owner'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(queryByText('System')).not.toBeInTheDocument(); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(getByText('Type')).toBeInTheDocument(); - expect(getByText('Type').nextSibling).toHaveTextContent('unknown'); - expect(queryByText('Lifecycle')).not.toBeInTheDocument(); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('No Tags'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'No Owner', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.queryByText('System')).not.toBeInTheDocument(); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type').nextSibling).toHaveTextContent('unknown'); + expect(screen.queryByText('Lifecycle')).not.toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('No Tags'); }); }); @@ -508,30 +509,31 @@ describe('', () => { }); it('renders info', async () => { - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('user:guest'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(getByText('System')).toBeInTheDocument(); - expect(getByText('System').nextSibling).toHaveTextContent('system'); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(getByText('Type')).toBeInTheDocument(); - expect(getByText('Type').nextSibling).toHaveTextContent('s3'); - expect(queryByText('Lifecycle')).not.toBeInTheDocument(); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('tag-1'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'user:guest', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getByText('System').nextSibling).toHaveTextContent( + 'system', + ); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type').nextSibling).toHaveTextContent('s3'); + expect(screen.queryByText('Lifecycle')).not.toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('tag-1'); }); it('highlights missing required fields', async () => { @@ -540,30 +542,31 @@ describe('', () => { delete entity.spec!.system; entity.relations = []; - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('No Owner'); - expect(queryByText('Domain')).not.toBeInTheDocument(); - expect(getByText('System')).toBeInTheDocument(); - expect(getByText('System').nextSibling).toHaveTextContent('No System'); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(getByText('Type')).toBeInTheDocument(); - expect(getByText('Type').nextSibling).toHaveTextContent('unknown'); - expect(queryByText('Lifecycle')).not.toBeInTheDocument(); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('No Tags'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'No Owner', + ); + expect(screen.queryByText('Domain')).not.toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getByText('System').nextSibling).toHaveTextContent( + 'No System', + ); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Type').nextSibling).toHaveTextContent('unknown'); + expect(screen.queryByText('Lifecycle')).not.toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('No Tags'); }); }); @@ -597,29 +600,30 @@ describe('', () => { }); it('renders info', async () => { - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('user:guest'); - expect(getByText('Domain')).toBeInTheDocument(); - expect(getByText('Domain').nextSibling).toHaveTextContent('domain'); - expect(queryByText('System')).not.toBeInTheDocument(); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(queryByText('Type')).not.toBeInTheDocument(); - expect(queryByText('Lifecycle')).not.toBeInTheDocument(); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('tag-1'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'user:guest', + ); + expect(screen.getByText('Domain')).toBeInTheDocument(); + expect(screen.getByText('Domain').nextSibling).toHaveTextContent( + 'domain', + ); + expect(screen.queryByText('System')).not.toBeInTheDocument(); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.queryByText('Type')).not.toBeInTheDocument(); + expect(screen.queryByText('Lifecycle')).not.toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('tag-1'); }); it('highlights missing required fields', async () => { @@ -627,29 +631,30 @@ describe('', () => { delete entity.spec!.domain; entity.relations = []; - const { getByText, queryByText } = await renderInTestApp( - , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, + await renderInTestApp(, { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, }, - ); + }); - expect(getByText('Description')).toBeInTheDocument(); - expect(getByText('Description').nextSibling).toHaveTextContent( + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Description').nextSibling).toHaveTextContent( 'This is the description', ); - expect(getByText('Owner')).toBeInTheDocument(); - expect(getByText('Owner').nextSibling).toHaveTextContent('No Owner'); - expect(getByText('Domain')).toBeInTheDocument(); - expect(getByText('Domain').nextSibling).toHaveTextContent('No Domain'); - expect(queryByText('System')).not.toBeInTheDocument(); - expect(queryByText('Parent Component')).not.toBeInTheDocument(); - expect(queryByText('Type')).not.toBeInTheDocument(); - expect(queryByText('Lifecycle')).not.toBeInTheDocument(); - expect(getByText('Tags')).toBeInTheDocument(); - expect(getByText('Tags').nextSibling).toHaveTextContent('No Tags'); + expect(screen.getByText('Owner')).toBeInTheDocument(); + expect(screen.getByText('Owner').nextSibling).toHaveTextContent( + 'No Owner', + ); + expect(screen.getByText('Domain')).toBeInTheDocument(); + expect(screen.getByText('Domain').nextSibling).toHaveTextContent( + 'No Domain', + ); + expect(screen.queryByText('System')).not.toBeInTheDocument(); + expect(screen.queryByText('Parent Component')).not.toBeInTheDocument(); + expect(screen.queryByText('Type')).not.toBeInTheDocument(); + expect(screen.queryByText('Lifecycle')).not.toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + expect(screen.getByText('Tags').nextSibling).toHaveTextContent('No Tags'); }); }); }); diff --git a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx index 15d41f3ab3..f511f58c58 100644 --- a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx +++ b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import { GetEntityFacetsResponse } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { @@ -31,6 +31,7 @@ import { } from '@backstage/test-utils'; import { CatalogKindHeader } from './CatalogKindHeader'; import { errorApiRef } from '@backstage/core-plugin-api'; +import pluralize from 'pluralize'; const entities: Entity[] = [ { @@ -83,7 +84,7 @@ const apis = TestApiRegistry.from( describe('', () => { it('renders available kinds', async () => { - const rendered = await renderWithEffects( + await renderWithEffects( @@ -91,18 +92,18 @@ describe('', () => { , ); - const input = rendered.getByText('Components'); + const input = screen.getByText('Components'); fireEvent.mouseDown(input); entities.map(entity => { expect( - rendered.getByRole('option', { name: `${entity.kind}s` }), + screen.getByRole('option', { name: `${pluralize(entity.kind)}` }), ).toBeInTheDocument(); }); }); it('renders unknown kinds provided in query parameters', async () => { - const rendered = await renderWithEffects( + await renderWithEffects( ', () => { , ); - expect(rendered.getByText('Frobs')).toBeInTheDocument(); + expect(screen.getByText('Frobs')).toBeInTheDocument(); }); it('updates the kind filter', async () => { const updateFilters = jest.fn(); - const rendered = await renderWithEffects( + await renderWithEffects( @@ -125,10 +126,10 @@ describe('', () => { , ); - const input = rendered.getByText('Components'); + const input = screen.getByText('Components'); fireEvent.mouseDown(input); - const option = rendered.getByRole('option', { name: 'Templates' }); + const option = screen.getByRole('option', { name: 'Templates' }); fireEvent.click(option); expect(updateFilters).toHaveBeenCalledWith({ @@ -171,7 +172,7 @@ describe('', () => { }); it('limits kinds when allowedKinds is set', async () => { - const rendered = await renderWithEffects( + await renderWithEffects( @@ -179,22 +180,20 @@ describe('', () => { , ); - const input = rendered.getByText('Components'); + const input = screen.getByText('Components'); fireEvent.mouseDown(input); expect( - rendered.getByRole('option', { name: 'Components' }), + screen.getByRole('option', { name: 'Components' }), ).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Systems' })).toBeInTheDocument(); expect( - rendered.getByRole('option', { name: 'Systems' }), - ).toBeInTheDocument(); - expect( - rendered.queryByRole('option', { name: 'Templates' }), + screen.queryByRole('option', { name: 'Templates' }), ).not.toBeInTheDocument(); }); it('renders kind from the query parameter even when not in allowedKinds', async () => { - const rendered = await renderWithEffects( + await renderWithEffects( ', () => { , ); - expect(rendered.getByText('Frobs')).toBeInTheDocument(); - const input = rendered.getByText('Frobs'); + expect(screen.getByText('Frobs')).toBeInTheDocument(); + const input = screen.getByText('Frobs'); fireEvent.mouseDown(input); - expect( - rendered.getByRole('option', { name: 'Systems' }), - ).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Systems' })).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx index b0aaeae658..bb9e98d9c3 100644 --- a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx +++ b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx @@ -31,6 +31,7 @@ import { } from '@backstage/plugin-catalog-react'; import useAsync from 'react-use/lib/useAsync'; import { useApi } from '@backstage/core-plugin-api'; +import pluralize from 'pluralize'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -132,7 +133,7 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) { > {Object.keys(options).map(kind => ( - {`${options[kind]}s`} + {`${pluralize(options[kind])}`} ))} diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 6fbdcf40a2..5b80f5976c 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -16,6 +16,7 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import { useOutlet } from 'react-router'; import { CatalogPage } from './CatalogPage'; @@ -30,15 +31,15 @@ jest.mock('./DefaultCatalogPage', () => ({ describe('CatalogPage', () => { it('renders provided router element', async () => { - const { getByText } = await renderInTestApp(); + await renderInTestApp(); - expect(getByText('Route Children')).toBeInTheDocument(); + expect(screen.getByText('Route Children')).toBeInTheDocument(); }); it('renders DefaultCatalogPage home when no router children are provided', async () => { (useOutlet as jest.Mock).mockReturnValueOnce(null); - const { getByText } = await renderInTestApp(); + await renderInTestApp(); - expect(getByText('DefaultCatalogPage')).toBeInTheDocument(); + expect(screen.getByText('DefaultCatalogPage')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx index cc00a57314..969992bd4e 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx @@ -178,11 +178,11 @@ describe('DefaultCatalogPage', () => { // limit. We should investigate why these timeouts happen. it('should render the default column of the grid', async () => { - const { getAllByRole } = await renderWrapped(); + await renderWrapped(); - const columnHeader = getAllByRole('button').filter( - c => c.tagName === 'SPAN', - ); + const columnHeader = screen + .getAllByRole('button') + .filter(c => c.tagName === 'SPAN'); const columnHeaderLabels = columnHeader.map(c => c.textContent); expect(columnHeaderLabels).toEqual([ @@ -203,26 +203,22 @@ describe('DefaultCatalogPage', () => { { title: 'Bar', field: 'entity.bar' }, { title: 'Baz', field: 'entity.spec.lifecycle' }, ]; - const { getAllByRole } = await renderWrapped( - , - ); + await renderWrapped(); - const columnHeader = getAllByRole('button').filter( - c => c.tagName === 'SPAN', - ); + const columnHeader = screen + .getAllByRole('button') + .filter(c => c.tagName === 'SPAN'); const columnHeaderLabels = columnHeader.map(c => c.textContent); expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']); }, 20_000); it('should render the default actions of an item in the grid', async () => { - const { getByTestId, findByTitle, findByText } = await renderWrapped( - , - ); - fireEvent.click(getByTestId('user-picker-owned')); - expect(await findByText(/Owned \(1\)/)).toBeInTheDocument(); - expect(await findByTitle(/View/)).toBeInTheDocument(); - expect(await findByTitle(/Edit/)).toBeInTheDocument(); - expect(await findByTitle(/Add to favorites/)).toBeInTheDocument(); + await renderWrapped(); + fireEvent.click(screen.getByTestId('user-picker-owned')); + expect(await screen.findByText(/Owned \(1\)/)).toBeInTheDocument(); + expect(await screen.findByTitle(/View/)).toBeInTheDocument(); + expect(await screen.findByTitle(/Edit/)).toBeInTheDocument(); + expect(await screen.findByTitle(/Add to favorites/)).toBeInTheDocument(); }, 20_000); it('should render the custom actions of an item passed as prop', async () => { @@ -245,41 +241,35 @@ describe('DefaultCatalogPage', () => { }, ]; - const { getByTestId, findByTitle, findByText } = await renderWrapped( - , - ); - fireEvent.click(getByTestId('user-picker-owned')); - expect(await findByText(/Owned \(1\)/)).toBeInTheDocument(); - expect(await findByTitle(/Foo Action/)).toBeInTheDocument(); - expect(await findByTitle(/Bar Action/)).toBeInTheDocument(); - expect((await findByTitle(/Bar Action/)).firstChild).toBeDisabled(); + await renderWrapped(); + fireEvent.click(screen.getByTestId('user-picker-owned')); + expect(await screen.findByText(/Owned \(1\)/)).toBeInTheDocument(); + expect(await screen.findByTitle(/Foo Action/)).toBeInTheDocument(); + expect(await screen.findByTitle(/Bar Action/)).toBeInTheDocument(); + expect((await screen.findByTitle(/Bar Action/)).firstChild).toBeDisabled(); }, 20_000); // this test right now causes some red lines in the log output when running tests // related to some theme issues in mui-table // https://github.com/mbrn/material-table/issues/1293 it('should render', async () => { - const { findByText, getByTestId } = await renderWrapped( - , - ); - fireEvent.click(getByTestId('user-picker-owned')); - await expect(findByText(/Owned \(1\)/)).resolves.toBeInTheDocument(); - fireEvent.click(getByTestId('user-picker-all')); - await expect(findByText(/All \(2\)/)).resolves.toBeInTheDocument(); + await renderWrapped(); + fireEvent.click(screen.getByTestId('user-picker-owned')); + await expect(screen.findByText(/Owned \(1\)/)).resolves.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('user-picker-all')); + await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument(); }, 20_000); it('should set initial filter correctly', async () => { - const { findByText } = await renderWrapped( - , - ); - await expect(findByText(/All \(2\)/)).resolves.toBeInTheDocument(); + await renderWrapped(); + await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument(); }, 20_000); // this test is for fixing the bug after favoriting an entity, the matching // entities defaulting to "owned" filter and not based on the selected filter it('should render the correct entities filtered on the selected filter', async () => { - const { getByTestId } = await renderWrapped(); - fireEvent.click(getByTestId('user-picker-owned')); + await renderWrapped(); + fireEvent.click(screen.getByTestId('user-picker-owned')); await expect(screen.findByText(/Owned \(1\)/)).resolves.toBeInTheDocument(); // The "Starred" menu option should initially be disabled, since there // aren't any starred entities. @@ -308,10 +298,12 @@ describe('DefaultCatalogPage', () => { it('should wrap filter in drawer on smaller screens', async () => { mockBreakpoint({ matches: true }); - const { getByRole } = await renderWrapped(); - const button = getByRole('button', { name: 'Filters' }); - expect(getByRole('presentation', { hidden: true })).toBeInTheDocument(); + await renderWrapped(); + const button = screen.getByRole('button', { name: 'Filters' }); + expect( + screen.getByRole('presentation', { hidden: true }), + ).toBeInTheDocument(); fireEvent.click(button); - expect(getByRole('presentation')).toBeVisible(); + expect(screen.getByRole('presentation')).toBeVisible(); }, 20_000); }); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index bb2f302a37..652c9f3873 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -29,7 +29,7 @@ import { UserListFilter, } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; -import { act, fireEvent } from '@testing-library/react'; +import { act, fireEvent, screen } from '@testing-library/react'; import * as React from 'react'; import { CatalogTable } from './CatalogTable'; @@ -66,7 +66,7 @@ describe('CatalogTable component', () => { }); it('should render error message', async () => { - const rendered = await renderInTestApp( + await renderInTestApp( @@ -78,14 +78,14 @@ describe('CatalogTable component', () => { }, }, ); - const errorMessage = await rendered.findByText( + const errorMessage = await screen.findByText( /Could not fetch catalog entities./, ); expect(errorMessage).toBeInTheDocument(); }); it('should display entity names when loading has finished and no error occurred', async () => { - const rendered = await renderInTestApp( + await renderInTestApp( { }, }, ); - expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument(); - expect(rendered.getByText(/component1/)).toBeInTheDocument(); - expect(rendered.getByText(/component2/)).toBeInTheDocument(); - expect(rendered.getByText(/component3/)).toBeInTheDocument(); + expect(screen.getByText(/Owned \(3\)/)).toBeInTheDocument(); + expect(screen.getByText(/component1/)).toBeInTheDocument(); + expect(screen.getByText(/component2/)).toBeInTheDocument(); + expect(screen.getByText(/component3/)).toBeInTheDocument(); }); it('should use specified edit URL if in annotation', async () => { @@ -124,7 +124,7 @@ describe('CatalogTable component', () => { }, }; - const { getByTitle } = await renderInTestApp( + await renderInTestApp( @@ -137,7 +137,7 @@ describe('CatalogTable component', () => { }, ); - const editButton = getByTitle('Edit'); + const editButton = screen.getByTitle('Edit'); await act(async () => { fireEvent.click(editButton); @@ -156,7 +156,7 @@ describe('CatalogTable component', () => { }, }; - const { getByTitle } = await renderInTestApp( + await renderInTestApp( @@ -169,7 +169,7 @@ describe('CatalogTable component', () => { }, ); - const viewButton = getByTitle('View'); + const viewButton = screen.getByTitle('View'); await act(async () => { fireEvent.click(viewButton); @@ -278,7 +278,7 @@ describe('CatalogTable component', () => { ])( 'should render correct columns with kind filter $kind', async ({ kind, expectedColumns }) => { - const { getAllByRole } = await renderInTestApp( + await renderInTestApp( { }, ); - const columnHeader = getAllByRole('button').filter( - c => c.tagName === 'SPAN', - ); + const columnHeader = screen + .getAllByRole('button') + .filter(c => c.tagName === 'SPAN'); const columnHeaderLabels = columnHeader.map(c => c.textContent); expect(columnHeaderLabels).toEqual(expectedColumns); }, @@ -316,7 +316,7 @@ describe('CatalogTable component', () => { }, }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -329,7 +329,7 @@ describe('CatalogTable component', () => { }, ); - expect(getByText('Should be rendered')).toBeInTheDocument(); + expect(screen.getByText('Should be rendered')).toBeInTheDocument(); }); it('should render the label column with customised title and value as specified', async () => { @@ -347,7 +347,7 @@ describe('CatalogTable component', () => { }; const expectedColumns = ['Name', 'Category', 'Actions']; - const { getAllByRole, getByText } = await renderInTestApp( + await renderInTestApp( @@ -360,13 +360,13 @@ describe('CatalogTable component', () => { }, ); - const columnHeader = getAllByRole('button').filter( - c => c.tagName === 'SPAN', - ); + const columnHeader = screen + .getAllByRole('button') + .filter(c => c.tagName === 'SPAN'); const columnHeaderLabels = columnHeader.map(c => c.textContent); expect(columnHeaderLabels).toEqual(expectedColumns); - const labelCellValue = getByText('generic'); + const labelCellValue = screen.getByText('generic'); expect(labelCellValue).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx b/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx index b3f8262076..94a9a86b70 100644 --- a/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx +++ b/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx @@ -22,7 +22,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import React from 'react'; import { DependencyOfComponentsCard } from './DependencyOfComponentsCard'; @@ -51,7 +51,7 @@ describe('', () => { relations: [], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -64,9 +64,9 @@ describe('', () => { }, ); - expect(getByText('Dependency of components')).toBeInTheDocument(); + expect(screen.getByText('Dependency of components')).toBeInTheDocument(); expect( - getByText(/No component depends on this component/i), + screen.getByText(/No component depends on this component/i), ).toBeInTheDocument(); }); @@ -99,7 +99,7 @@ describe('', () => { ], }); - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -113,8 +113,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText('Dependency of components')).toBeInTheDocument(); - expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(screen.getByText('Dependency of components')).toBeInTheDocument(); + expect(screen.getByText(/target-name/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx index 57106015b2..657f95c60f 100644 --- a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx +++ b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx @@ -22,7 +22,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { DependsOnComponentsCard } from './DependsOnComponentsCard'; @@ -51,7 +51,7 @@ describe('', () => { relations: [], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -64,9 +64,9 @@ describe('', () => { }, ); - expect(getByText('Depends on components')).toBeInTheDocument(); + expect(screen.getByText('Depends on components')).toBeInTheDocument(); expect( - getByText(/No component is a dependency of this component/i), + screen.getByText(/No component is a dependency of this component/i), ).toBeInTheDocument(); }); @@ -99,7 +99,7 @@ describe('', () => { ], }); - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -113,8 +113,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText('Depends on components')).toBeInTheDocument(); - expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(screen.getByText('Depends on components')).toBeInTheDocument(); + expect(screen.getByText(/target-name/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx index bd52bfeeb8..5a77cd6ab4 100644 --- a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx +++ b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx @@ -22,7 +22,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { DependsOnResourcesCard } from './DependsOnResourcesCard'; @@ -51,7 +51,7 @@ describe('', () => { relations: [], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -64,9 +64,9 @@ describe('', () => { }, ); - expect(getByText('Depends on resources')).toBeInTheDocument(); + expect(screen.getByText('Depends on resources')).toBeInTheDocument(); expect( - getByText(/No resource is a dependency of this component/i), + screen.getByText(/No resource is a dependency of this component/i), ).toBeInTheDocument(); }); @@ -99,7 +99,7 @@ describe('', () => { ], }); - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -113,8 +113,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText('Depends on resources')).toBeInTheDocument(); - expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(screen.getByText('Depends on resources')).toBeInTheDocument(); + expect(screen.getByText(/target-name/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx index 04cfa1b947..f7220cb1ea 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx @@ -63,7 +63,7 @@ describe('ComponentContextMenu', () => { it('check Unregister entity button is disabled', async () => { const mockCallback = jest.fn(); - const { getByText } = await render( + await render( { expect(button).toBeInTheDocument(); fireEvent.click(button); - const unregister = await screen.getByText('Unregister entity'); + const unregister = screen.getByText('Unregister entity'); expect(unregister).toBeInTheDocument(); - const unregisterSpanItem = getByText(/Unregister entity/); + const unregisterSpanItem = screen.getByText(/Unregister entity/); const unregisterMenuListItem = unregisterSpanItem?.parentElement?.parentElement; expect(unregisterMenuListItem).toHaveAttribute('aria-disabled'); diff --git a/plugins/catalog/src/components/EntityContextMenu/UnregisterEntity.test.tsx b/plugins/catalog/src/components/EntityContextMenu/UnregisterEntity.test.tsx index 95cb601ea0..fd57b79431 100644 --- a/plugins/catalog/src/components/EntityContextMenu/UnregisterEntity.test.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/UnregisterEntity.test.tsx @@ -60,7 +60,7 @@ describe('ComponentContextMenu', () => { it('check Unregister entity button is disabled', async () => { const mockCallback = jest.fn(); - const { getByText } = await render( + await render( { />, ); - const unregister = await screen.getByText('Unregister entity'); + const unregister = screen.getByText('Unregister entity'); expect(unregister).toBeInTheDocument(); - const unregisterSpanItem = getByText(/Unregister entity/); + const unregisterSpanItem = screen.getByText(/Unregister entity/); const unregisterMenuListItem = unregisterSpanItem?.parentElement?.parentElement; expect(unregisterMenuListItem).toHaveAttribute('aria-disabled'); diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 548fff3ff1..980ce13109 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -32,7 +32,7 @@ import { renderInTestApp, TestApiRegistry, } from '@backstage/test-utils'; -import { act, fireEvent } from '@testing-library/react'; +import { act, fireEvent, screen } from '@testing-library/react'; import React from 'react'; import { EntityLayout } from './EntityLayout'; @@ -52,7 +52,7 @@ const mockApis = TestApiRegistry.from( describe('EntityLayout', () => { it('renders simplest case', async () => { - const rendered = await renderInTestApp( + await renderInTestApp( @@ -69,9 +69,9 @@ describe('EntityLayout', () => { }, ); - expect(rendered.getByText('my-entity')).toBeInTheDocument(); - expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); - expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + expect(screen.getByText('my-entity')).toBeInTheDocument(); + expect(screen.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(screen.getByText('tabbed-test-content')).toBeInTheDocument(); }); it('renders the entity title if defined', async () => { @@ -83,7 +83,7 @@ describe('EntityLayout', () => { }, } as Entity; - const rendered = await renderInTestApp( + await renderInTestApp( @@ -100,13 +100,13 @@ describe('EntityLayout', () => { }, ); - expect(rendered.getByText('My Entity')).toBeInTheDocument(); - expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); - expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); + expect(screen.getByText('My Entity')).toBeInTheDocument(); + expect(screen.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(screen.getByText('tabbed-test-content')).toBeInTheDocument(); }); it('renders default error message when entity is not found', async () => { - const rendered = await renderInTestApp( + await renderInTestApp( @@ -123,14 +123,14 @@ describe('EntityLayout', () => { }, ); - expect(rendered.getByText('Warning: Entity not found')).toBeInTheDocument(); - expect(rendered.queryByText('my-entity')).not.toBeInTheDocument(); - expect(rendered.queryByText('tabbed-test-title')).not.toBeInTheDocument(); - expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument(); + expect(screen.getByText('Warning: Entity not found')).toBeInTheDocument(); + expect(screen.queryByText('my-entity')).not.toBeInTheDocument(); + expect(screen.queryByText('tabbed-test-title')).not.toBeInTheDocument(); + expect(screen.queryByText('tabbed-test-content')).not.toBeInTheDocument(); }); it('renders custom message when entity is not found', async () => { - const rendered = await renderInTestApp( + await renderInTestApp( { ); expect( - rendered.getByText('Oppps.. Your entity was not found'), + screen.getByText('Oppps.. Your entity was not found'), ).toBeInTheDocument(); - expect(rendered.queryByText('my-entity')).not.toBeInTheDocument(); - expect(rendered.queryByText('tabbed-test-title')).not.toBeInTheDocument(); - expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument(); + expect(screen.queryByText('my-entity')).not.toBeInTheDocument(); + expect(screen.queryByText('tabbed-test-title')).not.toBeInTheDocument(); + expect(screen.queryByText('tabbed-test-content')).not.toBeInTheDocument(); }); it('navigates when user clicks different tab', async () => { - const rendered = await renderInTestApp( + await renderInTestApp( @@ -181,23 +181,23 @@ describe('EntityLayout', () => { }, ); - const secondTab = rendered.queryAllByRole('tab')[1]; + const secondTab = screen.queryAllByRole('tab')[1]; act(() => { fireEvent.click(secondTab); }); - expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); - expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument(); + expect(screen.getByText('tabbed-test-title')).toBeInTheDocument(); + expect(screen.queryByText('tabbed-test-content')).not.toBeInTheDocument(); - expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument(); - expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument(); + expect(screen.getByText('tabbed-test-title-2')).toBeInTheDocument(); + expect(screen.queryByText('tabbed-test-content-2')).toBeInTheDocument(); }); it('should conditionally render tabs', async () => { const shouldRenderTab = (e: Entity) => e.metadata.name === 'my-entity'; const shouldNotRenderTab = (e: Entity) => e.metadata.name === 'some-entity'; - const rendered = await renderInTestApp( + await renderInTestApp( @@ -228,8 +228,8 @@ describe('EntityLayout', () => { }, ); - expect(rendered.queryByText('tabbed-test-title')).toBeInTheDocument(); - expect(rendered.queryByText('tabbed-test-title-2')).not.toBeInTheDocument(); - expect(rendered.queryByText('tabbed-test-title-3')).toBeInTheDocument(); + expect(screen.queryByText('tabbed-test-title')).toBeInTheDocument(); + expect(screen.queryByText('tabbed-test-title-2')).not.toBeInTheDocument(); + expect(screen.queryByText('tabbed-test-title-3')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx index 8972cd2f9e..6316bdbc85 100644 --- a/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx +++ b/plugins/catalog/src/components/EntityLinksCard/EntityLinksCard.test.tsx @@ -17,6 +17,7 @@ import { Entity, EntityLink } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import React from 'react'; import { EntityLinksCard } from './EntityLinksCard'; @@ -43,7 +44,7 @@ describe('EntityLinksCard', () => { it('should render a link', async () => { const links: EntityLink[] = [createLink()]; - const { queryByText } = await renderWithEffects( + await renderWithEffects( wrapInTestApp( @@ -51,12 +52,12 @@ describe('EntityLinksCard', () => { ), ); - expect(queryByText('admin dashboard')).toBeInTheDocument(); - expect(queryByText('derp')).not.toBeInTheDocument(); + expect(screen.queryByText('admin dashboard')).toBeInTheDocument(); + expect(screen.queryByText('derp')).not.toBeInTheDocument(); }); it('should show empty state', async () => { - const { queryByText } = await renderWithEffects( + await renderWithEffects( wrapInTestApp( @@ -65,8 +66,8 @@ describe('EntityLinksCard', () => { ); expect( - queryByText(/.*No links defined for this entity.*/), + screen.queryByText(/.*No links defined for this entity.*/), ).toBeInTheDocument(); - expect(queryByText('admin dashboard')).not.toBeInTheDocument(); + expect(screen.queryByText('admin dashboard')).not.toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx b/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx index 9c1b021071..164e54a2eb 100644 --- a/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx +++ b/plugins/catalog/src/components/EntityLinksCard/IconLink.test.tsx @@ -17,13 +17,13 @@ import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import CloudIcon from '@material-ui/icons/Cloud'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import { IconLink } from './IconLink'; describe('IconLink', () => { it('should render an icon link', () => { - const rendered = render( + render( { , ); - expect(rendered.queryByText('I am Link')).toBeInTheDocument(); + expect(screen.queryByText('I am Link')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/EntityNotFound/EntityNotFound.test.tsx b/plugins/catalog/src/components/EntityNotFound/EntityNotFound.test.tsx index 4f16078eb9..6f214dc1e0 100644 --- a/plugins/catalog/src/components/EntityNotFound/EntityNotFound.test.tsx +++ b/plugins/catalog/src/components/EntityNotFound/EntityNotFound.test.tsx @@ -16,13 +16,16 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import { EntityNotFound } from './EntityNotFound'; describe('', () => { it('renders without exploding', async () => { - const { getByText } = await renderInTestApp(); - expect(getByText(/entity was not found/i)).toBeInTheDocument(); - expect(getByText(/getting started documentation/i)).toBeInTheDocument(); - expect(getByText(/docs/i)).toBeInTheDocument(); + await renderInTestApp(); + expect(screen.getByText(/entity was not found/i)).toBeInTheDocument(); + expect( + screen.getByText(/getting started documentation/i), + ).toBeInTheDocument(); + expect(screen.getByText(/docs/i)).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx index f00474868f..6071d15166 100644 --- a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx +++ b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx @@ -15,8 +15,8 @@ */ import { catalogApiRef, EntityProvider } from '@backstage/plugin-catalog-react'; - import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import React from 'react'; import { rootRouteRef } from '../../routes'; import { EntityOrphanWarning } from './EntityOrphanWarning'; @@ -39,7 +39,7 @@ describe('', () => { }, }; - const { getByText } = await renderInTestApp( + await renderInTestApp( ', () => { }, ); expect( - getByText( + screen.getByText( 'This entity is not referenced by any location and is therefore not receiving updates. Click here to delete.', ), ).toBeInTheDocument(); diff --git a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx index 2297493a7a..cfe3022261 100644 --- a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx +++ b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx @@ -23,6 +23,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import React from 'react'; import { EntityProcessingErrorsPanel } from './EntityProcessingErrorsPanel'; @@ -100,7 +101,7 @@ describe('', () => { rootEntityRef: stringifyEntityRef(entity), items: [{ entity, parentEntityRefs: [] }], }); - const { getByText, queryByText } = await renderInTestApp( + await renderInTestApp( @@ -109,14 +110,14 @@ describe('', () => { ); expect( - getByText( + screen.getByText( 'Error: Policy check failed; caused by Error: Malformed envelope, /metadata/labels should be object', ), ).toBeInTheDocument(); - expect(getByText('Error: Foo')).toBeInTheDocument(); - expect(queryByText('Error: This should not be rendered')).toBeNull(); + expect(screen.getByText('Error: Foo')).toBeInTheDocument(); + expect(screen.queryByText('Error: This should not be rendered')).toBeNull(); expect( - queryByText('The error below originates from'), + screen.queryByText('The error below originates from'), ).not.toBeInTheDocument(); }); @@ -204,7 +205,7 @@ describe('', () => { { entity: parent, parentEntityRefs: [] }, ], }); - const { getByText, queryByText } = await renderInTestApp( + await renderInTestApp( @@ -218,12 +219,14 @@ describe('', () => { ); expect( - getByText( + screen.getByText( 'Error: Policy check failed; caused by Error: Malformed envelope, /metadata/labels should be object', ), ).toBeInTheDocument(); - expect(getByText('Error: Foo')).toBeInTheDocument(); - expect(queryByText('Error: This should not be rendered')).toBeNull(); - expect(queryByText('The error below originates from')).toBeInTheDocument(); + expect(screen.getByText('Error: Foo')).toBeInTheDocument(); + expect(screen.queryByText('Error: This should not be rendered')).toBeNull(); + expect( + screen.queryByText('The error below originates from'), + ).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 4ee9d9c820..d2c51465b5 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -19,7 +19,7 @@ import { AsyncEntityProvider, EntityProvider, } from '@backstage/plugin-catalog-react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import { isKind } from './conditions'; import { EntitySwitch } from './EntitySwitch'; @@ -54,9 +54,9 @@ describe('EntitySwitch', () => { , ); - expect(rendered.queryByText('A')).toBeInTheDocument(); - expect(rendered.queryByText('B')).not.toBeInTheDocument(); - expect(rendered.queryByText('C')).not.toBeInTheDocument(); + expect(screen.queryByText('A')).toBeInTheDocument(); + expect(screen.queryByText('B')).not.toBeInTheDocument(); + expect(screen.queryByText('C')).not.toBeInTheDocument(); rendered.rerender( @@ -68,9 +68,9 @@ describe('EntitySwitch', () => { , ); - expect(rendered.queryByText('A')).not.toBeInTheDocument(); - expect(rendered.queryByText('B')).toBeInTheDocument(); - expect(rendered.queryByText('C')).not.toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + expect(screen.queryByText('B')).toBeInTheDocument(); + expect(screen.queryByText('C')).not.toBeInTheDocument(); rendered.rerender( @@ -82,9 +82,9 @@ describe('EntitySwitch', () => { , ); - expect(rendered.queryByText('A')).not.toBeInTheDocument(); - expect(rendered.queryByText('B')).not.toBeInTheDocument(); - expect(rendered.queryByText('C')).toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + expect(screen.queryByText('B')).not.toBeInTheDocument(); + expect(screen.queryByText('C')).toBeInTheDocument(); rendered.rerender( @@ -94,9 +94,9 @@ describe('EntitySwitch', () => { , ); - expect(rendered.queryByText('A')).not.toBeInTheDocument(); - expect(rendered.queryByText('B')).not.toBeInTheDocument(); - expect(rendered.queryByText('C')).toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + expect(screen.queryByText('B')).not.toBeInTheDocument(); + expect(screen.queryByText('C')).toBeInTheDocument(); }); it('should switch child when filters switch', () => { @@ -113,8 +113,8 @@ describe('EntitySwitch', () => { , ); - expect(rendered.queryByText('A')).toBeInTheDocument(); - expect(rendered.queryByText('B')).not.toBeInTheDocument(); + expect(screen.queryByText('A')).toBeInTheDocument(); + expect(screen.queryByText('B')).not.toBeInTheDocument(); rendered.rerender( @@ -127,15 +127,15 @@ describe('EntitySwitch', () => { , ); - expect(rendered.queryByText('A')).not.toBeInTheDocument(); - expect(rendered.queryByText('B')).toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + expect(screen.queryByText('B')).toBeInTheDocument(); }); it('should switch with async condition that is true', async () => { const entity = { metadata: { name: 'mock' }, kind: 'component' } as Entity; const shouldRender = () => Promise.resolve(true); - const rendered = render( + render( @@ -146,15 +146,15 @@ describe('EntitySwitch', () => { , ); - await expect(rendered.findByText('A')).resolves.toBeInTheDocument(); - expect(rendered.queryByText('B')).not.toBeInTheDocument(); + await expect(screen.findByText('A')).resolves.toBeInTheDocument(); + expect(screen.queryByText('B')).not.toBeInTheDocument(); }); it('should switch with sync condition that is false', async () => { const entity = { metadata: { name: 'mock' }, kind: 'component' } as Entity; const shouldRender = () => Promise.resolve(false); - const rendered = render( + render( @@ -165,15 +165,15 @@ describe('EntitySwitch', () => { , ); - await expect(rendered.findByText('B')).resolves.toBeInTheDocument(); - expect(rendered.queryByText('A')).not.toBeInTheDocument(); + await expect(screen.findByText('B')).resolves.toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); }); it('should switch with sync condition that throws', async () => { const entity = { metadata: { name: 'mock' }, kind: 'component' } as Entity; const shouldRender = () => Promise.reject(); - const rendered = render( + render( @@ -185,8 +185,8 @@ describe('EntitySwitch', () => { , ); - await expect(rendered.findByText('C')).resolves.toBeInTheDocument(); - expect(rendered.queryByText('A')).not.toBeInTheDocument(); - expect(rendered.queryByText('B')).not.toBeInTheDocument(); + await expect(screen.findByText('C')).resolves.toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + expect(screen.queryByText('B')).not.toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx index 77fdb74f2f..9d46f2ed42 100644 --- a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx @@ -22,7 +22,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { HasComponentsCard } from './HasComponentsCard'; @@ -51,7 +51,7 @@ describe('', () => { relations: [], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -64,9 +64,9 @@ describe('', () => { }, ); - expect(getByText('Has components')).toBeInTheDocument(); + expect(screen.getByText('Has components')).toBeInTheDocument(); expect( - getByText(/No component is part of this system/i), + screen.getByText(/No component is part of this system/i), ).toBeInTheDocument(); }); @@ -99,7 +99,7 @@ describe('', () => { ], }); - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -113,8 +113,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText('Has components')).toBeInTheDocument(); - expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(screen.getByText('Has components')).toBeInTheDocument(); + expect(screen.getByText(/target-name/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx index 20830d2ccd..364d503a96 100644 --- a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx +++ b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx @@ -22,7 +22,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { HasResourcesCard } from './HasResourcesCard'; @@ -51,7 +51,7 @@ describe('', () => { relations: [], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -59,9 +59,9 @@ describe('', () => { , ); - expect(getByText('Has resources')).toBeInTheDocument(); + expect(screen.getByText('Has resources')).toBeInTheDocument(); expect( - getByText(/No resource is part of this system/i), + screen.getByText(/No resource is part of this system/i), ).toBeInTheDocument(); }); @@ -94,7 +94,7 @@ describe('', () => { ], }); - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -108,8 +108,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText('Has resources')).toBeInTheDocument(); - expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(screen.getByText('Has resources')).toBeInTheDocument(); + expect(screen.getByText(/target-name/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx index 7fd0273939..b76a0a68ec 100644 --- a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx @@ -22,7 +22,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { HasSubcomponentsCard } from './HasSubcomponentsCard'; @@ -51,7 +51,7 @@ describe('', () => { relations: [], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -64,9 +64,9 @@ describe('', () => { }, ); - expect(getByText('Has subcomponents')).toBeInTheDocument(); + expect(screen.getByText('Has subcomponents')).toBeInTheDocument(); expect( - getByText(/No subcomponent is part of this component/i), + screen.getByText(/No subcomponent is part of this component/i), ).toBeInTheDocument(); }); @@ -99,7 +99,7 @@ describe('', () => { ], }); - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -113,8 +113,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText('Has subcomponents')).toBeInTheDocument(); - expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(screen.getByText('Has subcomponents')).toBeInTheDocument(); + expect(screen.getByText(/target-name/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx index cd11e4d3f3..634fe57e82 100644 --- a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx @@ -22,7 +22,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { HasSystemsCard } from './HasSystemsCard'; @@ -51,7 +51,7 @@ describe('', () => { relations: [], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -64,8 +64,10 @@ describe('', () => { }, ); - expect(getByText('Has systems')).toBeInTheDocument(); - expect(getByText(/No system is part of this domain/i)).toBeInTheDocument(); + expect(screen.getByText('Has systems')).toBeInTheDocument(); + expect( + screen.getByText(/No system is part of this domain/i), + ).toBeInTheDocument(); }); it('shows related systems', async () => { @@ -97,7 +99,7 @@ describe('', () => { ], }); - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -111,8 +113,8 @@ describe('', () => { ); await waitFor(() => { - expect(getByText('Has systems')).toBeInTheDocument(); - expect(getByText(/target-name/i)).toBeInTheDocument(); + expect(screen.getByText('Has systems')).toBeInTheDocument(); + expect(screen.getByText(/target-name/i)).toBeInTheDocument(); }); }); }); diff --git a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx index 18b45a84d4..dc8d68e2b2 100644 --- a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx +++ b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx @@ -22,6 +22,7 @@ import { } from '@backstage/plugin-catalog-react'; import { Entity, RELATION_PART_OF } from '@backstage/catalog-model'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; import React from 'react'; import { SystemDiagramCard } from './SystemDiagramCard'; @@ -53,7 +54,7 @@ describe('', () => { relations: [], }; - const { queryByText } = await renderInTestApp( + await renderInTestApp( @@ -66,9 +67,9 @@ describe('', () => { }, ); - expect(queryByText(/System Diagram/)).toBeInTheDocument(); - expect(queryByText(/namespace2\/system2/)).toBeInTheDocument(); - expect(queryByText(/namespace\/entity/)).not.toBeInTheDocument(); + expect(screen.queryByText(/System Diagram/)).toBeInTheDocument(); + expect(screen.queryByText(/namespace2\/system2/)).toBeInTheDocument(); + expect(screen.queryByText(/namespace\/entity/)).not.toBeInTheDocument(); }); it('shows related systems', async () => { @@ -108,7 +109,7 @@ describe('', () => { ], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -121,9 +122,9 @@ describe('', () => { }, ); - expect(getByText('System Diagram')).toBeInTheDocument(); - expect(getByText('namespace/system')).toBeInTheDocument(); - expect(getByText('namespace/entity')).toBeInTheDocument(); + expect(screen.getByText('System Diagram')).toBeInTheDocument(); + expect(screen.getByText('namespace/system')).toBeInTheDocument(); + expect(screen.getByText('namespace/entity')).toBeInTheDocument(); }); it('should truncate long domains, systems or entities', async () => { @@ -163,7 +164,7 @@ describe('', () => { ], }; - const { getByText } = await renderInTestApp( + await renderInTestApp( @@ -176,8 +177,8 @@ describe('', () => { }, ); - expect(getByText('namespace/alongdomai...')).toBeInTheDocument(); - expect(getByText('namespace/alongsyste...')).toBeInTheDocument(); - expect(getByText('namespace/alongentit...')).toBeInTheDocument(); + expect(screen.getByText('namespace/alongdomai...')).toBeInTheDocument(); + expect(screen.getByText('namespace/alongsyste...')).toBeInTheDocument(); + expect(screen.getByText('namespace/alongentit...')).toBeInTheDocument(); }); }); diff --git a/plugins/codescene/package.json b/plugins/codescene/package.json index f18267e5bc..42e4b6ecf1 100644 --- a/plugins/codescene/package.json +++ b/plugins/codescene/package.json @@ -30,7 +30,7 @@ "@material-ui/core": "^4.9.10", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.57", - "rc-progress": "3.4.0", + "rc-progress": "3.4.1", "react-use": "^17.2.4" }, "peerDependencies": { diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json index a7dc17deb3..c2ed4e49f7 100644 --- a/plugins/config-schema/package.json +++ b/plugins/config-schema/package.json @@ -34,7 +34,7 @@ "@material-ui/lab": "4.0.0-alpha.57", "jsonschema": "^1.2.6", "react-use": "^17.2.4", - "zen-observable": "^0.8.15" + "zen-observable": "^0.9.0" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0" diff --git a/plugins/dynatrace/src/components/Synthetics/SyntheticsLocation/SyntheticsLocation.test.tsx b/plugins/dynatrace/src/components/Synthetics/SyntheticsLocation/SyntheticsLocation.test.tsx index de04f73e36..6d772efc9b 100644 --- a/plugins/dynatrace/src/components/Synthetics/SyntheticsLocation/SyntheticsLocation.test.tsx +++ b/plugins/dynatrace/src/components/Synthetics/SyntheticsLocation/SyntheticsLocation.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { SyntheticsLocation } from './SyntheticsLocation'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; @@ -60,6 +61,6 @@ describe('SyntheticsLocation', () => { , ); expect(await rendered.findByText(/__location__/)).toBeInTheDocument(); - expect(await rendered.queryByText(/failed/)).not.toBeInTheDocument(); + expect(rendered.queryByText(/failed/)).not.toBeInTheDocument(); }); }); diff --git a/plugins/events-backend/README.md b/plugins/events-backend/README.md index f71df21517..cef8ac7330 100644 --- a/plugins/events-backend/README.md +++ b/plugins/events-backend/README.md @@ -170,8 +170,8 @@ const http = HttpPostIngressEventPublisher.fromConfig({ }, }, logger: env.logger, - router: httpRouter, }); +http.bind(router); await new EventsBackend(env.logger) .addPublishers(http) diff --git a/plugins/events-backend/api-report.md b/plugins/events-backend/api-report.md index 4154168fa1..69d7600ed3 100644 --- a/plugins/events-backend/api-report.md +++ b/plugins/events-backend/api-report.md @@ -33,6 +33,8 @@ export const eventsPlugin: (options?: undefined) => BackendFeature; // @public export class HttpPostIngressEventPublisher implements EventPublisher { + // (undocumented) + bind(router: express.Router): void; // (undocumented) static fromConfig(env: { config: Config; @@ -40,7 +42,6 @@ export class HttpPostIngressEventPublisher implements EventPublisher { [topic: string]: Omit; }; logger: Logger; - router: express.Router; }): HttpPostIngressEventPublisher; // (undocumented) setEventBroker(eventBroker: EventBroker): Promise; diff --git a/plugins/events-backend/src/service/EventsPlugin.ts b/plugins/events-backend/src/service/EventsPlugin.ts index d5a38a7fd7..65e7770d87 100644 --- a/plugins/events-backend/src/service/EventsPlugin.ts +++ b/plugins/events-backend/src/service/EventsPlugin.ts @@ -90,14 +90,11 @@ export const eventsPlugin = createBackendPlugin({ env.registerInit({ deps: { config: configServiceRef, - httpRouter: httpRouterServiceRef, logger: loggerServiceRef, + router: httpRouterServiceRef, }, - async init({ config, httpRouter, logger }) { + async init({ config, logger, router }) { const winstonLogger = loggerToWinstonLogger(logger); - const eventsRouter = Router(); - const router = Router(); - eventsRouter.use('/http', router); const ingresses = Object.fromEntries( extensionPoint.httpPostIngresses.map(ingress => [ @@ -108,23 +105,20 @@ export const eventsPlugin = createBackendPlugin({ const http = HttpPostIngressEventPublisher.fromConfig({ config, - logger: winstonLogger, - router, ingresses, + logger: winstonLogger, }); + const eventsRouter = Router(); + http.bind(eventsRouter); + router.use(eventsRouter); - if (!extensionPoint.eventBroker) { - extensionPoint.setEventBroker(new InMemoryEventBroker(winstonLogger)); - } + const eventBroker = + extensionPoint.eventBroker ?? new InMemoryEventBroker(winstonLogger); - extensionPoint.eventBroker!.subscribe(extensionPoint.subscribers); + eventBroker.subscribe(extensionPoint.subscribers); [extensionPoint.publishers, http] .flat() - .forEach(publisher => - publisher.setEventBroker(extensionPoint.eventBroker!), - ); - - httpRouter.use(eventsRouter); + .forEach(publisher => publisher.setEventBroker(eventBroker)); }, }); }, diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts index 3d93e347b3..28f3ddcac4 100644 --- a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { errorHandler, getVoidLogger } from '@backstage/backend-common'; +import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; import express from 'express'; @@ -35,37 +35,35 @@ describe('HttpPostIngressEventPublisher', () => { }); const router = Router(); - router.use(express.json()); - router.use(errorHandler()); const app = express().use(router); const publisher = HttpPostIngressEventPublisher.fromConfig({ config, - logger, - router, ingresses: { testB: {}, }, + logger, }); + publisher.bind(router); const eventBroker = new TestEventBroker(); await publisher.setEventBroker(eventBroker); const notFoundResponse = await request(app) - .post('/unknown') + .post('/http/unknown') .timeout(100) .send({ test: 'data' }); expect(notFoundResponse.status).toBe(404); const response1 = await request(app) - .post('/testA') + .post('/http/testA') .set('X-Custom-Header', 'test-value') .timeout(100) .send({ testA: 'data' }); expect(response1.status).toBe(202); const response2 = await request(app) - .post('/testB') + .post('/http/testB') .set('X-Custom-Header', 'test-value') .timeout(100) .send({ testB: 'data' }); @@ -100,14 +98,10 @@ describe('HttpPostIngressEventPublisher', () => { }); const router = Router(); - router.use(express.json()); - router.use(errorHandler()); const app = express().use(router); const publisher = HttpPostIngressEventPublisher.fromConfig({ config, - logger, - router, ingresses: { testB: { validator: async (req, context) => { @@ -148,26 +142,28 @@ describe('HttpPostIngressEventPublisher', () => { }, }, }, + logger, }); + publisher.bind(router); const eventBroker = new TestEventBroker(); await publisher.setEventBroker(eventBroker); const response1 = await request(app) - .post('/testA') + .post('/http/testA') .timeout(100) .send({ test: 'data' }); expect(response1.status).toBe(202); const response2 = await request(app) - .post('/testB') + .post('/http/testB') .timeout(100) .send({ test: 'data' }); expect(response2.status).toBe(400); expect(response2.body).toEqual({ message: 'wrong signature' }); const response3 = await request(app) - .post('/testB') + .post('/http/testB') .set('X-Test-Signature', 'wrong') .timeout(100) .send({ test: 'data' }); @@ -175,21 +171,21 @@ describe('HttpPostIngressEventPublisher', () => { expect(response3.body).toEqual({ message: 'wrong signature' }); const response4 = await request(app) - .post('/testB') + .post('/http/testB') .set('X-Test-Signature', 'testB-signature') .timeout(100) .send({ test: 'data' }); expect(response4.status).toBe(202); const response5 = await request(app) - .post('/testC') + .post('/http/testC') .timeout(100) .send({ test: 'data' }); expect(response5.status).toBe(404); expect(response5.body).toEqual({}); const response6 = await request(app) - .post('/testD') + .post('/http/testD') .timeout(100) .send({ test: 'data' }); expect(response6.status).toBe(403); @@ -210,15 +206,10 @@ describe('HttpPostIngressEventPublisher', () => { it('without configuration', async () => { const config = new ConfigReader({}); - const router = Router(); - router.use(express.json()); - router.use(errorHandler()); - expect(() => HttpPostIngressEventPublisher.fromConfig({ config, logger, - router, }), ).not.toThrow(); }); diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts index 64b7140b54..b5a6ccbca1 100644 --- a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts @@ -41,7 +41,6 @@ export class HttpPostIngressEventPublisher implements EventPublisher { config: Config; ingresses?: { [topic: string]: Omit }; logger: Logger; - router: express.Router; }): HttpPostIngressEventPublisher { const topics = env.config.getOptionalStringArray('events.http.topics') ?? []; @@ -55,15 +54,18 @@ export class HttpPostIngressEventPublisher implements EventPublisher { } }); - return new HttpPostIngressEventPublisher(env.logger, env.router, ingresses); + return new HttpPostIngressEventPublisher(env.logger, ingresses); } private constructor( - private logger: Logger, - router: express.Router, - ingresses: { [topic: string]: Omit }, - ) { - router.use(this.createRouter(ingresses)); + private readonly logger: Logger, + private readonly ingresses: { + [topic: string]: Omit; + }, + ) {} + + bind(router: express.Router): void { + router.use('/http', this.createRouter(this.ingresses)); } async setEventBroker(eventBroker: EventBroker): Promise { @@ -92,8 +94,12 @@ export class HttpPostIngressEventPublisher implements EventPublisher { const path = `/${topic}`; router.post(path, async (request, response) => { + const requestDetails = { + body: request.body, + headers: request.headers, + }; const context = new RequestValidationContextImpl(); - await validator?.(request, context); + await validator?.(requestDetails, context); if (context.wasRejected()) { response .status(context.rejectionDetails!.status) diff --git a/plugins/events-node/api-report.md b/plugins/events-node/api-report.md index 1054211cbc..51136fd301 100644 --- a/plugins/events-node/api-report.md +++ b/plugins/events-node/api-report.md @@ -4,7 +4,6 @@ ```ts import { ExtensionPoint } from '@backstage/backend-plugin-api'; -import { Request as Request_2 } from 'express'; // @public export interface EventBroker { @@ -74,6 +73,12 @@ export interface HttpPostIngressOptions { validator?: RequestValidator; } +// @public (undocumented) +export interface RequestDetails { + body: unknown; + headers: Record; +} + // @public export interface RequestRejectionDetails { // (undocumented) @@ -89,7 +94,7 @@ export interface RequestValidationContext { // @public export type RequestValidator = ( - request: Request_2, + request: RequestDetails, context: RequestValidationContext, ) => Promise; diff --git a/plugins/events-node/package.json b/plugins/events-node/package.json index d193f33b00..978037f8ff 100644 --- a/plugins/events-node/package.json +++ b/plugins/events-node/package.json @@ -24,9 +24,7 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/backend-plugin-api": "workspace:^", - "@types/express": "^4.17.6", - "express": "^4.17.1" + "@backstage/backend-plugin-api": "workspace:^" }, "devDependencies": { "@backstage/cli": "workspace:^" diff --git a/plugins/events-node/src/api/http/validation/RequestDetails.ts b/plugins/events-node/src/api/http/validation/RequestDetails.ts new file mode 100644 index 0000000000..83c2669503 --- /dev/null +++ b/plugins/events-node/src/api/http/validation/RequestDetails.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +/** + * @public + */ +export interface RequestDetails { + /** + * Request body. JSON payloads have been parsed already. + */ + body: unknown; + /** + * Key-value pairs of header names and values. Header names are lower-cased. + */ + headers: Record; +} diff --git a/plugins/events-node/src/api/http/validation/RequestValidator.ts b/plugins/events-node/src/api/http/validation/RequestValidator.ts index b419b855cf..3008db03ae 100644 --- a/plugins/events-node/src/api/http/validation/RequestValidator.ts +++ b/plugins/events-node/src/api/http/validation/RequestValidator.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Request } from 'express'; +import { RequestDetails } from './RequestDetails'; import { RequestValidationContext } from './RequestValidationContext'; /** @@ -29,6 +29,6 @@ import { RequestValidationContext } from './RequestValidationContext'; * @public */ export type RequestValidator = ( - request: Request, + request: RequestDetails, context: RequestValidationContext, ) => Promise; diff --git a/plugins/events-node/src/api/http/validation/index.ts b/plugins/events-node/src/api/http/validation/index.ts index 95f2d474eb..11f2e9f1f6 100644 --- a/plugins/events-node/src/api/http/validation/index.ts +++ b/plugins/events-node/src/api/http/validation/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export type { RequestDetails } from './RequestDetails'; export type { RequestRejectionDetails } from './RequestRejectionDetails'; export type { RequestValidationContext } from './RequestValidationContext'; export type { RequestValidator } from './RequestValidator'; diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx index 23553b8b76..674ef1e43f 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx @@ -43,8 +43,10 @@ import useAsync from 'react-use/lib/useAsync'; const useStyles = makeStyles((theme: BackstageTheme) => ({ graph: { - flex: 1, - minHeight: 0, + minHeight: '100%', + }, + graphWrapper: { + height: '100%', }, organizationNode: { fill: theme.palette.secondary.light, @@ -62,6 +64,15 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({ justifyContent: 'center', color: 'black', }, + legend: { + position: 'absolute', + bottom: 0, + right: 0, + padding: theme.spacing(1), + '& .icon': { + verticalAlign: 'bottom', + }, + }, textOrganization: { color: theme.palette.secondary.contrastText, }, @@ -221,7 +232,7 @@ export function GroupsDiagram() { } return ( - <> +
+ - Use pinch & zoom - to move around the diagram. + Use pinch & zoom to move around the + diagram. - +
); } diff --git a/plugins/gocd/src/components/Select/Select.test.tsx b/plugins/gocd/src/components/Select/Select.test.tsx index fb467b946f..9d73194ed8 100644 --- a/plugins/gocd/src/components/Select/Select.test.tsx +++ b/plugins/gocd/src/components/Select/Select.test.tsx @@ -40,7 +40,7 @@ describe('Select', () => { expect(rendered.getAllByText(testLabel)).toHaveLength(2); }); - describe('when the user hasn`t clicked on it', () => { + describe("when the user hasn't clicked on it", () => { it('should only render the current select item', async () => { const rendered = await renderInTestApp( ', () => { it('renders nothing', async () => { const { queryByTestId } = subject(); - expect(await queryByTestId('AuditListTable')).toBeNull(); + expect(queryByTestId('AuditListTable')).toBeNull(); }); }); @@ -137,7 +137,7 @@ describe('', () => { it('renders nothing', async () => { const { queryByTestId } = subject(); - expect(await queryByTestId('AuditListTable')).toBeNull(); + expect(queryByTestId('AuditListTable')).toBeNull(); }); }); }); diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx index 58a3033a1f..ab1e2683b6 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx @@ -151,10 +151,10 @@ describe('', () => { it('renders nothing', async () => { const { queryByTestId } = subject(); - expect(await queryByTestId('AuditListTable')).toBeNull(); + expect(queryByTestId('AuditListTable')).toBeNull(); }); }); - // + describe('where there is no data', () => { beforeEach(() => { (useWebsiteForEntity as jest.Mock).mockReturnValue({ @@ -166,7 +166,7 @@ describe('', () => { it('renders nothing', async () => { const { queryByTestId } = subject(); - expect(await queryByTestId('AuditListTable')).toBeNull(); + expect(queryByTestId('AuditListTable')).toBeNull(); }); }); }); diff --git a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.test.tsx b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.test.tsx index b8a499b8fe..21b85ffe5c 100644 --- a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.test.tsx +++ b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.test.tsx @@ -15,7 +15,8 @@ */ import React from 'react'; -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ApiProvider } from '@backstage/core-app-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CatalogApi } from '@backstage/catalog-client'; @@ -74,13 +75,13 @@ describe('', () => {
, ); - fireEvent.click(getByTestId('group-list-picker-button')); + await userEvent.click(getByTestId('group-list-picker-button')); const input = getByTestId('group-list-picker-input').querySelector('input'); - fireEvent.change(input as HTMLElement, { target: { value: 'GR' } }); + await userEvent.type(input as HTMLElement, 'GR'); await waitFor(async () => { expect(getByText('Group A')).toBeInTheDocument(); - fireEvent.click(getByText('Group A')); + await userEvent.click(getByText('Group A')); expect(getByText('Group A')).toBeInTheDocument(); }); }); diff --git a/plugins/pagerduty/src/api/client.ts b/plugins/pagerduty/src/api/client.ts index 7266d5e9eb..7d99be219d 100644 --- a/plugins/pagerduty/src/api/client.ts +++ b/plugins/pagerduty/src/api/client.ts @@ -73,7 +73,7 @@ export class PagerDutyClient implements PagerDutyApi { url = `${await this.config.discoveryApi.getBaseUrl( 'proxy', )}/pagerduty/services?${commonGetServiceParams}&query=${integrationKey}`; - const { services } = await this.getByUrl(url); + const { services } = await this.findByUrl(url); const service = services[0]; if (!service) throw new NotFoundError(); @@ -84,7 +84,7 @@ export class PagerDutyClient implements PagerDutyApi { 'proxy', )}/pagerduty/services/${serviceId}?${commonGetServiceParams}`; - response = await this.getByUrl(url); + response = await this.findByUrl(url); } else { throw new NotFoundError(); } @@ -100,7 +100,7 @@ export class PagerDutyClient implements PagerDutyApi { 'proxy', )}/pagerduty/incidents?${params}`; - return await this.getByUrl(url); + return await this.findByUrl(url); } async getChangeEventsByServiceId( @@ -111,7 +111,7 @@ export class PagerDutyClient implements PagerDutyApi { 'proxy', )}/pagerduty/services/${serviceId}/change_events?${params}`; - return await this.getByUrl(url); + return await this.findByUrl(url); } async getOnCallByPolicyId( @@ -122,7 +122,7 @@ export class PagerDutyClient implements PagerDutyApi { 'proxy', )}/pagerduty/oncalls?${params}`; - return await this.getByUrl(url); + return await this.findByUrl(url); } triggerAlarm(request: PagerDutyTriggerAlarmRequest): Promise { @@ -158,7 +158,7 @@ export class PagerDutyClient implements PagerDutyApi { return this.request(`${url}/enqueue`, options); } - private async getByUrl(url: string): Promise { + private async findByUrl(url: string): Promise { const options = { method: 'GET', headers: { diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index dcca4d382e..d5371053ca 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -77,7 +77,7 @@ "vm2": "^3.9.11", "winston": "^3.2.1", "yaml": "^2.0.0", - "zen-observable": "^0.8.15", + "zen-observable": "^0.9.0", "zod": "^3.11.6" }, "devDependencies": { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts index 2891ea51cf..fe29378e2e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Config } from '@backstage/config'; import { assertError, InputError } from '@backstage/errors'; import { @@ -124,6 +125,7 @@ export async function createGithubRepoWithCollaboratorsAndTopics( topics: string[] | undefined, logger: Logger, ) { + // eslint-disable-next-line testing-library/no-await-sync-query const user = await client.rest.users.getByUsername({ username: owner, }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index eff7e95c50..634e344a2c 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -23,9 +23,10 @@ import { stringifyEntityRef, UserEntity, } from '@backstage/catalog-model'; -import { Config, JsonObject, JsonValue } from '@backstage/config'; +import { Config } from '@backstage/config'; import { InputError, NotFoundError, stringifyError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; +import { JsonObject, JsonValue } from '@backstage/types'; import { TaskSpec, TemplateEntityV1beta3, diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 27eb863a16..33a5f8c2fe 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -11,6 +11,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import type { ErrorTransformer } from '@rjsf/utils'; import { Extension } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FetchApi } from '@backstage/core-plugin-api'; @@ -35,6 +36,7 @@ import { TaskStep } from '@backstage/plugin-scaffolder-common'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { UIOptionsType } from '@rjsf/utils'; import { UiSchema } from '@rjsf/utils'; +import { z } from 'zod'; // @alpha export function createNextScaffolderFieldExtension< @@ -57,6 +59,12 @@ export function createScaffolderLayout( options: LayoutOptions, ): Extension>; +// @public +export type CustomFieldExtensionSchema = { + returnValue: JSONSchema7; + uiOptions?: JSONSchema7; +}; + // @public export type CustomFieldValidator = ( data: TFieldReturnValue, @@ -75,36 +83,52 @@ export const EntityNamePickerFieldExtension: FieldExtensionComponent< // @public export const EntityPickerFieldExtension: FieldExtensionComponent< string, - EntityPickerUiOptions + { + defaultKind?: string | undefined; + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } +>; + +// @public (undocumented) +export const EntityPickerFieldSchema: FieldSchema< + string, + { + defaultKind?: string | undefined; + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } >; // @public -export interface EntityPickerUiOptions { - // (undocumented) - allowArbitraryValues?: boolean; - // (undocumented) - allowedKinds?: string[]; - // (undocumented) - defaultKind?: string; - // (undocumented) - defaultNamespace?: string | false; -} +export type EntityPickerUiOptions = + typeof EntityPickerFieldSchema.uiOptionsType; // @public export const EntityTagsPickerFieldExtension: FieldExtensionComponent< string[], - EntityTagsPickerUiOptions + { + showCounts?: boolean | undefined; + kinds?: string[] | undefined; + helperText?: string | undefined; + } +>; + +// @public (undocumented) +export const EntityTagsPickerFieldSchema: FieldSchema< + string[], + { + showCounts?: boolean | undefined; + kinds?: string[] | undefined; + helperText?: string | undefined; + } >; // @public -export interface EntityTagsPickerUiOptions { - // (undocumented) - helperText?: string; - // (undocumented) - kinds?: string[]; - // (undocumented) - showCounts?: boolean; -} +export type EntityTagsPickerUiOptions = + typeof EntityTagsPickerFieldSchema.uiOptionsType; // @public export type FieldExtensionComponent<_TReturnValue, _TInputProps> = () => null; @@ -130,8 +154,19 @@ export type FieldExtensionOptions< props: FieldExtensionComponentProps, ) => JSX.Element | null; validation?: CustomFieldValidator; + schema?: CustomFieldExtensionSchema; }; +// @public +export interface FieldSchema { + // (undocumented) + readonly schema: CustomFieldExtensionSchema; + // (undocumented) + readonly type: FieldExtensionComponentProps; + // (undocumented) + readonly uiOptionsType: TUiOptions; +} + // @public export type LayoutComponent<_TInputProps> = () => null; @@ -169,6 +204,20 @@ export type LogEvent = { taskId: string; }; +// @public +export function makeFieldSchemaFromZod< + TReturnSchema extends z.ZodType, + TUiOptionsSchema extends z.ZodType = z.ZodType, +>( + returnSchema: TReturnSchema, + uiOptionsSchema?: TUiOptionsSchema, +): FieldSchema< + TReturnSchema extends z.ZodType ? IReturn : never, + TUiOptionsSchema extends z.ZodType + ? IUiOptions + : never +>; + // @alpha export type NextCustomFieldValidator = ( data: TFieldReturnValue, @@ -199,6 +248,7 @@ export type NextFieldExtensionOptions< props: NextFieldExtensionComponentProps, ) => JSX.Element | null; validation?: NextCustomFieldValidator; + schema?: CustomFieldExtensionSchema; }; // @alpha (undocumented) @@ -213,6 +263,7 @@ export type NextRouterProps = { TaskPageComponent?: React_2.ComponentType<{}>; }; groups?: TemplateGroupFilter[]; + transformErrors?: ErrorTransformer; }; // @alpha @@ -228,36 +279,51 @@ export const nextSelectedTemplateRouteRef: SubRouteRef< // @public export const OwnedEntityPickerFieldExtension: FieldExtensionComponent< string, - OwnedEntityPickerUiOptions + { + defaultKind?: string | undefined; + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } +>; + +// @public (undocumented) +export const OwnedEntityPickerFieldSchema: FieldSchema< + string, + { + defaultKind?: string | undefined; + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } >; // @public -export interface OwnedEntityPickerUiOptions { - // (undocumented) - allowArbitraryValues?: boolean; - // (undocumented) - allowedKinds?: string[]; - // (undocumented) - defaultKind?: string; - // (undocumented) - defaultNamespace?: string | false; -} +export type OwnedEntityPickerUiOptions = + typeof OwnedEntityPickerFieldSchema.uiOptionsType; // @public export const OwnerPickerFieldExtension: FieldExtensionComponent< string, - OwnerPickerUiOptions + { + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } +>; + +// @public (undocumented) +export const OwnerPickerFieldSchema: FieldSchema< + string, + { + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } >; // @public -export interface OwnerPickerUiOptions { - // (undocumented) - allowArbitraryValues?: boolean; - // (undocumented) - allowedKinds?: string[]; - // (undocumented) - defaultNamespace?: string | false; -} +export type OwnerPickerUiOptions = typeof OwnerPickerFieldSchema.uiOptionsType; // @public export const repoPickerValidation: ( @@ -271,31 +337,56 @@ export const repoPickerValidation: ( // @public export const RepoUrlPickerFieldExtension: FieldExtensionComponent< string, - RepoUrlPickerUiOptions + { + allowedOwners?: string[] | undefined; + allowedOrganizations?: string[] | undefined; + allowedRepos?: string[] | undefined; + allowedHosts?: string[] | undefined; + requestUserCredentials?: + | { + additionalScopes?: + | { + azure?: string[] | undefined; + github?: string[] | undefined; + gitlab?: string[] | undefined; + bitbucket?: string[] | undefined; + gerrit?: string[] | undefined; + } + | undefined; + secretsKey: string; + } + | undefined; + } +>; + +// @public (undocumented) +export const RepoUrlPickerFieldSchema: FieldSchema< + string, + { + allowedOwners?: string[] | undefined; + allowedOrganizations?: string[] | undefined; + allowedRepos?: string[] | undefined; + allowedHosts?: string[] | undefined; + requestUserCredentials?: + | { + additionalScopes?: + | { + azure?: string[] | undefined; + github?: string[] | undefined; + gitlab?: string[] | undefined; + bitbucket?: string[] | undefined; + gerrit?: string[] | undefined; + } + | undefined; + secretsKey: string; + } + | undefined; + } >; // @public -export interface RepoUrlPickerUiOptions { - // (undocumented) - allowedHosts?: string[]; - // (undocumented) - allowedOrganizations?: string[]; - // (undocumented) - allowedOwners?: string[]; - // (undocumented) - allowedRepos?: string[]; - // (undocumented) - requestUserCredentials?: { - secretsKey: string; - additionalScopes?: { - gerrit?: string[]; - github?: string[]; - gitlab?: string[]; - bitbucket?: string[]; - azure?: string[]; - }; - }; -} +export type RepoUrlPickerUiOptions = + typeof RepoUrlPickerFieldSchema.uiOptionsType; // @public (undocumented) export const rootRouteRef: RouteRef; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 7f3ad0479b..adb7461dd9 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -74,7 +74,9 @@ "react-use": "^17.2.4", "use-immer": "^0.7.0", "yaml": "^2.0.0", - "zen-observable": "^0.8.15" + "zen-observable": "^0.9.0", + "zod": "^3.11.6", + "zod-to-json-schema": "^3.18.1" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx b/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx index 837b87437f..57dde13a43 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx @@ -236,7 +236,7 @@ describe('', () => { ); await act(async () => { - const allButton = await getByText('All'); + const allButton = getByText('All'); fireEvent.click(allButton); }); diff --git a/plugins/scaffolder/src/components/ListTasksPage/OwnerListPicker.test.tsx b/plugins/scaffolder/src/components/ListTasksPage/OwnerListPicker.test.tsx index 63f943d6dd..7c335a0ef7 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/OwnerListPicker.test.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/OwnerListPicker.test.tsx @@ -15,7 +15,6 @@ */ import { renderInTestApp } from '@backstage/test-utils'; - import React from 'react'; import { OwnerListPicker } from './OwnerListPicker'; import { fireEvent } from '@testing-library/react'; @@ -29,8 +28,8 @@ describe('', () => { const { getByText } = await renderInTestApp(); - expect(await getByText('Owned')).toBeDefined(); - expect(await getByText('All')).toBeDefined(); + expect(getByText('Owned')).toBeDefined(); + expect(getByText('All')).toBeDefined(); }); it('should call the function on select other item', async () => { @@ -41,7 +40,7 @@ describe('', () => { const { getByText } = await renderInTestApp(); - fireEvent.click(await getByText('All')); + fireEvent.click(getByText('All')); expect(props.onSelectOwner).toHaveBeenCalledWith('all'); }); }); diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index 097eddf6cd..9f46b427a0 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -163,6 +163,7 @@ export const Router = (props: RouterProps) => { } diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx new file mode 100644 index 0000000000..3510968acd --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx @@ -0,0 +1,201 @@ +/* + * 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 { StreamLanguage } from '@codemirror/language'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; +import { + Button, + Card, + CardContent, + CardHeader, + FormControl, + IconButton, + InputLabel, + makeStyles, + MenuItem, + Select, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import { withTheme } from '@rjsf/core'; +import { Theme as MuiTheme } from '@rjsf/material-ui'; +import CodeMirror from '@uiw/react-codemirror'; +import React, { useCallback, useMemo, useState } from 'react'; +import yaml from 'yaml'; +import { FieldExtensionOptions } from '../../extensions'; +import * as fieldOverrides from '../MultistepJsonForm/FieldOverrides'; +import { TemplateEditorForm } from './TemplateEditorForm'; + +const Form = withTheme(MuiTheme); + +const useStyles = makeStyles(theme => ({ + root: { + gridArea: 'pageContent', + display: 'grid', + gridTemplateAreas: ` + "controls controls" + "fieldForm preview" + `, + gridTemplateRows: 'auto 1fr', + gridTemplateColumns: '1fr 1fr', + }, + controls: { + gridArea: 'controls', + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + margin: theme.spacing(1), + }, + fieldForm: { + gridArea: 'fieldForm', + }, + preview: { + gridArea: 'preview', + }, +})); + +export const CustomFieldExplorer = ({ + customFieldExtensions = [], + onClose, +}: { + customFieldExtensions?: FieldExtensionOptions[]; + onClose?: () => void; +}) => { + const classes = useStyles(); + const fieldOptions = customFieldExtensions.filter(field => !!field.schema); + const [selectedField, setSelectedField] = useState(fieldOptions[0]); + const [fieldFormState, setFieldFormState] = useState({}); + const [formState, setFormState] = useState({}); + const [refreshKey, setRefreshKey] = useState(Date.now()); + const sampleFieldTemplate = useMemo( + () => + yaml.stringify({ + parameters: [ + { + title: `${selectedField.name} Example`, + properties: { + [selectedField.name]: { + type: selectedField.schema?.returnValue?.type, + 'ui:field': selectedField.name, + 'ui:options': fieldFormState, + }, + }, + }, + ], + }), + [fieldFormState, selectedField], + ); + + const fieldComponents = useMemo(() => { + return Object.fromEntries( + customFieldExtensions.map(({ name, component }) => [name, component]), + ); + }, [customFieldExtensions]); + + const handleSelectionChange = useCallback( + selection => { + setSelectedField(selection); + setFieldFormState({}); + setFormState({}); + }, + [setFieldFormState, setFormState, setSelectedField], + ); + + const handleFieldConfigChange = useCallback( + state => { + setFieldFormState(state); + setFormState({}); + // Force TemplateEditorForm to re-render since some fields + // may not be responsive to ui:option changes + setRefreshKey(Date.now()); + }, + [setFieldFormState, setRefreshKey], + ); + + return ( +
+
+ + + Choose Custom Field Extension + + + + + + + +
+
+ + + +
handleFieldConfigChange(e.formData)} + schema={selectedField.schema?.uiOptions || {}} + > + +
+
+
+
+
+ + + + + + + null} + /> +
+
+ ); +}; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx index dc411ee965..61e6c46964 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx @@ -44,7 +44,7 @@ const useStyles = makeStyles(theme => ({ interface EditorIntroProps { style?: JSX.IntrinsicElements['div']['style']; - onSelect?: (option: 'local' | 'form') => void; + onSelect?: (option: 'local' | 'form' | 'field-explorer') => void; } export function TemplateEditorIntro(props: EditorIntroProps) { @@ -104,6 +104,22 @@ export function TemplateEditorIntro(props: EditorIntroProps) { ); + const cardFieldExplorer = ( + + props.onSelect?.('field-explorer')}> + + + Custom Field Explorer + + + View and play around with available installed custom field + extensions. + + + + + ); + return (
@@ -121,6 +137,7 @@ export function TemplateEditorIntro(props: EditorIntroProps) { {supportsLoad && cardLoadLocal} {cardFormEditor} {!supportsLoad && cardLoadLocal} + {cardFieldExplorer}
); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index b539b6403b..8847b85373 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -19,6 +19,7 @@ import { TemplateDirectoryAccess, WebFileSystemAccess, } from '../../lib/filesystem'; +import { CustomFieldExplorer } from './CustomFieldExplorer'; import { TemplateEditorIntro } from './TemplateEditorIntro'; import { TemplateEditor } from './TemplateEditor'; import { TemplateFormPreviewer } from './TemplateFormPreviewer'; @@ -32,6 +33,9 @@ type Selection = } | { type: 'form'; + } + | { + type: 'field-explorer'; }; interface TemplateEditorPageProps { @@ -62,6 +66,13 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { layouts={props.layouts} /> ); + } else if (selection?.type === 'field-explorer') { + content = ( + setSelection(undefined)} + /> + ); } else { content = ( @@ -73,6 +84,8 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { .catch(() => {}); } else if (option === 'form') { setSelection({ type: 'form' }); + } else if (option === 'field-explorer') { + setSelection({ type: 'field-explorer' }); } }} /> diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index a48644e72d..71f5ffad47 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { MockAnalyticsApi, renderInTestApp, @@ -340,9 +341,9 @@ describe('TemplatePage', () => { }, ); - expect(await queryByText('Name')).not.toBeInTheDocument(); - expect(await queryByText('Description')).toBeInTheDocument(); - expect(await queryByText('Owner')).toBeInTheDocument(); - expect(await queryByText('Send data')).toBeInTheDocument(); + expect(queryByText('Name')).not.toBeInTheDocument(); + expect(queryByText('Description')).toBeInTheDocument(); + expect(queryByText('Owner')).toBeInTheDocument(); + expect(queryByText('Send data')).toBeInTheDocument(); }); }); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 87678d2a83..522f37dfc9 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -51,13 +51,21 @@ const useTemplateParameterSchema = (templateRef: string) => { return { schema: value, loading, error }; }; +type Props = { + customFieldExtensions?: FieldExtensionOptions[]; + layouts?: LayoutOptions[]; + headerOptions?: { + pageTitleOverride?: string; + title?: string; + subtitle?: string; + }; +}; + export const TemplatePage = ({ customFieldExtensions = [], layouts = [], -}: { - customFieldExtensions?: FieldExtensionOptions[]; - layouts?: LayoutOptions[]; -}) => { + headerOptions, +}: Props) => { const apiHolder = useApiHolder(); const secretsContext = useContext(SecretsContext); const errorApi = useApi(errorApiRef); @@ -136,6 +144,7 @@ export const TemplatePage = ({ pageTitleOverride="Create a New Component" title="Create a New Component" subtitle="Create new software components using standard templates" + {...headerOptions} /> {loading && } diff --git a/plugins/scaffolder/src/components/fields/EntityNamePicker/EntityNamePicker.tsx b/plugins/scaffolder/src/components/fields/EntityNamePicker/EntityNamePicker.tsx index 802135a863..555b726917 100644 --- a/plugins/scaffolder/src/components/fields/EntityNamePicker/EntityNamePicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityNamePicker/EntityNamePicker.tsx @@ -14,15 +14,15 @@ * limitations under the License. */ import React from 'react'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { EntityNamePickerProps } from './schema'; import { TextField } from '@material-ui/core'; +export { EntityNamePickerSchema } from './schema'; + /** * EntityName Picker */ -export const EntityNamePicker = ( - props: FieldExtensionComponentProps, -) => { +export const EntityNamePicker = (props: EntityNamePickerProps) => { const { onChange, required, diff --git a/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts new file mode 100644 index 0000000000..27e33befb5 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts @@ -0,0 +1,23 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +const EntityNamePickerFieldSchema = makeFieldSchemaFromZod(z.string()); + +export const EntityNamePickerSchema = EntityNamePickerFieldSchema.schema; + +export type EntityNamePickerProps = typeof EntityNamePickerFieldSchema.type; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index e7883a4721..bc78e00576 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -23,20 +23,9 @@ import FormControl from '@material-ui/core/FormControl'; import Autocomplete from '@material-ui/lab/Autocomplete'; import React, { useCallback, useEffect } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { EntityPickerProps } from './schema'; -/** - * The input props that can be specified under `ui:options` for the - * `EntityPicker` field extension. - * - * @public - */ -export interface EntityPickerUiOptions { - allowedKinds?: string[]; - defaultKind?: string; - allowArbitraryValues?: boolean; - defaultNamespace?: string | false; -} +export { EntityPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `EntityPicker` @@ -44,9 +33,7 @@ export interface EntityPickerUiOptions { * * @public */ -export const EntityPicker = ( - props: FieldExtensionComponentProps, -) => { +export const EntityPicker = (props: EntityPickerProps) => { const { onChange, schema: { title = 'Entity', description = 'An entity from the catalog' }, diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/index.ts b/plugins/scaffolder/src/components/fields/EntityPicker/index.ts index 891b5bef16..b8df596c98 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/EntityPicker/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { EntityPickerUiOptions } from './EntityPicker'; +export { EntityPickerFieldSchema, type EntityPickerUiOptions } from './schema'; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts new file mode 100644 index 0000000000..9236457fc0 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts @@ -0,0 +1,59 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const EntityPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + allowedKinds: z + .array(z.string()) + .optional() + .describe('List of kinds of entities to derive options from'), + defaultKind: z + .string() + .optional() + .describe( + 'The default entity kind. Options of this kind will not be prefixed.', + ), + allowArbitraryValues: z + .boolean() + .optional() + .describe('Whether to allow arbitrary user input. Defaults to true'), + defaultNamespace: z + .union([z.string(), z.literal(false)]) + .optional() + .describe( + 'The default namespace. Options with this namespace will not be prefixed.', + ), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `EntityPicker` field extension. + * + * @public + */ +export type EntityPickerUiOptions = + typeof EntityPickerFieldSchema.uiOptionsType; + +export type EntityPickerProps = typeof EntityPickerFieldSchema.type; + +export const EntityPickerSchema = EntityPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx b/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx index c0e3aab436..549ccdb8aa 100644 --- a/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx @@ -22,19 +22,9 @@ import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { FormControl, TextField } from '@material-ui/core'; import { Autocomplete } from '@material-ui/lab'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { EntityTagsPickerProps } from './schema'; -/** - * The input props that can be specified under `ui:options` for the - * `EntityTagsPicker` field extension. - * - * @public - */ -export interface EntityTagsPickerUiOptions { - kinds?: string[]; - showCounts?: boolean; - helperText?: string; -} +export { EntityTagsPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `EntityTagsPicker` @@ -42,9 +32,7 @@ export interface EntityTagsPickerUiOptions { * * @public */ -export const EntityTagsPicker = ( - props: FieldExtensionComponentProps, -) => { +export const EntityTagsPicker = (props: EntityTagsPickerProps) => { const { formData, onChange, uiSchema } = props; const catalogApi = useApi(catalogApiRef); const [tagOptions, setTagOptions] = useState([]); diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/index.ts b/plugins/scaffolder/src/components/fields/EntityTagsPicker/index.ts index 9ff6e553a6..52bad9a80d 100644 --- a/plugins/scaffolder/src/components/fields/EntityTagsPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/index.ts @@ -13,4 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { EntityTagsPickerUiOptions } from './EntityTagsPicker'; +export { + EntityTagsPickerFieldSchema, + type EntityTagsPickerUiOptions, +} from './schema'; diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts new file mode 100644 index 0000000000..6677d16e98 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts @@ -0,0 +1,48 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const EntityTagsPickerFieldSchema = makeFieldSchemaFromZod( + z.array(z.string()), + z.object({ + kinds: z + .array(z.string()) + .optional() + .describe('List of kinds of entities to derive tags from'), + showCounts: z + .boolean() + .optional() + .describe('Whether to show usage counts per tag'), + helperText: z.string().optional().describe('Helper text to display'), + }), +); + +export const EntityTagsPickerSchema = EntityTagsPickerFieldSchema.schema; + +export type EntityTagsPickerProps = typeof EntityTagsPickerFieldSchema.type; + +/** + * The input props that can be specified under `ui:options` for the + * `EntityTagsPicker` field extension. + * + * @public + */ +export type EntityTagsPickerUiOptions = + typeof EntityTagsPickerFieldSchema.uiOptionsType; diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx index 2446883a86..01a174a6a3 100644 --- a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx @@ -26,20 +26,9 @@ import Autocomplete from '@material-ui/lab/Autocomplete'; import React, { useMemo } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { OwnedEntityPickerProps } from './schema'; -/** - * The input props that can be specified under `ui:options` for the - * `OwnedEntityPicker` field extension. - * - * @public - */ -export interface OwnedEntityPickerUiOptions { - allowedKinds?: string[]; - defaultKind?: string; - allowArbitraryValues?: boolean; - defaultNamespace?: string | false; -} +export { OwnedEntityPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `OwnedEntityPicker` @@ -47,9 +36,7 @@ export interface OwnedEntityPickerUiOptions { * * @public */ -export const OwnedEntityPicker = ( - props: FieldExtensionComponentProps, -) => { +export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => { const { onChange, schema: { title = 'Entity', description = 'An entity from the catalog' }, diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts index 2988ba8cdc..985dae1d3c 100644 --- a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts @@ -13,4 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { OwnedEntityPickerUiOptions } from './OwnedEntityPicker'; +export { + OwnedEntityPickerFieldSchema, + type OwnedEntityPickerUiOptions, +} from './schema'; diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts new file mode 100644 index 0000000000..4190b89f95 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2021 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const OwnedEntityPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + allowedKinds: z + .array(z.string()) + .optional() + .describe('List of kinds of entities to derive options from'), + defaultKind: z + .string() + .optional() + .describe( + 'The default entity kind. Options of this kind will not be prefixed.', + ), + allowArbitraryValues: z + .boolean() + .optional() + .describe('Whether to allow arbitrary user input. Defaults to true'), + defaultNamespace: z + .union([z.string(), z.literal(false)]) + .optional() + .describe( + 'The default namespace. Options with this namespace will not be prefixed.', + ), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `OwnedEntityPicker` field extension. + * + * @public + */ +export type OwnedEntityPickerUiOptions = + typeof OwnedEntityPickerFieldSchema.uiOptionsType; + +export type OwnedEntityPickerProps = typeof OwnedEntityPickerFieldSchema.type; + +export const OwnedEntityPickerSchema = OwnedEntityPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx index 3de2b12bd0..1c4c356906 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx @@ -15,19 +15,9 @@ */ import React from 'react'; import { EntityPicker } from '../EntityPicker/EntityPicker'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { OwnerPickerProps } from './schema'; -/** - * The input props that can be specified under `ui:options` for the - * `OwnerPicker` field extension. - * - * @public - */ -export interface OwnerPickerUiOptions { - allowedKinds?: string[]; - allowArbitraryValues?: boolean; - defaultNamespace?: string | false; -} +export { OwnerPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `OwnerPicker` @@ -35,9 +25,7 @@ export interface OwnerPickerUiOptions { * * @public */ -export const OwnerPicker = ( - props: FieldExtensionComponentProps, -) => { +export const OwnerPicker = (props: OwnerPickerProps) => { const { schema: { title = 'Owner', description = 'The owner of the component' }, uiSchema, diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts b/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts index aa26024b5b..9d94650e04 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { OwnerPickerUiOptions } from './OwnerPicker'; +export { OwnerPickerFieldSchema, type OwnerPickerUiOptions } from './schema'; diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts b/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts new file mode 100644 index 0000000000..56edef1343 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts @@ -0,0 +1,55 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const OwnerPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + allowedKinds: z + .array(z.string()) + .default(['Group', 'User']) + .optional() + .describe( + 'List of kinds of entities to derive options from. Defaults to Group and User', + ), + allowArbitraryValues: z + .boolean() + .optional() + .describe('Whether to allow arbitrary user input. Defaults to true'), + defaultNamespace: z + .union([z.string(), z.literal(false)]) + .optional() + .describe( + 'The default namespace. Options with this namespace will not be prefixed.', + ), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `OwnerPicker` field extension. + * + * @public + */ +export type OwnerPickerUiOptions = typeof OwnerPickerFieldSchema.uiOptionsType; + +export type OwnerPickerProps = typeof OwnerPickerFieldSchema.type; + +export const OwnerPickerSchema = OwnerPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 451df857e5..827eff6ea2 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -24,36 +24,15 @@ import { GitlabRepoPicker } from './GitlabRepoPicker'; import { AzureRepoPicker } from './AzureRepoPicker'; import { BitbucketRepoPicker } from './BitbucketRepoPicker'; import { GerritRepoPicker } from './GerritRepoPicker'; -import { FieldExtensionComponentProps } from '../../../extensions'; import { RepoUrlPickerHost } from './RepoUrlPickerHost'; import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName'; import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils'; +import { RepoUrlPickerProps } from './schema'; import { RepoUrlPickerState } from './types'; import useDebounce from 'react-use/lib/useDebounce'; import { useTemplateSecrets } from '../../secrets'; -/** - * The input props that can be specified under `ui:options` for the - * `RepoUrlPicker` field extension. - * - * @public - */ -export interface RepoUrlPickerUiOptions { - allowedHosts?: string[]; - allowedOrganizations?: string[]; - allowedOwners?: string[]; - allowedRepos?: string[]; - requestUserCredentials?: { - secretsKey: string; - additionalScopes?: { - gerrit?: string[]; - github?: string[]; - gitlab?: string[]; - bitbucket?: string[]; - azure?: string[]; - }; - }; -} +export { RepoUrlPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `RepoUrlPicker` @@ -61,9 +40,7 @@ export interface RepoUrlPickerUiOptions { * * @public */ -export const RepoUrlPicker = ( - props: FieldExtensionComponentProps, -) => { +export const RepoUrlPicker = (props: RepoUrlPickerProps) => { const { uiSchema, onChange, rawErrors, formData } = props; const [state, setState] = useState( parseRepoPickerUrl(formData), diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts index c5f596a786..33e5ef1715 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts @@ -13,5 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { RepoUrlPickerUiOptions } from './RepoUrlPicker'; +export { + RepoUrlPickerFieldSchema, + type RepoUrlPickerUiOptions, +} from './schema'; export { repoPickerValidation } from './validation'; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts new file mode 100644 index 0000000000..8f8674a0a7 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts @@ -0,0 +1,95 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const RepoUrlPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + allowedHosts: z + .array(z.string()) + .optional() + .describe('List of allowed SCM platform hosts'), + allowedOrganizations: z + .array(z.string()) + .optional() + .describe('List of allowed organizations in the given SCM platform'), + allowedOwners: z + .array(z.string()) + .optional() + .describe('List of allowed owners in the given SCM platform'), + allowedRepos: z + .array(z.string()) + .optional() + .describe('List of allowed repos in the given SCM platform'), + requestUserCredentials: z + .object({ + secretsKey: z + .string() + .describe( + 'Key used within the template secrets context to store the credential', + ), + additionalScopes: z + .object({ + gerrit: z + .array(z.string()) + .optional() + .describe('Additional Gerrit scopes to request'), + github: z + .array(z.string()) + .optional() + .describe('Additional GitHub scopes to request'), + gitlab: z + .array(z.string()) + .optional() + .describe('Additional GitLab scopes to request'), + bitbucket: z + .array(z.string()) + .optional() + .describe('Additional BitBucket scopes to request'), + azure: z + .array(z.string()) + .optional() + .describe('Additional Azure scopes to request'), + }) + .optional() + .describe('Additional permission scopes to request'), + }) + .optional() + .describe( + 'If defined will request user credentials to auth against the given SCM platform', + ), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `RepoUrlPicker` field extension. + * + * @public + */ +export type RepoUrlPickerUiOptions = + typeof RepoUrlPickerFieldSchema.uiOptionsType; + +export type RepoUrlPickerProps = typeof RepoUrlPickerFieldSchema.type; + +// NOTE: There is a bug with this failing validation in the custom field explorer due +// to https://github.com/rjsf-team/react-jsonschema-form/issues/675 even if +// requestUserCredentials is not defined +export const RepoUrlPickerSchema = RepoUrlPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts index 6358934025..8d86f601ce 100644 --- a/plugins/scaffolder/src/components/fields/index.ts +++ b/plugins/scaffolder/src/components/fields/index.ts @@ -18,3 +18,4 @@ export * from './OwnerPicker'; export * from './RepoUrlPicker'; export * from './OwnedEntityPicker'; export * from './EntityTagsPicker'; +export { type FieldSchema, makeFieldSchemaFromZod } from './utils'; diff --git a/plugins/scaffolder/src/components/fields/utils.ts b/plugins/scaffolder/src/components/fields/utils.ts new file mode 100644 index 0000000000..2d3e2aab1e --- /dev/null +++ b/plugins/scaffolder/src/components/fields/utils.ts @@ -0,0 +1,62 @@ +/* + * 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 { JSONSchema7 } from 'json-schema'; +import { z } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; +import { + CustomFieldExtensionSchema, + FieldExtensionComponentProps, +} from '../../extensions'; + +/** + * @public + * FieldSchema encapsulates a JSONSchema7 along with the + * matching FieldExtensionComponentProps type for a field extension. + */ +export interface FieldSchema { + readonly schema: CustomFieldExtensionSchema; + readonly type: FieldExtensionComponentProps; + readonly uiOptionsType: TUiOptions; +} + +/** + * @public + * Utility function to convert zod return and UI options schemas to a + * CustomFieldExtensionSchema with FieldExtensionComponentProps type inference + */ +export function makeFieldSchemaFromZod< + TReturnSchema extends z.ZodType, + TUiOptionsSchema extends z.ZodType = z.ZodType, +>( + returnSchema: TReturnSchema, + uiOptionsSchema?: TUiOptionsSchema, +): FieldSchema< + TReturnSchema extends z.ZodType ? IReturn : never, + TUiOptionsSchema extends z.ZodType + ? IUiOptions + : never +> { + return { + schema: { + returnValue: zodToJsonSchema(returnSchema) as JSONSchema7, + uiOptions: uiOptionsSchema + ? (zodToJsonSchema(uiOptionsSchema) as JSONSchema7) + : undefined, + }, + type: null as any, + uiOptionsType: null as any, + }; +} diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts index 8081dcb0ca..c6ab5276d0 100644 --- a/plugins/scaffolder/src/extensions/default.ts +++ b/plugins/scaffolder/src/extensions/default.ts @@ -13,40 +13,64 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EntityPicker } from '../components/fields/EntityPicker/EntityPicker'; -import { EntityNamePicker } from '../components/fields/EntityNamePicker/EntityNamePicker'; +import { + EntityPicker, + EntityPickerSchema, +} from '../components/fields/EntityPicker/EntityPicker'; +import { + EntityNamePicker, + EntityNamePickerSchema, +} from '../components/fields/EntityNamePicker/EntityNamePicker'; import { entityNamePickerValidation } from '../components/fields/EntityNamePicker/validation'; -import { EntityTagsPicker } from '../components/fields/EntityTagsPicker/EntityTagsPicker'; -import { OwnerPicker } from '../components/fields/OwnerPicker/OwnerPicker'; -import { RepoUrlPicker } from '../components/fields/RepoUrlPicker/RepoUrlPicker'; +import { + EntityTagsPicker, + EntityTagsPickerSchema, +} from '../components/fields/EntityTagsPicker/EntityTagsPicker'; +import { + OwnerPicker, + OwnerPickerSchema, +} from '../components/fields/OwnerPicker/OwnerPicker'; +import { + RepoUrlPicker, + RepoUrlPickerSchema, +} from '../components/fields/RepoUrlPicker/RepoUrlPicker'; import { repoPickerValidation } from '../components/fields/RepoUrlPicker/validation'; -import { OwnedEntityPicker } from '../components/fields/OwnedEntityPicker/OwnedEntityPicker'; +import { + OwnedEntityPicker, + OwnedEntityPickerSchema, +} from '../components/fields/OwnedEntityPicker/OwnedEntityPicker'; export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [ { component: EntityPicker, name: 'EntityPicker', + schema: EntityPickerSchema, }, { component: EntityNamePicker, name: 'EntityNamePicker', validation: entityNamePickerValidation, + schema: EntityNamePickerSchema, }, { component: EntityTagsPicker, name: 'EntityTagsPicker', + schema: EntityTagsPickerSchema, }, { component: RepoUrlPicker, name: 'RepoUrlPicker', validation: repoPickerValidation, + schema: RepoUrlPickerSchema, }, { component: OwnerPicker, name: 'OwnerPicker', + schema: OwnerPickerSchema, }, { component: OwnedEntityPicker, name: 'OwnedEntityPicker', + schema: OwnedEntityPickerSchema, }, ]; diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index 08ab3e603c..a3a4fdf57b 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -16,6 +16,7 @@ import React from 'react'; import { + CustomFieldExtensionSchema, CustomFieldValidator, FieldExtensionOptions, FieldExtensionComponentProps, @@ -103,6 +104,7 @@ attachComponentData( ); export type { + CustomFieldExtensionSchema, CustomFieldValidator, FieldExtensionOptions, FieldExtensionComponentProps, diff --git a/plugins/scaffolder/src/extensions/types.ts b/plugins/scaffolder/src/extensions/types.ts index 62f17dff15..3834b39c0a 100644 --- a/plugins/scaffolder/src/extensions/types.ts +++ b/plugins/scaffolder/src/extensions/types.ts @@ -15,14 +15,14 @@ */ import { ApiHolder } from '@backstage/core-plugin-api'; import { FieldValidation, FieldProps } from '@rjsf/core'; -import { PropsWithChildren } from 'react'; - import { UIOptionsType, FieldProps as FieldPropsV5, UiSchema as UiSchemaV5, FieldValidation as FieldValidationV5, } from '@rjsf/utils'; +import { PropsWithChildren } from 'react'; +import { JSONSchema7 } from 'json-schema'; /** * Field validation type for Custom Field Extensions. @@ -35,6 +35,16 @@ export type CustomFieldValidator = ( context: { apiHolder: ApiHolder }, ) => void | Promise; +/** + * Type for the Custom Field Extension schema. + * + * @public + */ +export type CustomFieldExtensionSchema = { + returnValue: JSONSchema7; + uiOptions?: JSONSchema7; +}; + /** * Type for the Custom Field Extension with the * name and components and validation function. @@ -50,6 +60,7 @@ export type FieldExtensionOptions< props: FieldExtensionComponentProps, ) => JSX.Element | null; validation?: CustomFieldValidator; + schema?: CustomFieldExtensionSchema; }; /** @@ -107,4 +118,5 @@ export type NextFieldExtensionOptions< props: NextFieldExtensionComponentProps, ) => JSX.Element | null; validation?: NextCustomFieldValidator; + schema?: CustomFieldExtensionSchema; }; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 7853667101..0e5e2630ac 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -43,6 +43,7 @@ export { ScaffolderFieldExtensions, } from './extensions'; export type { + CustomFieldExtensionSchema, CustomFieldValidator, FieldExtensionOptions, FieldExtensionComponentProps, diff --git a/plugins/scaffolder/src/next/Router/Router.tsx b/plugins/scaffolder/src/next/Router/Router.tsx index ebe00fdfb8..0fc1c9eceb 100644 --- a/plugins/scaffolder/src/next/Router/Router.tsx +++ b/plugins/scaffolder/src/next/Router/Router.tsx @@ -30,6 +30,7 @@ import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '../TemplateListPage/TemplateGroups'; import { nextSelectedTemplateRouteRef } from '../../routes'; import { SecretsContextProvider } from '../../components/secrets/SecretsContext'; +import type { ErrorTransformer } from '@rjsf/utils'; /** * The Props for the Scaffolder Router @@ -44,6 +45,7 @@ export type NextRouterProps = { TaskPageComponent?: React.ComponentType<{}>; }; groups?: TemplateGroupFilter[]; + transformErrors?: ErrorTransformer; }; /** diff --git a/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.test.tsx index e6022d16fb..a8e377bd3d 100644 --- a/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.test.tsx +++ b/plugins/scaffolder/src/next/TemplateListPage/RegisterExistingButton.test.tsx @@ -34,7 +34,7 @@ describe('RegisterExistingButton', () => { , ); - expect(await queryByText('Pick me')).not.toBeInTheDocument(); + expect(queryByText('Pick me')).not.toBeInTheDocument(); }); it('should not render if permissions are not allowed', async () => { @@ -43,7 +43,7 @@ describe('RegisterExistingButton', () => { , ); - expect(await queryByText('Pick me')).not.toBeInTheDocument(); + expect(queryByText('Pick me')).not.toBeInTheDocument(); }); it('should render the button with the text', async () => { @@ -52,6 +52,6 @@ describe('RegisterExistingButton', () => { , ); - expect(await queryByText('Pick me')).toBeInTheDocument(); + expect(queryByText('Pick me')).toBeInTheDocument(); }); }); diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/ReviewState.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/ReviewState.test.tsx index 2b2e706102..c03df8f73c 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/ReviewState.test.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/ReviewState.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { ReviewState } from './ReviewState'; import { render } from '@testing-library/react'; @@ -97,9 +98,7 @@ describe('ReviewState', () => { , ); - expect( - await queryByRole('row', { name: 'Name ******' }), - ).not.toBeInTheDocument(); + expect(queryByRole('row', { name: 'Name ******' })).not.toBeInTheDocument(); }); it('should allow for masking an option with a set text', () => { diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx index 12ad05ab75..bf82fecf49 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx @@ -18,6 +18,7 @@ import { TemplateParameterSchema } from '../../../types'; import { Stepper } from './Stepper'; import { renderInTestApp } from '@backstage/test-utils'; import { act, fireEvent } from '@testing-library/react'; +import type { RJSFValidationError } from '@rjsf/utils'; describe('Stepper', () => { it('should render the step titles for each step of the manifest', async () => { @@ -140,4 +141,50 @@ describe('Stepper', () => { expect(getByText('im a custom field extension')).toBeInTheDocument(); }); + + it('should transform default error message', async () => { + const manifest: TemplateParameterSchema = { + steps: [ + { + title: 'Step 1', + schema: { + properties: { + postcode: { + type: 'string', + pattern: '[A-Z][0-9][A-Z] [0-9][A-Z][0-9]', + }, + }, + }, + }, + ], + title: 'transformErrors Form Test', + }; + + const transformErrors = (errors: RJSFValidationError[]) => { + return errors.map(err => + err.property === '.postcode' + ? { ...err, message: 'invalid postcode' } + : err, + ); + }; + + const { getByText, getByRole } = await renderInTestApp( + , + ); + + await fireEvent.change(getByRole('textbox', { name: 'postcode' }), { + target: { value: 'invalid' }, + }); + + await act(async () => { + await fireEvent.click(getByRole('button', { name: 'Review' })); + }); + + expect(getByText('invalid postcode')).toBeInTheDocument(); + }); }); diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx index 75f92916df..de06655aa1 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx @@ -36,6 +36,7 @@ import { useTemplateSchema } from './useTemplateSchema'; import { ReviewState } from './ReviewState'; import validator from '@rjsf/validator-ajv8'; import { selectedTemplateRouteRef } from '../../../routes'; +import type { ErrorTransformer } from '@rjsf/utils'; const useStyles = makeStyles(theme => ({ backButton: { @@ -56,6 +57,7 @@ export interface StepperProps { manifest: TemplateParameterSchema; extensions: NextFieldExtensionOptions[]; onComplete: (values: Record) => Promise; + transformErrors?: ErrorTransformer; } // TODO(blam): We require here, as the types in this package depend on @rjsf/core explicitly @@ -149,6 +151,7 @@ export const Stepper = (props: StepperProps) => { onSubmit={handleNext} fields={extensions} showErrorList={false} + transformErrors={props.transformErrors} >