diff --git a/.changeset/chilly-books-sneeze.md b/.changeset/chilly-books-sneeze.md new file mode 100644 index 0000000000..a2d3ca1e04 --- /dev/null +++ b/.changeset/chilly-books-sneeze.md @@ -0,0 +1,6 @@ +--- +'@backstage/cli-node': minor +'@backstage/cli': minor +--- + +Removed support for the `publishConfig.alphaTypes` and `.betaTypes` fields that were used together with `--experimental-type-build` to generate `/alpha` and `/beta` entry points. Use the `exports` field to achieve this instead. diff --git a/.changeset/fast-bears-lick.md b/.changeset/fast-bears-lick.md new file mode 100644 index 0000000000..8e0dc8e60b --- /dev/null +++ b/.changeset/fast-bears-lick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Migrate catalog entity cards to new frontend system extension format. diff --git a/.changeset/four-files-behave.md b/.changeset/four-files-behave.md new file mode 100644 index 0000000000..0430bd98d8 --- /dev/null +++ b/.changeset/four-files-behave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-graphiql': minor +--- + +Upgrade to GraphiQL to 3.0.6 diff --git a/.changeset/fresh-schools-thank.md b/.changeset/fresh-schools-thank.md new file mode 100644 index 0000000000..1e32f82051 --- /dev/null +++ b/.changeset/fresh-schools-thank.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-gitlab': patch +--- + +Added try catch around fetching gitlab group users to prevent refresh from failing completely while only a select number of groups might not be able to load correctly. diff --git a/.changeset/large-forks-arrive.md b/.changeset/large-forks-arrive.md new file mode 100644 index 0000000000..3490a87128 --- /dev/null +++ b/.changeset/large-forks-arrive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-code-coverage-backend': patch +--- + +Added support for new backend system diff --git a/.changeset/many-masks-smoke.md b/.changeset/many-masks-smoke.md new file mode 100644 index 0000000000..6d249eeb15 --- /dev/null +++ b/.changeset/many-masks-smoke.md @@ -0,0 +1,6 @@ +--- +'@backstage/repo-tools': minor +'@backstage/cli': minor +--- + +Remove support for the deprecated `--experimental-type-build` option for `package build`. diff --git a/.changeset/poor-seahorses-rush.md b/.changeset/poor-seahorses-rush.md new file mode 100644 index 0000000000..55313f706d --- /dev/null +++ b/.changeset/poor-seahorses-rush.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-azure-devops': patch +--- + +Consolidated getting the annotation values into a single function to help with future changes diff --git a/.changeset/shaggy-buses-beg.md b/.changeset/shaggy-buses-beg.md new file mode 100644 index 0000000000..d2c9351b5e --- /dev/null +++ b/.changeset/shaggy-buses-beg.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +The `UserListPicker` component has undergone improvements to enhance its performance. + +The previous implementation inferred the number of owned and starred entities based on the entities available in the `EntityListContext`. The updated version no longer relies on the `EntityListContext` for inference, allowing for better decoupling. + +The component now loads the entities' count asynchronously, resulting in improved performance and responsiveness. For this purpose, some of the exported filters such as `EntityTagFilter`, `EntityOwnerFilter`, `EntityLifecycleFilter` and `EntityNamespaceFilter` have now the `getCatalogFilters` method implemented. diff --git a/.changeset/sour-toes-joke.md b/.changeset/sour-toes-joke.md new file mode 100644 index 0000000000..4e380f4ded --- /dev/null +++ b/.changeset/sour-toes-joke.md @@ -0,0 +1,13 @@ +--- +'@backstage/plugin-user-settings-backend': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-periskop-backend': patch +'@backstage/plugin-scaffolder-node': patch +'@backstage/plugin-bazaar-backend': patch +'@backstage/plugin-kafka-backend': patch +'@backstage/plugin-proxy-backend': patch +'@backstage/plugin-sonarqube': patch +'@backstage/plugin-todo': patch +--- + +Switched to using `"exports"` field for `/alpha` subpath export. diff --git a/.changeset/strange-queens-deliver.md b/.changeset/strange-queens-deliver.md new file mode 100644 index 0000000000..ed1d4d0b5b --- /dev/null +++ b/.changeset/strange-queens-deliver.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Reverted the Microsoft auth provider to the previous implementation. diff --git a/.changeset/tall-colts-roll.md b/.changeset/tall-colts-roll.md new file mode 100644 index 0000000000..914a09cdac --- /dev/null +++ b/.changeset/tall-colts-roll.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Allow setting `update: true` in `publish:github:pull-request` scaffolder action diff --git a/.changeset/wicked-ties-knock.md b/.changeset/wicked-ties-knock.md new file mode 100644 index 0000000000..582cb905d0 --- /dev/null +++ b/.changeset/wicked-ties-knock.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Add examples for `gitlab:projectAccessToken:create` scaffolder action & improve related tests diff --git a/.github/workflows/automate_changeset_feedback.yml b/.github/workflows/automate_changeset_feedback.yml index 483d59dc8c..eac020cb8e 100644 --- a/.github/workflows/automate_changeset_feedback.yml +++ b/.github/workflows/automate_changeset_feedback.yml @@ -34,7 +34,7 @@ jobs: ref: 'refs/pull/${{ github.event.pull_request.number }}/merge' - name: fetch base run: git fetch --depth 1 origin ${{ github.base_ref }} - - uses: backstage/actions/changeset-feedback@v0.6.4 + - uses: backstage/actions/changeset-feedback@v0.6.5 name: Generate feedback with: diff-ref: 'origin/master' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81ea6f96d2..23e81192c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} @@ -76,7 +76,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} @@ -207,7 +207,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 2874be512c..79cfe9d790 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -13,7 +13,7 @@ jobs: with: egress-policy: audit - - uses: backstage/actions/cron@v0.6.4 + - uses: backstage/actions/cron@v0.6.5 with: app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }} diff --git a/.github/workflows/deploy_docker-image.yml b/.github/workflows/deploy_docker-image.yml index 30e7badcd0..69776da2d9 100644 --- a/.github/workflows/deploy_docker-image.yml +++ b/.github/workflows/deploy_docker-image.yml @@ -31,7 +31,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/deploy_nightly.yml b/.github/workflows/deploy_nightly.yml index d5fe9f5a64..7184557e12 100644 --- a/.github/workflows/deploy_nightly.yml +++ b/.github/workflows/deploy_nightly.yml @@ -27,7 +27,7 @@ jobs: node-version: 18.x registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@497722d45fb5a35c14c721f63ed74e6ad5f629dd # v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v18.x diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml index f5ebfd7ee2..32f4b139f2 100644 --- a/.github/workflows/deploy_packages.yml +++ b/.github/workflows/deploy_packages.yml @@ -68,7 +68,7 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} @@ -150,7 +150,7 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/issue.yaml b/.github/workflows/issue.yaml index 1c1c028f20..f5150a3a01 100644 --- a/.github/workflows/issue.yaml +++ b/.github/workflows/issue.yaml @@ -15,4 +15,4 @@ jobs: egress-policy: audit - name: Issue sync - uses: backstage/actions/issue-sync@v0.6.4 + uses: backstage/actions/issue-sync@v0.6.5 diff --git a/.github/workflows/pr-review-comment.yaml b/.github/workflows/pr-review-comment.yaml index e557c85d51..8d66567ff4 100644 --- a/.github/workflows/pr-review-comment.yaml +++ b/.github/workflows/pr-review-comment.yaml @@ -40,7 +40,7 @@ jobs: const prNumber = artifact.name.slice('pr_number-'.length) core.setOutput('pr-number', prNumber); - - uses: backstage/actions/re-review@v0.6.4 + - uses: backstage/actions/re-review@v0.6.5 with: app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }} diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 8c99f105a9..89193c30ab 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -23,7 +23,7 @@ jobs: egress-policy: audit - name: PR sync - uses: backstage/actions/pr-sync@v0.6.4 + uses: backstage/actions/pr-sync@v0.6.5 with: github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index c9078f0086..0f99453bba 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -66,6 +66,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1 + uses: github/codeql-action/upload-sarif@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2.22.4 with: sarif_file: results.sarif diff --git a/.github/workflows/sync_code-formatting.yml b/.github/workflows/sync_code-formatting.yml index fa8d91f25a..610ad323e2 100644 --- a/.github/workflows/sync_code-formatting.yml +++ b/.github/workflows/sync_code-formatting.yml @@ -25,7 +25,7 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/sync_snyk-github-issues.yml b/.github/workflows/sync_snyk-github-issues.yml index 690e2fb76e..2be954746b 100644 --- a/.github/workflows/sync_snyk-github-issues.yml +++ b/.github/workflows/sync_snyk-github-issues.yml @@ -24,7 +24,7 @@ jobs: node-version: 18.x registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v18.x diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml index 6450b1bd51..84b2b3387b 100644 --- a/.github/workflows/sync_snyk-monitor.yml +++ b/.github/workflows/sync_snyk-monitor.yml @@ -58,6 +58,6 @@ jobs: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} NODE_OPTIONS: --max-old-space-size=7168 - name: Upload Snyk report - uses: github/codeql-action/upload-sarif@v2.21.8 + uses: github/codeql-action/upload-sarif@v2.22.4 with: sarif_file: snyk.sarif diff --git a/.github/workflows/uffizzi-build.yml b/.github/workflows/uffizzi-build.yml index 3107b87b8f..375c6bdcd2 100644 --- a/.github/workflows/uffizzi-build.yml +++ b/.github/workflows/uffizzi-build.yml @@ -34,7 +34,7 @@ jobs: registry-url: https://registry.npmjs.org/ - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: linux-v18 diff --git a/.github/workflows/verify_accessibility.yml b/.github/workflows/verify_accessibility.yml index d53f3834d2..f34f2f5506 100644 --- a/.github/workflows/verify_accessibility.yml +++ b/.github/workflows/verify_accessibility.yml @@ -30,7 +30,7 @@ jobs: with: node-version: 18.x - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v18.x - name: run Lighthouse CI diff --git a/.github/workflows/verify_codeql.yml b/.github/workflows/verify_codeql.yml index 666bd5f0f4..fb29fb78e1 100644 --- a/.github/workflows/verify_codeql.yml +++ b/.github/workflows/verify_codeql.yml @@ -55,7 +55,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2.21.8 + uses: github/codeql-action/init@v2.22.4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -66,7 +66,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2.21.8 + uses: github/codeql-action/autobuild@v2.22.4 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -80,4 +80,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.21.8 + uses: github/codeql-action/analyze@v2.22.4 diff --git a/.github/workflows/verify_e2e-kubernetes.yml b/.github/workflows/verify_e2e-kubernetes.yml index cddd6589d7..ea77609614 100644 --- a/.github/workflows/verify_e2e-kubernetes.yml +++ b/.github/workflows/verify_e2e-kubernetes.yml @@ -35,7 +35,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/verify_e2e-linux.yml b/.github/workflows/verify_e2e-linux.yml index 744a5d7e74..6b61053628 100644 --- a/.github/workflows/verify_e2e-linux.yml +++ b/.github/workflows/verify_e2e-linux.yml @@ -57,7 +57,7 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/verify_e2e-windows.yml b/.github/workflows/verify_e2e-windows.yml index 1385983c24..7ee5e596c5 100644 --- a/.github/workflows/verify_e2e-windows.yml +++ b/.github/workflows/verify_e2e-windows.yml @@ -78,7 +78,7 @@ jobs: uses: browser-actions/setup-chrome@803ef6dfb4fdf22089c9563225d95e4a515820a0 # latest - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/verify_storybook.yml b/.github/workflows/verify_storybook.yml index f0354df296..e992a2d658 100644 --- a/.github/workflows/verify_storybook.yml +++ b/.github/workflows/verify_storybook.yml @@ -42,7 +42,7 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@v0.6.4 + uses: backstage/actions/yarn-install@v0.6.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} - name: storybook yarn install diff --git a/REVIEWING.md b/REVIEWING.md index 43085090a8..7ed3fcda57 100644 --- a/REVIEWING.md +++ b/REVIEWING.md @@ -172,13 +172,7 @@ We generate API Reports using the [API Extractor](https://api-extractor.com/) to Each API report contains a list of all the exported types of each package. As long as the API report does not have any warnings it will contain the full publicly facing API of the package, meaning you do not need to consider any other changes to the package from the point of view of TypeScript API stability. -Exported types can be marked with either `@public`, `@alpha` or `@beta` release tags. It is only the `@public` exports that we consider to be part of the stable API. The `@alpha` and `@beta` exports are considered unstable and can be changed at any time without needing a breaking package versions bump. However, this **ONLY** applies if the package has been configured to use experimental type builds, which looks like this in `package.json`: - -```json - "build": "backstage-cli package build --experimental-type-build" -``` - -If a package does not have this configuration, then all exported types are considered stable, even if they are marked as `@alpha` or `@beta`. +Exported types can be marked with either `@public`, `@alpha` or `@beta` release tags. It is only the `@public` exports that we consider to be part of the stable API. The `@alpha` and `@beta` exports are considered unstable and can be changed at any time without needing a breaking package versions bump. #### Changes that are Not Considered Breaking diff --git a/docs/local-dev/cli-build-system.md b/docs/local-dev/cli-build-system.md index 61e24863fe..7bb4918d47 100644 --- a/docs/local-dev/cli-build-system.md +++ b/docs/local-dev/cli-build-system.md @@ -674,38 +674,3 @@ To add subpath exports to an existing package, simply add the desired `"exports" ```bash yarn backstage-cli migrate package-exports ``` - -## Experimental Type Build - -> Note: Experimental type builds are deprecated and will be removed in the future. They have been replaced by [subpath exports](#subpath-exports). - -The Backstage CLI has an experimental feature where multiple different type definition files can be generated for different release stages. The release stages are marked in the [TSDoc](https://tsdoc.org/) for each individual export, using either `@public`, `@alpha`, or `@beta`. Rather than just building a single `index.d.ts` file, the build process will instead output `index.d.ts`, `index.beta.d.ts`, and `index.alpha.d.ts`. Each of these files will have exports from more unstable release stages stripped, meaning that `index.d.ts` will omit all exports marked with `@alpha` or `@beta`, while `index.beta.d.ts` will omit all exports marked with `@alpha`. - -This feature is aimed at projects that publish to package registries and wish to maintain different levels of API stability within each package. There is no need to use this within a single monorepo, as it has no effect due to only applying to built and published packages. - -In order for the experimental type build to work, `@microsoft/api-extractor` must be installed in your project, as it is an optional peer dependency of the Backstage CLI. There are then three steps that need to be taken for each package where you want to enable this feature: - -- Add the `--experimental-type-build` flag to the `"build"` script of the package. -- Add either one or both of `"alphaTypes"` and `"betaTypes"` to the `"publishConfig"` of the package: - ```json - "publishConfig": { - ... - "types": "dist/index.d.ts", - "alphaTypes": "dist/index.alpha.d.ts", - "betaTypes": "dist/index.beta.d.ts" - }, - ``` -- Add either one or both of `"alpha"` and `"beta"` to the `"files"` of the package: - ```json - "files": [ - "dist", - "alpha", - "beta" - ] - ``` - -Once this setup is complete, users of the published packages will only be able to access the stable API via the main package entry point, for example `@acme/my-plugin`. Exports marked with `@alpha` or `@beta` will only be available via the `/alpha` entry point, for example `@acme/my-plugin/alpha`, and exports marked with `@beta` will only be available via `/beta`. This does not apply within the monorepo that contains the package. There all exports still have to be imported via the main entry point. - -Note that these different entry points are only separated during type checking. At runtime they all share the same code which contains the exports from all releases stages. - -An example of this setup can be seen in the [`@backstage/catalog-model`](https://github.com/backstage/backstage/blob/da0675bf9f28ed1460f03635a22d3c26abd14707/packages/catalog-model/package.json#L14) package, which has enabled `alpha` type exports. With this setup, exports marked as `@alpha` are only available for import via `@backstage/catalog-model/alpha`. The `@backstage/catalog-model` package currently does not have any exports marked as `@beta`, or a `/beta` entry point. diff --git a/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md b/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md index 839441e733..23bc4e84f1 100644 --- a/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md +++ b/docs/permissions/plugin-authors/02-adding-a-basic-permission-check.md @@ -349,7 +349,7 @@ import { createRouter } from './service/router'; /** * The example TODO list backend plugin. * -* @alpha +* @public */ export const exampleTodoListPlugin = createBackendPlugin({ pluginId: 'exampleTodoList', diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 81af3b91e7..ecc1f008b5 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -41,6 +41,7 @@ import { EntityAzurePullRequestsContent, isAzureDevOpsAvailable, isAzurePipelinesAvailable, + EntityAzureReadmeCard, } from '@backstage/plugin-azure-devops'; import { isOctopusDeployAvailable, @@ -415,6 +416,14 @@ const overviewContent = ( + + + + + + + + diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index bdfbc2c26b..dd2c6994f9 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -39,7 +39,7 @@ backend.add( import('@backstage/plugin-permission-backend-module-allow-all-policy'), ); backend.add(import('@backstage/plugin-permission-backend/alpha')); -backend.add(import('@backstage/plugin-proxy-backend')); +backend.add(import('@backstage/plugin-proxy-backend/alpha')); backend.add(import('@backstage/plugin-scaffolder-backend/alpha')); backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha')); backend.add(import('@backstage/plugin-search-backend-module-explore/alpha')); diff --git a/packages/cli-node/api-report.md b/packages/cli-node/api-report.md index 91148596b3..71be6c4bd4 100644 --- a/packages/cli-node/api-report.md +++ b/packages/cli-node/api-report.md @@ -53,8 +53,6 @@ export interface BackstagePackageJson { access?: 'public' | 'restricted'; directory?: string; registry?: string; - alphaTypes?: string; - betaTypes?: string; }; // (undocumented) scripts?: { diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index 421c49f9b9..fb46dbde26 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -56,8 +56,6 @@ export interface BackstagePackageJson { access?: 'public' | 'restricted'; directory?: string; registry?: string; - alphaTypes?: string; - betaTypes?: string; }; dependencies?: { diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 9d6ffd3f69..9292f2ea37 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -224,7 +224,6 @@ Usage: backstage-cli package build [options] Options: --role --minify - --experimental-type-build --skip-build-dependencies --stats --config diff --git a/packages/cli/package.json b/packages/cli/package.json index 689de4b20e..f397fd5865 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -174,16 +174,12 @@ "type-fest": "^2.19.0" }, "peerDependencies": { - "@microsoft/api-extractor": "^7.21.2", "@vitejs/plugin-react": "^4.0.4", "vite": "^4.4.9", "vite-plugin-html": "^3.2.0", "vite-plugin-node-polyfills": "^0.14.1" }, "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, "@vitejs/plugin-react": { "optional": true }, diff --git a/packages/cli/src/commands/build/command.ts b/packages/cli/src/commands/build/command.ts index 507085e0a0..1b3aeff2f9 100644 --- a/packages/cli/src/commands/build/command.ts +++ b/packages/cli/src/commands/build/command.ts @@ -65,6 +65,5 @@ export async function command(opts: OptionValues): Promise { return buildPackage({ outputs, minify: Boolean(opts.minify), - useApiExtractor: Boolean(opts.experimentalTypeBuild), }); } diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index ce4a4d2bae..fb0a55f37e 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -130,10 +130,6 @@ export function registerScriptCommand(program: Command) { '--minify', 'Minify the generated code. Does not apply to app or backend packages.', ) - .option( - '--experimental-type-build', - 'Enable experimental type build. Does not apply to app or backend packages. [DEPRECATED]', - ) .option( '--skip-build-dependencies', 'Skip the automatic building of local dependencies. Applies to backend packages only.', diff --git a/packages/cli/src/commands/migrate/packageScripts.ts b/packages/cli/src/commands/migrate/packageScripts.ts index 1552208a5f..e9ff20aed3 100644 --- a/packages/cli/src/commands/migrate/packageScripts.ts +++ b/packages/cli/src/commands/migrate/packageScripts.ts @@ -49,9 +49,6 @@ export async function command() { if (scripts.build?.includes('--minify')) { buildCmd.push('--minify'); } - if (scripts.build?.includes('--experimental-type-build')) { - buildCmd.push('--experimental-type-build'); - } if (scripts.build?.includes('--config')) { buildCmd.push(...(scripts.build.match(configArgPattern) ?? [])); } diff --git a/packages/cli/src/commands/repo/build.ts b/packages/cli/src/commands/repo/build.ts index 71e58c6201..32f227c208 100644 --- a/packages/cli/src/commands/repo/build.ts +++ b/packages/cli/src/commands/repo/build.ts @@ -137,7 +137,6 @@ export async function command(opts: OptionValues, cmd: Command): Promise { outputs, logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `, minify: buildOptions.minify, - useApiExtractor: buildOptions.experimentalTypeBuild, }; }); diff --git a/packages/cli/src/lib/builder/buildTypeDefinitions.ts b/packages/cli/src/lib/builder/buildTypeDefinitions.ts deleted file mode 100644 index 3bf3a4495f..0000000000 --- a/packages/cli/src/lib/builder/buildTypeDefinitions.ts +++ /dev/null @@ -1,129 +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 fs from 'fs-extra'; -import chalk from 'chalk'; -import { relative as relativePath, resolve as resolvePath } from 'path'; -import { paths } from '../paths'; -import { buildTypeDefinitionsWorker } from './buildTypeDefinitionsWorker'; -import { runWorkerThreads } from '../parallel'; - -// These message types are ignored since we want to avoid duplicating the logic of -// handling them correctly, and we already have the API Reports warning about them. -const ignoredMessages = new Set(['tsdoc-undefined-tag', 'ae-forgotten-export']); - -export async function buildTypeDefinitions( - targetDirs: string[] = [paths.targetDir], -) { - const packageDirs = targetDirs.map(dir => - relativePath(paths.targetRoot, dir), - ); - const entryPoints = await Promise.all( - packageDirs.map(async dir => { - const entryPoint = paths.resolveTargetRoot( - 'dist-types', - dir, - 'src/index.d.ts', - ); - - const declarationsExist = await fs.pathExists(entryPoint); - if (!declarationsExist) { - throw new Error( - `No declaration files found at ${entryPoint}, be sure to run ${chalk.bgRed.white( - 'yarn tsc', - )} to generate .d.ts files before packaging`, - ); - } - return entryPoint; - }), - ); - - const workerConfigs = packageDirs.map(packageDir => { - const targetDir = paths.resolveTargetRoot(packageDir); - const targetTypesDir = paths.resolveTargetRoot('dist-types', packageDir); - const extractorOptions = { - configObject: { - mainEntryPointFilePath: resolvePath(targetTypesDir, 'src/index.d.ts'), - bundledPackages: [], - - compiler: { - skipLibCheck: true, - tsconfigFilePath: paths.resolveTargetRoot('tsconfig.json'), - }, - - dtsRollup: { - enabled: true, - untrimmedFilePath: resolvePath(targetDir, 'dist/index.alpha.d.ts'), - betaTrimmedFilePath: resolvePath(targetDir, 'dist/index.beta.d.ts'), - publicTrimmedFilePath: resolvePath(targetDir, 'dist/index.d.ts'), - }, - - newlineKind: 'lf', - - projectFolder: targetDir, - }, - configObjectFullPath: targetDir, - packageJsonFullPath: resolvePath(targetDir, 'package.json'), - }; - return { extractorOptions, targetTypesDir }; - }); - - const typescriptDir = paths.resolveTargetRoot('node_modules/typescript'); - const hasTypescript = await fs.pathExists(typescriptDir); - const typescriptCompilerFolder = hasTypescript ? typescriptDir : undefined; - await runWorkerThreads({ - threadCount: 1, - workerData: { - entryPoints, - workerConfigs, - typescriptCompilerFolder, - }, - worker: buildTypeDefinitionsWorker, - onMessage: ({ - message, - targetTypesDir, - }: { - message: any; - targetTypesDir: string; - }) => { - if (ignoredMessages.has(message.messageId)) { - return; - } - - let text = `${message.text} (${message.messageId})`; - if (message.sourceFilePath) { - text += ' at '; - text += relativePath(targetTypesDir, message.sourceFilePath); - if (message.sourceFileLine) { - text += `:${message.sourceFileLine}`; - if (message.sourceFileColumn) { - text += `:${message.sourceFileColumn}`; - } - } - } - if (message.logLevel === 'error') { - console.error(chalk.red(`Error: ${text}`)); - } else if ( - message.logLevel === 'warning' || - message.category === 'Extractor' - ) { - console.warn(`Warning: ${text}`); - } else { - console.log(text); - } - }, - }); -} diff --git a/packages/cli/src/lib/builder/buildTypeDefinitionsWorker.ts b/packages/cli/src/lib/builder/buildTypeDefinitionsWorker.ts deleted file mode 100644 index c3c37d91cf..0000000000 --- a/packages/cli/src/lib/builder/buildTypeDefinitionsWorker.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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. - */ - -/** - * NOTE: This is a worker thread function that is stringified and executed - * within a `worker_threads.Worker`. Everything in this function must - * be self-contained. - * Using TypeScript is fine as it is transpiled before being stringified. - */ -export async function buildTypeDefinitionsWorker( - workerData: any, - sendMessage: (message: any) => void, -) { - try { - require('@microsoft/api-extractor'); - } catch (error) { - throw new Error( - 'Failed to resolve @microsoft/api-extractor, it must best installed ' + - 'as a dependency of your project in order to use experimental type builds', - ); - } - - const { dirname } = require('path'); - const { entryPoints, workerConfigs, typescriptCompilerFolder } = workerData; - - const apiExtractor = require('@microsoft/api-extractor'); - const { Extractor, ExtractorConfig, CompilerState } = apiExtractor; - - /** - * All of this monkey patching below is because Material UI has these bare package.json file as a method - * for making TypeScript accept imports like `@material-ui/core/Button`, and improve tree-shaking - * by declaring them side effect free. - * - * The package.json lookup logic in api-extractor really doesn't like that though, as it enforces - * that the 'name' field exists in all package.json files that it discovers. This below is just - * making sure that we ignore those file package.json files instead of crashing. - */ - const { - PackageJsonLookup, - // eslint-disable-next-line @backstage/no-undeclared-imports - } = require('@rushstack/node-core-library/lib/PackageJsonLookup'); - - const old = PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor; - PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor = - function tryGetPackageJsonFilePathForPatch(path: string) { - if ( - path.includes('@material-ui') && - !dirname(path).endsWith('@material-ui') - ) { - return undefined; - } - return old.call(this, path); - }; - - let compilerState; - for (const { extractorOptions, targetTypesDir } of workerConfigs) { - const extractorConfig = ExtractorConfig.prepare(extractorOptions); - - if (!compilerState) { - compilerState = CompilerState.create(extractorConfig, { - additionalEntryPoints: entryPoints, - }); - } - - const extractorResult = Extractor.invoke(extractorConfig, { - compilerState, - localBuild: false, - typescriptCompilerFolder, - showVerboseMessages: false, - showDiagnostics: false, - messageCallback: (message: any) => { - message.handled = true; - sendMessage({ message, targetTypesDir }); - }, - }); - - if (!extractorResult.succeeded) { - throw new Error( - `Type definition build completed with ${extractorResult.errorCount} errors` + - ` and ${extractorResult.warningCount} warnings`, - ); - } - } -} diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index f614581320..41e9dd6906 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -151,7 +151,7 @@ export async function makeRollupConfigs( }); } - if (options.outputs.has(Output.types) && !options.useApiExtractor) { + if (options.outputs.has(Output.types)) { const input = Object.fromEntries( scriptEntryPoints.map(e => [ e.name, diff --git a/packages/cli/src/lib/builder/packager.ts b/packages/cli/src/lib/builder/packager.ts index 780f9c2cfe..be9bc9a6ea 100644 --- a/packages/cli/src/lib/builder/packager.ts +++ b/packages/cli/src/lib/builder/packager.ts @@ -21,7 +21,6 @@ import { relative as relativePath, resolve as resolvePath } from 'path'; import { paths } from '../paths'; import { makeRollupConfigs } from './config'; import { BuildOptions, Output } from './types'; -import { buildTypeDefinitions } from './buildTypeDefinitions'; import { PackageRoles } from '@backstage/cli-node'; import { runParallelWorkers } from '../parallel'; @@ -112,10 +111,6 @@ export const buildPackage = async (options: BuildOptions) => { const buildTasks = rollupConfigs.map(rollupBuild); - if (options.outputs.has(Output.types) && options.useApiExtractor) { - buildTasks.push(buildTypeDefinitions()); - } - await Promise.all(buildTasks); }; @@ -131,18 +126,6 @@ export const buildPackages = async (options: BuildOptions[]) => { const buildTasks = rollupConfigs.flat().map(opts => () => rollupBuild(opts)); - const typeDefinitionTargetDirs = options - .filter( - ({ outputs, useApiExtractor }) => - outputs.has(Output.types) && useApiExtractor, - ) - .map(_ => _.targetDir!); - - if (typeDefinitionTargetDirs.length > 0) { - // Make sure this one is started first - buildTasks.unshift(() => buildTypeDefinitions(typeDefinitionTargetDirs)); - } - await runParallelWorkers({ items: buildTasks, worker: async task => task(), diff --git a/packages/cli/src/lib/builder/types.ts b/packages/cli/src/lib/builder/types.ts index 330419235f..7d9c34fe29 100644 --- a/packages/cli/src/lib/builder/types.ts +++ b/packages/cli/src/lib/builder/types.ts @@ -28,5 +28,4 @@ export type BuildOptions = { packageJson?: BackstagePackageJson; outputs: Set; minify?: boolean; - useApiExtractor?: boolean; }; diff --git a/packages/cli/src/lib/packager/createDistWorkspace.ts b/packages/cli/src/lib/packager/createDistWorkspace.ts index e91924dd37..00b8784d6d 100644 --- a/packages/cli/src/lib/packager/createDistWorkspace.ts +++ b/packages/cli/src/lib/packager/createDistWorkspace.ts @@ -206,7 +206,6 @@ export async function createDistWorkspace( logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `, // No need to detect these for the backend builds, we assume no minification or types minify: false, - useApiExtractor: false, }); } } diff --git a/packages/cli/src/lib/packager/productionPack.ts b/packages/cli/src/lib/packager/productionPack.ts index aea5df649a..6a59924a30 100644 --- a/packages/cli/src/lib/packager/productionPack.ts +++ b/packages/cli/src/lib/packager/productionPack.ts @@ -23,7 +23,7 @@ import { readEntryPoints } from '../entryPoints'; const PKG_PATH = 'package.json'; const PKG_BACKUP_PATH = 'package.json-prepack'; -const SKIPPED_KEYS = ['access', 'registry', 'tag', 'alphaTypes', 'betaTypes']; +const SKIPPED_KEYS = ['access', 'registry', 'tag']; const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx']; interface ProductionPackOptions { @@ -42,14 +42,6 @@ export async function productionPack(options: ProductionPackOptions) { await fs.writeFile(PKG_BACKUP_PATH, pkgContent); } - const hasStageEntry = - !!pkg.publishConfig?.alphaTypes || !!pkg.publishConfig?.betaTypes; - if (pkg.exports && hasStageEntry) { - throw new Error( - 'Combining both exports and alpha/beta types is not supported', - ); - } - // This mutates pkg to fill in index exports, so call it before applying publishConfig const writeCompatibilityEntryPoints = await prepareExportsEntryPoints( pkg, @@ -99,12 +91,6 @@ export async function productionPack(options: ProductionPackOptions) { await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 }); } - if (publishConfig.alphaTypes) { - await writeReleaseStageEntrypoint(pkg, 'alpha', targetDir ?? packageDir); - } - if (publishConfig.betaTypes) { - await writeReleaseStageEntrypoint(pkg, 'beta', targetDir ?? packageDir); - } if (writeCompatibilityEntryPoints) { await writeCompatibilityEntryPoints(targetDir ?? packageDir); } @@ -118,12 +104,6 @@ export async function revertProductionPack(packageDir: string) { // Check if we're shipping types for other release stages, clean up in that case const pkg = await fs.readJson(PKG_PATH); - if (pkg.publishConfig?.alphaTypes) { - await fs.remove(resolvePath(packageDir, 'alpha')); - } - if (pkg.publishConfig?.betaTypes) { - await fs.remove(resolvePath(packageDir, 'beta')); - } // Remove any extra entrypoint backwards compatibility directories const entryPoints = readEntryPoints(pkg); @@ -140,32 +120,6 @@ export async function revertProductionPack(packageDir: string) { } } -function resolveEntrypoint(pkg: any, name: string) { - const targetEntry = pkg.publishConfig[name] || pkg[name]; - return targetEntry && posixPath.join('..', targetEntry); -} - -// Writes e.g. alpha/package.json -async function writeReleaseStageEntrypoint( - pkg: BackstagePackageJson, - stage: 'alpha' | 'beta', - targetDir: string, -) { - await fs.ensureDir(resolvePath(targetDir, stage)); - await fs.writeJson( - resolvePath(targetDir, stage, PKG_PATH), - { - name: pkg.name, - version: pkg.version, - main: resolveEntrypoint(pkg, 'main'), - module: resolveEntrypoint(pkg, 'module'), - browser: resolveEntrypoint(pkg, 'browser'), - types: posixPath.join('..', pkg.publishConfig![`${stage}Types`]!), - }, - { encoding: 'utf8', spaces: 2 }, - ); -} - const EXPORT_MAP = { import: '.esm.js', require: '.cjs.js', diff --git a/packages/repo-tools/src/commands/api-reports/api-extractor.ts b/packages/repo-tools/src/commands/api-reports/api-extractor.ts index 13b6acb8fd..ce3e6f5b20 100644 --- a/packages/repo-tools/src/commands/api-reports/api-extractor.ts +++ b/packages/repo-tools/src/commands/api-reports/api-extractor.ts @@ -304,7 +304,6 @@ async function findPackageEntryPoints(packageDirs: string[]): Promise< Array<{ packageDir: string; name: string; - usesExperimentalTypeBuild?: boolean; }> > { return Promise.all( @@ -317,9 +316,6 @@ async function findPackageEntryPoints(packageDirs: string[]): Promise< getPackageExportNames(pkg)?.map(name => ({ packageDir, name })) ?? { packageDir, name: 'index', - usesExperimentalTypeBuild: pkg.scripts?.build?.includes( - '--experimental-type-build', - ), } ); }), @@ -367,11 +363,7 @@ export async function runApiExtraction({ } const warnings = new Array(); - for (const { - packageDir, - name, - usesExperimentalTypeBuild, - } of packageEntryPoints) { + for (const { packageDir, name } of packageEntryPoints) { console.log(`## Processing ${packageDir}`); const noBail = Array.isArray(allowWarnings) ? allowWarnings.some(aw => aw === packageDir || minimatch(packageDir, aw)) @@ -511,7 +503,6 @@ export async function runApiExtraction({ // The root index entrypoint is only allowed @public exports, while /alpha and /beta only allow @alpha and @beta. if ( validateReleaseTags && - !usesExperimentalTypeBuild && fs.pathExistsSync(extractorConfig.reportFilePath) ) { if (['index', 'alpha', 'beta'].includes(name)) { diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx index fa698ded58..6d0b191d68 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx @@ -52,6 +52,10 @@ describe('DefaultApiExplorerPage', () => { kind: 'API', metadata: { name: 'Entity1', + annotations: { + 'backstage.io/view-url': 'viewurl', + 'backstage.io/edit-url': 'editurl', + }, }, spec: { type: 'openapi' }, }, @@ -63,6 +67,11 @@ describe('DefaultApiExplorerPage', () => { getEntityFacets: async () => ({ facets: { 'relations.ownedBy': [] }, }), + queryEntities: async () => ({ + items: [], + pageInfo: {}, + totalItems: 0, + }), }; const configApi: ConfigApi = new ConfigReader({ @@ -152,37 +161,39 @@ describe('DefaultApiExplorerPage', () => { it('should render the default actions of an item in the grid', async () => { await renderWrapped(); - expect(await screen.findByText(/All apis \(1\)/)).toBeInTheDocument(); - expect(await screen.findByTitle(/View/)).toBeInTheDocument(); - expect(await screen.findByTitle(/View/)).toBeInTheDocument(); - expect(await screen.findByTitle(/Edit/)).toBeInTheDocument(); - expect(await screen.findByTitle(/Add to favorites/)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(/All apis \(1\)/)).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /view/i })).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); + expect(screen.getByTitle(/Add to favorites/)).toBeInTheDocument(); }); it('should render the custom actions of an item passed as prop', async () => { const actions: TableProps['actions'] = [ - () => { - return { - icon: () => , - tooltip: 'Foo Action', - disabled: false, - onClick: () => jest.fn(), - }; + { + icon: () => , + tooltip: 'Foo Action', + disabled: false, + onClick: jest.fn(), }, - () => { - return { - icon: () => , - tooltip: 'Bar Action', - disabled: true, - onClick: () => jest.fn(), - }; + { + icon: () => , + tooltip: 'Bar Action', + disabled: true, + onClick: jest.fn(), }, ]; await renderWrapped(); - expect(await screen.findByText(/All apis \(1\)/)).toBeInTheDocument(); - expect(await screen.findByTitle(/Foo Action/)).toBeInTheDocument(); - expect(await screen.findByTitle(/Bar Action/)).toBeInTheDocument(); - expect((await screen.findByTitle(/Bar Action/)).firstChild).toBeDisabled(); + await waitFor(() => { + expect(screen.getByText(/All apis \(1\)/)).toBeInTheDocument(); + }); + expect(screen.getByTitle(/Foo Action/)).toBeInTheDocument(); + expect(screen.getByTitle(/Bar Action/)).toBeInTheDocument(); + expect(screen.getByTitle(/Bar Action/).firstChild).toBeDisabled(); }); }); diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 41536f2a3f..6372c2ee60 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -538,9 +538,9 @@ export const providers: Readonly<{ | undefined, ) => AuthProviderFactory_2; resolvers: Readonly<{ - emailMatchingUserEntityProfileEmail: () => SignInResolver_2; - emailLocalPartMatchingUserEntityName: () => SignInResolver_2; - emailMatchingUserEntityAnnotation: () => SignInResolver_2; + emailLocalPartMatchingUserEntityName: () => SignInResolver; + emailMatchingUserEntityProfileEmail: () => SignInResolver; + emailMatchingUserEntityAnnotation(): SignInResolver; }>; }>; oauth2: Readonly<{ diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index f8953e1588..e719468ac1 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -179,6 +179,18 @@ export interface Config { }; }; /** @visibility frontend */ + microsoft?: { + [authEnv: string]: { + clientId: string; + /** + * @visibility secret + */ + clientSecret: string; + tenantId: string; + callbackUrl?: string; + }; + }; + /** @visibility frontend */ onelogin?: { [authEnv: string]: { clientId: string; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 61e7993ab3..6de6fddd58 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -42,7 +42,6 @@ "@backstage/plugin-auth-backend-module-github-provider": "workspace:^", "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^", "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", - "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", diff --git a/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts b/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts new file mode 100644 index 0000000000..dee37f0f6b --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { FakeMicrosoftAPI } from './fake'; + +describe('FakeMicrosoftAPI', () => { + const api = new FakeMicrosoftAPI(); + + describe('#token', () => { + it('exchanges auth codes', () => { + const { access_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('User.Read'), + }), + ); + + expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true); + }); + + it('supports scopes for the first requested audience only', () => { + const { access_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('someaudience/somescope User.Read'), + }), + ); + + expect(api.tokenHasScope(access_token, 'User.Read')).toBe(false); + }); + + it('special openid scopes do not count towards the 1-audience limit', () => { + const { access_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('openid offline_access User.Read'), + }), + ); + + expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true); + }); + + it('refreshes tokens', () => { + const { access_token } = api.token( + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: api.generateRefreshToken( + 'email openid profile User.Read', + ), + }), + ); + + expect( + api.tokenHasScope(access_token, 'email openid profile User.Read'), + ).toBe(true); + }); + it('requires `openid` scope for ID token', () => { + const { id_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('User.Read'), + }), + ); + + expect(id_token).toBeUndefined(); + }); + it('requires `offline_access` scope for refresh token', () => { + const { refresh_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('User.Read'), + }), + ); + + expect(refresh_token).toBeUndefined(); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.ts b/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.ts new file mode 100644 index 0000000000..9b3ca09c57 --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { decodeJwt } from 'jose'; + +type Claims = { aud: string; scp: string }; + +export class FakeMicrosoftAPI { + generateAccessToken(scope: string): string { + return this.tokenWithClaims(this.allClaimsForScope(scope)).access_token; + } + generateAuthCode(scope: string): string { + return this.encodeClaims(this.allClaimsForScope(scope)); + } + generateRefreshToken(scope: string): string { + return this.encodeClaims(this.allClaimsForScope(scope)); + } + token(formData: URLSearchParams): { + access_token: string; + scope: string; + refresh_token?: string; + id_token?: string; + } { + const scopeParameter = formData.get('scope'); + const claims = + (scopeParameter && this.allClaimsForScope(scopeParameter)) ?? + formData.get('grant_type') === 'refresh_token' + ? this.decodeClaims(formData.get('refresh_token')!) + : this.decodeClaims(formData.get('code')!); + return { + ...this.tokenWithClaims(claims), + ...(this.hasScope(claims, 'offline_access') && { + refresh_token: this.encodeClaims(claims), + }), + ...(this.hasScope(claims, 'openid') && { + id_token: 'header.e30K.microsoft', + }), + }; + } + tokenHasScope(token: string, scope: string): boolean { + const { aud, scp } = decodeJwt(token); + return this.hasScope({ aud: aud as string, scp: scp as string }, scope); + } + private tokenWithClaims(claims: Claims): { + access_token: string; + scope: string; + } { + const filteredClaims = { + ...claims, + scp: claims.scp + .split(' ') + .filter(s => s !== 'offline_access') + .join(' '), + }; + return { + access_token: `header.${Buffer.from( + JSON.stringify(filteredClaims), + ).toString('base64')}.signature`, + scope: this.scopeFromClaims(filteredClaims), + }; + } + private allClaimsForScope(scope: string): Claims { + const scopes = scope.split(' ').map(this.parseScope); + const firstAudience = scopes + .map(({ aud }) => aud) + .find(aud => aud !== 'openid'); + return { + aud: firstAudience ?? '00000003-0000-0000-c000-000000000000', + scp: scopes + .filter(({ aud }) => aud === 'openid' || aud === firstAudience) + .map(({ scp }) => scp) + .join(' '), + }; + } + // auth codes and refresh tokens in this fake system are base64-encoded JSON + // strings of claims + private encodeClaims(claims: Claims): string { + return Buffer.from(JSON.stringify(claims)).toString('base64'); + } + private decodeClaims(encoded: string): Claims { + return JSON.parse(Buffer.from(encoded, 'base64').toString()); + } + private hasScope(claims: Claims, scope: string): boolean { + return this.scopeFromClaims(claims).includes(scope); + } + private parseScope(s: string): Claims { + if (s.includes('/')) { + const [aud, scp] = s.split('/'); + return { aud, scp }; + } + switch (s) { + case 'email': + case 'openid': + case 'offline_access': + case 'profile': { + return { aud: 'openid', scp: s }; + } + default: + return { aud: '00000003-0000-0000-c000-000000000000', scp: s }; + } + } + private scopeFromClaims(claims: Claims): string { + return claims.scp + .split(' ') + .map(this.parseScope) + .map(({ aud, scp }) => + aud === 'openid' || + claims.aud === '00000003-0000-0000-c000-000000000000' + ? scp + : `${claims.aud}/${scp}`, + ) + .join(' '); + } +} diff --git a/plugins/auth-backend/src/providers/microsoft/provider.test.ts b/plugins/auth-backend/src/providers/microsoft/provider.test.ts new file mode 100644 index 0000000000..7bbfd085d6 --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/provider.test.ts @@ -0,0 +1,450 @@ +/* + * 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 { microsoft } from './provider'; +import { getVoidLogger } from '@backstage/backend-common'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { AuthProviderRouteHandlers, AuthResolverContext } from '../types'; +import express from 'express'; +import crypto from 'crypto'; +import { FakeMicrosoftAPI } from './__testUtils__/fake'; + +describe('MicrosoftAuthProvider', () => { + const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64 + const state = Buffer.from( + `nonce=${encodeURIComponent(nonce)}&env=development`, + ).toString('hex'); + const mockBackstageToken = `header.${Buffer.from( + JSON.stringify({ sub: 'user:default/mock' }), + 'utf8', + ).toString('base64')}.backstage`; + + const server = setupServer(); + const microsoftApi = new FakeMicrosoftAPI(); + let provider: AuthProviderRouteHandlers; + let response: jest.Mocked; + + setupRequestMockHandlers(server); + + beforeEach(() => { + provider = microsoft.create({ + signIn: { + resolver: microsoft.resolvers.emailMatchingUserEntityAnnotation(), + }, + })({ + providerId: 'microsoft', + baseUrl: 'http://backstage.test/api/auth', + appUrl: 'http://backstage.test', + isOriginAllowed: _ => true, + globalConfig: { + baseUrl: 'http://backstage.test/api/auth', + appUrl: 'http://backstage.test', + isOriginAllowed: _ => true, + }, + config: new ConfigReader({ + development: { + tenantId: 'tenantId', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }), + logger: getVoidLogger(), + resolverContext: { + issueToken: jest.fn(), + findCatalogUser: jest.fn(), + signInWithCatalogUser: async _ => ({ + token: mockBackstageToken, + }), + } as AuthResolverContext, + }) as AuthProviderRouteHandlers; + + server.use( + rest.post( + 'https://login.microsoftonline.com/tenantId/oauth2/v2.0/token', + async (req, res, ctx) => { + return res( + ctx.json({ + ...microsoftApi.token(new URLSearchParams(await req.text())), + token_type: 'Bearer', + expires_in: 123, + ext_expires_in: 123, + }), + ); + }, + ), + rest.get('https://graph.microsoft.com/v1.0/me/', (req, res, ctx) => { + if ( + !microsoftApi.tokenHasScope( + req.headers.get('authorization')!.replace(/^Bearer /, ''), + 'User.Read', + ) + ) { + return res(ctx.status(403)); + } + return res( + ctx.json({ + id: 'conrad', + displayName: 'Conrad', + surname: 'Ribas', + givenName: 'Francisco', + mail: 'conrad@example.com', + }), + ); + }), + rest.get( + 'https://graph.microsoft.com/v1.0/me/photos/*', + async (req, res, ctx) => { + if ( + !microsoftApi.tokenHasScope( + req.headers.get('authorization')!.replace(/^Bearer /, ''), + 'User.Read', + ) + ) { + return res(ctx.status(403)); + } + const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer; + return res( + ctx.set('Content-Length', imageBuffer.byteLength.toString()), + ctx.set('Content-Type', 'image/jpeg'), + ctx.body(imageBuffer), + ); + }, + ), + ); + response = { + cookie: jest.fn(), + end: jest.fn(), + json: jest.fn(), + setHeader: jest.fn(), + status: jest.fn(), + } as unknown as jest.Mocked; + response.status.mockReturnValue(response); + }); + + describe('#start', () => { + const randomBytes = jest.spyOn( + crypto, + 'randomBytes', + ) as unknown as jest.MockedFunction<(size: number) => Buffer>; + + afterEach(() => { + randomBytes.mockRestore(); + }); + + it('redirects to authorize URL', async () => { + randomBytes.mockReturnValue(Buffer.from(nonce, 'base64')); + + await provider.start( + { + query: { + env: 'development', + scope: 'email openid profile User.Read', + }, + } as unknown as express.Request, + response, + ); + + expect(response.setHeader).toHaveBeenCalledWith( + 'Location', + 'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' + + '?response_type=code' + + `&redirect_uri=${encodeURIComponent( + 'http://backstage.test/api/auth/microsoft/handler/frame', + )}` + + `&scope=${encodeURIComponent('email openid profile User.Read')}` + + `&state=${state}` + + '&client_id=clientId', + ); + }); + }); + + describe('#handle', () => { + it('returns provider info and profile with photo data', async () => { + await provider.frameHandler( + { + query: { + env: 'development', + code: microsoftApi.generateAuthCode( + 'email openid profile User.Read', + ), + state, + }, + cookies: { + 'microsoft-nonce': nonce, + }, + } as unknown as express.Request, + response, + ); + + expect(response.end).toHaveBeenCalledWith( + expect.stringContaining( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + providerInfo: { + accessToken: microsoftApi.generateAccessToken( + 'email openid profile User.Read', + ), + scope: 'email openid profile User.Read', + expiresInSeconds: 123, + idToken: 'header.e30K.microsoft', + }, + profile: { + email: 'conrad@example.com', + picture: 'data:image/jpeg;base64,aG93ZHk=', + displayName: 'Conrad', + }, + backstageIdentity: { + token: mockBackstageToken, + identity: { + type: 'user', + userEntityRef: 'user:default/mock', + ownershipEntityRefs: [], + }, + }, + }, + }), + ), + ), + ); + }); + + it('returns access token for non-microsoft graph scope', async () => { + await provider.frameHandler( + { + query: { + env: 'development', + code: microsoftApi.generateAuthCode('aks-audience/user.read'), + state, + }, + cookies: { + 'microsoft-nonce': nonce, + }, + } as unknown as express.Request, + response, + ); + + expect(response.end).toHaveBeenCalledWith( + expect.stringContaining( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + providerInfo: { + accessToken: microsoftApi.generateAccessToken( + 'aks-audience/user.read', + ), + scope: 'aks-audience/user.read', + expiresInSeconds: 123, + }, + profile: {}, + }, + }), + ), + ), + ); + }); + + it('sets refresh token', async () => { + await provider.frameHandler( + { + query: { + env: 'development', + code: microsoftApi.generateAuthCode( + 'email offline_access openid profile User.Read', + ), + state, + }, + cookies: { + 'microsoft-nonce': nonce, + }, + } as unknown as express.Request, + response, + ); + + expect(response.cookie).toHaveBeenCalledWith( + 'microsoft-refresh-token', + microsoftApi.generateRefreshToken( + 'email offline_access openid profile User.Read', + ), + { + domain: 'backstage.test', + httpOnly: true, + maxAge: 86400000000, + path: '/api/auth/microsoft', + sameSite: 'lax', + secure: false, + }, + ); + }); + + it('omits photo data when fetching it fails', async () => { + server.use( + rest.get('https://graph.microsoft.com/v1.0/me/photos/*', (_, res) => + res.networkError('remote hung up'), + ), + ); + + await provider.frameHandler( + { + query: { + env: 'development', + code: microsoftApi.generateAuthCode( + 'email openid profile User.Read', + ), + state, + }, + cookies: { + 'microsoft-nonce': nonce, + }, + } as unknown as express.Request, + response, + ); + + expect(response.end).toHaveBeenCalledWith( + expect.stringContaining( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + providerInfo: { + accessToken: microsoftApi.generateAccessToken( + 'email openid profile User.Read', + ), + scope: 'email openid profile User.Read', + expiresInSeconds: 123, + idToken: 'header.e30K.microsoft', + }, + profile: { + email: 'conrad@example.com', + displayName: 'Conrad', + }, + backstageIdentity: { + token: mockBackstageToken, + identity: { + type: 'user', + userEntityRef: 'user:default/mock', + ownershipEntityRefs: [], + }, + }, + }, + }), + ), + ), + ); + }); + }); + + describe('#refresh', () => { + it('returns provider info and profile with photo data', async () => { + await provider.refresh!( + { + query: { + env: 'development', + scope: 'email openid profile User.Read', + }, + header: jest.fn(_ => 'XMLHttpRequest'), + cookies: { + 'microsoft-refresh-token': microsoftApi.generateRefreshToken( + 'email openid profile User.Read', + ), + }, + get: jest.fn(), + } as unknown as express.Request, + response, + ); + + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + providerInfo: { + accessToken: microsoftApi.generateAccessToken( + 'email openid profile User.Read', + ), + scope: 'email openid profile User.Read', + expiresInSeconds: 123, + idToken: 'header.e30K.microsoft', + }, + profile: { + email: 'conrad@example.com', + picture: 'data:image/jpeg;base64,aG93ZHk=', + displayName: 'Conrad', + }, + }), + ); + }); + + it('returns access token for non-microsoft graph scope', async () => { + await provider.refresh!( + { + query: { + env: 'development', + scope: 'aks-audience/user.read', + }, + header: jest.fn(_ => 'XMLHttpRequest'), + cookies: { + 'microsoft-refresh-token': microsoftApi.generateRefreshToken( + 'aks-audience/user.read', + ), + }, + get: jest.fn(), + } as unknown as express.Request, + response, + ); + + expect(response.json).toHaveBeenCalledWith({ + providerInfo: { + accessToken: microsoftApi.generateAccessToken( + 'aks-audience/user.read', + ), + expiresInSeconds: 123, + scope: 'aks-audience/user.read', + }, + profile: {}, + }); + }); + + it('returns backstage identity', async () => { + await provider.refresh!( + { + query: { + env: 'development', + scope: 'email openid profile User.Read', + }, + header: jest.fn(_ => 'XMLHttpRequest'), + cookies: { + 'microsoft-refresh-token': microsoftApi.generateRefreshToken( + 'email openid profile User.Read', + ), + }, + get: jest.fn(), + } as unknown as express.Request, + response, + ); + + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + backstageIdentity: expect.objectContaining({ + token: mockBackstageToken, + }), + }), + ); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index 8947c7432b..8fc8459f10 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -14,25 +14,218 @@ * limitations under the License. */ -import { SignInResolver, AuthHandler } from '../types'; -import { OAuthResult } from '../../lib/oauth'; +import express from 'express'; +import passport from 'passport'; +import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; +import { + encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, + OAuthRefreshRequest, + OAuthResponse, + OAuthResult, + OAuthStartRequest, +} from '../../lib/oauth'; +import { + executeFetchUserProfileStrategy, + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, + PassportDoneCallback, +} from '../../lib/passport'; +import { + AuthHandler, + OAuthStartResponse, + SignInResolver, + AuthResolverContext, +} from '../types'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { - commonSignInResolvers, - createOAuthProviderFactory, -} from '@backstage/plugin-auth-node'; -import { - adaptLegacyOAuthHandler, - adaptLegacyOAuthSignInResolver, - adaptOAuthSignInResolverToLegacy, -} from '../../lib/legacy'; -import { - microsoftAuthenticator, - microsoftSignInResolvers, -} from '@backstage/plugin-auth-backend-module-microsoft-provider'; + commonByEmailLocalPartResolver, + commonByEmailResolver, +} from '../resolvers'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import fetch from 'node-fetch'; +import { decodeJwt } from 'jose'; +import { Profile as PassportProfile } from 'passport'; +import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session'; + +type PrivateInfo = { + refreshToken: string; +}; + +type Options = OAuthProviderOptions & { + signInResolver?: SignInResolver; + authHandler: AuthHandler; + logger: LoggerService; + resolverContext: AuthResolverContext; + authorizationUrl?: string; + tokenUrl?: string; +}; + +export class MicrosoftAuthProvider implements OAuthHandlers { + private readonly _strategy: MicrosoftStrategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly logger: LoggerService; + private readonly resolverContext: AuthResolverContext; + + constructor(options: Options) { + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.logger = options.logger; + this.resolverContext = options.resolverContext; + + this._strategy = new MicrosoftStrategy( + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + authorizationURL: options.authorizationUrl, + tokenURL: options.tokenUrl, + passReqToCallback: false, + skipUserProfile: ( + accessToken: string, + done: (err: unknown, skip: boolean) => void, + ) => { + done(null, this.skipUserProfile(accessToken)); + }, + }, + ( + accessToken: any, + refreshToken: any, + params: any, + fullProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + done(undefined, { fullProfile, accessToken, params }, { refreshToken }); + }, + ); + } + + private skipUserProfile = (accessToken: string): boolean => { + const { aud, scp } = decodeJwt(accessToken); + const hasGraphReadScope = + aud === '00000003-0000-0000-c000-000000000000' && + (scp as string) + .split(' ') + .map(s => s.toLowerCase()) + .includes('user.read'); + return !hasGraphReadScope; + }; + + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { + scope: req.scope, + state: encodeState(req.state), + }); + } + + async handler(req: express.Request) { + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, + PrivateInfo + >(req, this._strategy); + + return { + response: await this.handleResult(result), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(req: OAuthRefreshRequest) { + const { accessToken, refreshToken, params } = + await executeRefreshTokenStrategy( + this._strategy, + req.refreshToken, + req.scope, + ); + + return { + response: await this.handleResult({ + params, + accessToken, + ...(!this.skipUserProfile(accessToken) && { + fullProfile: await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + ), + }), + }), + refreshToken, + }; + } + + private async handleResult(result: { + fullProfile?: PassportProfile; + params: { + id_token?: string; + scope: string; + expires_in: number; + }; + accessToken: string; + refreshToken?: string; + }): Promise { + let profile = {}; + if (result.fullProfile) { + const photo = await this.getUserPhoto(result.accessToken); + result.fullProfile.photos = photo ? [{ value: photo }] : undefined; + ({ profile } = await this.authHandler( + result as OAuthResult, + this.resolverContext, + )); + } + + const expiresInSeconds = + result.params.expires_in === undefined + ? BACKSTAGE_SESSION_EXPIRATION + : Math.min(result.params.expires_in, BACKSTAGE_SESSION_EXPIRATION); + + return { + providerInfo: { + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds, + ...{ idToken: result.params.id_token }, + }, + profile, + ...(result.fullProfile && + this.signInResolver && { + backstageIdentity: await this.signInResolver( + { result: result as OAuthResult, profile }, + this.resolverContext, + ), + }), + }; + } + + private async getUserPhoto(accessToken: string): Promise { + try { + const res = await fetch( + 'https://graph.microsoft.com/v1.0/me/photos/48x48/$value', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + const data = await res.buffer(); + + return `data:image/jpeg;base64,${data.toString('base64')}`; + } catch (error) { + this.logger.warn( + `Could not retrieve user profile photo from Microsoft Graph API: ${error}`, + ); + return undefined; + } + } +} /** - * Auth provider integration for GitLab auth + * Auth provider integration for Microsoft auth * * @public */ @@ -48,21 +241,75 @@ export const microsoft = createAuthProviderIntegration({ * Configure sign-in for this provider, without it the provider can not be used to sign users in. */ signIn?: { + /** + * Maps an auth result to a Backstage identity for the user. + */ resolver: SignInResolver; }; }) { - return createOAuthProviderFactory({ - authenticator: microsoftAuthenticator, - profileTransform: adaptLegacyOAuthHandler(options?.authHandler), - signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver), - }); + return ({ providerId, globalConfig, config, logger, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const tenantId = envConfig.getString('tenantId'); + + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`; + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile ?? {}, params.id_token), + }); + + const provider = new MicrosoftAuthProvider({ + clientId, + clientSecret, + callbackUrl, + authorizationUrl, + tokenUrl, + authHandler, + signInResolver: options?.signIn?.resolver, + logger, + resolverContext, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + providerId, + callbackUrl, + }); + }); + }, + resolvers: { + /** + * Looks up the user by matching their email local part to the entity name. + */ + emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver, + /** + * Looks up the user by matching their email to the entity email. + */ + emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, + /** + * Looks up the user by matching their email to the `microsoft.com/email` annotation. + */ + emailMatchingUserEntityAnnotation(): SignInResolver { + return async (info, ctx) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Microsoft profile contained no email'); + } + + return ctx.signInWithCatalogUser({ + annotations: { + 'microsoft.com/email': profile.email, + }, + }); + }; + }, }, - resolvers: adaptOAuthSignInResolverToLegacy({ - emailLocalPartMatchingUserEntityName: - commonSignInResolvers.emailLocalPartMatchingUserEntityName(), - emailMatchingUserEntityProfileEmail: - commonSignInResolvers.emailMatchingUserEntityProfileEmail(), - emailMatchingUserEntityAnnotation: - microsoftSignInResolvers.emailMatchingUserEntityAnnotation(), - }), }); diff --git a/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx index ad278e3fc4..02a61a9a9a 100644 --- a/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx +++ b/plugins/azure-devops/src/components/EntityPageAzurePipelines/EntityPageAzurePipelines.tsx @@ -16,14 +16,14 @@ import { BuildTable } from '../BuildTable/BuildTable'; import React from 'react'; -import { getAnnotationFromEntity } from '../../utils/getAnnotationFromEntity'; +import { getAnnotationValuesFromEntity } from '../../utils/getAnnotationValuesFromEntity'; import { useBuildRuns } from '../../hooks/useBuildRuns'; import { useEntity } from '@backstage/plugin-catalog-react'; export const EntityPageAzurePipelines = (props: { defaultLimit?: number }) => { const { entity } = useEntity(); - const { project, repo, definition } = getAnnotationFromEntity(entity); + const { project, repo, definition } = getAnnotationValuesFromEntity(entity); const { items, loading, error } = useBuildRuns( project, diff --git a/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx b/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx index d06726e929..420fe6ecf4 100644 --- a/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx +++ b/plugins/azure-devops/src/components/ReadmeCard/ReadmeCard.tsx @@ -23,11 +23,9 @@ import { ErrorPanel, } from '@backstage/core-components'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { useProjectRepoFromEntity } from '../../hooks'; -import { useApi } from '@backstage/core-plugin-api'; import React from 'react'; -import { azureDevOpsApiRef } from '../../api'; -import useAsync from 'react-use/lib/useAsync'; + +import { useReadme } from '../../hooks'; const useStyles = makeStyles(theme => ({ readMe: { @@ -85,18 +83,9 @@ const ReadmeCardError = ({ error }: ErrorProps) => { export const ReadmeCard = (props: Props) => { const classes = useStyles(); - const api = useApi(azureDevOpsApiRef); const { entity } = useEntity(); - const { project, repo } = useProjectRepoFromEntity(entity); - const { loading, error, value } = useAsync( - () => - api.getReadme({ - project, - repo, - }), - [api, project, repo, entity], - ); + const { loading, error, item: value } = useReadme(entity); if (loading) { return ; diff --git a/plugins/azure-devops/src/constants.ts b/plugins/azure-devops/src/constants.ts index 95e5be4930..aba0ec093a 100644 --- a/plugins/azure-devops/src/constants.ts +++ b/plugins/azure-devops/src/constants.ts @@ -16,6 +16,7 @@ export const AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION = 'dev.azure.com/build-definition'; +export const AZURE_DEVOPS_HOST_ORG_ANNOTATION = 'dev.azure.com/host-org'; export const AZURE_DEVOPS_PROJECT_ANNOTATION = 'dev.azure.com/project'; export const AZURE_DEVOPS_REPO_ANNOTATION = 'dev.azure.com/project-repo'; export const AZURE_DEVOPS_DEFAULT_TOP: number = 10; diff --git a/plugins/azure-devops/src/hooks/index.ts b/plugins/azure-devops/src/hooks/index.ts index a3864c21f7..ab1a3c9998 100644 --- a/plugins/azure-devops/src/hooks/index.ts +++ b/plugins/azure-devops/src/hooks/index.ts @@ -16,8 +16,8 @@ export * from './useAllTeams'; export * from './useDashboardPullRequests'; -export * from './useProjectRepoFromEntity'; export * from './usePullRequests'; export * from './useRepoBuilds'; export * from './useUserEmail'; export * from './useUserTeamIds'; +export * from './useReadme'; diff --git a/plugins/azure-devops/src/hooks/useGitTags.test.tsx b/plugins/azure-devops/src/hooks/useGitTags.test.tsx new file mode 100644 index 0000000000..1a5620e16e --- /dev/null +++ b/plugins/azure-devops/src/hooks/useGitTags.test.tsx @@ -0,0 +1,130 @@ +/* + * 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 React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useGitTags } from './useGitTags'; +import { Entity } from '@backstage/catalog-model'; +import { GitTag } from '@backstage/plugin-azure-devops-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { AzureDevOpsApi, azureDevOpsApiRef } from '../api'; + +describe('useGitTags', () => { + const azureDevOpsApiMock = { + getGitTags: jest.fn(), + }; + const azureDevOpsApi = + azureDevOpsApiMock as Partial as AzureDevOpsApi; + + const Wrapper = (props: { children?: React.ReactNode }) => ( + + {props.children} + + ); + + it('should provide an array of GitTag', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'projectName/repoName', + }, + }, + }; + const tags: GitTag[] = [ + { + objectId: 'tag-1', + peeledObjectId: 'tag-1', + name: 'tag-1', + createdBy: 'awanlin', + link: 'https://dev.azure.com/org/project/repo', + commitLink: 'https://dev.azure.com/org/project/repo/commit-sha-1', + }, + { + objectId: 'tag-2', + peeledObjectId: 'tag-2', + name: 'tag-2', + createdBy: 'awanlin', + link: 'https://dev.azure.com/org/project/repo', + commitLink: 'https://dev.azure.com/org/project/repo/commit-sha-2', + }, + { + objectId: 'tag-3', + peeledObjectId: 'tag-3', + name: 'tag-3', + createdBy: 'awanlin', + link: 'https://dev.azure.com/org/project/repo', + commitLink: 'https://dev.azure.com/org/project/repo/commit-sha-3', + }, + ]; + azureDevOpsApiMock.getGitTags.mockResolvedValue({ items: tags }); + const { result } = renderHook(() => useGitTags(entity), { + wrapper: Wrapper, + }); + + expect(result.current.loading).toEqual(true); + + await waitFor(() => { + expect(result.current).toEqual({ + error: undefined, + items: tags, + loading: false, + }); + }); + }); + + it('should return throw when annotation missing', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + }, + }; + + expect(() => + renderHook(() => useGitTags(entity), { + wrapper: Wrapper, + }), + ).toThrow('Value for annotation "dev.azure.com/project" was not found'); + }); + + it('should return throw when annotation invalid', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'fake', + }, + }, + }; + + expect(() => + renderHook(() => useGitTags(entity), { + wrapper: Wrapper, + }), + ).toThrow( + 'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: /, found: "fake"', + ); + }); +}); diff --git a/plugins/azure-devops/src/hooks/useGitTags.ts b/plugins/azure-devops/src/hooks/useGitTags.ts index b940a7105d..13c7118934 100644 --- a/plugins/azure-devops/src/hooks/useGitTags.ts +++ b/plugins/azure-devops/src/hooks/useGitTags.ts @@ -20,7 +20,7 @@ import { Entity } from '@backstage/catalog-model'; import { azureDevOpsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; -import { useProjectRepoFromEntity } from './useProjectRepoFromEntity'; +import { getAnnotationValuesFromEntity } from '../utils'; export function useGitTags(entity: Entity): { items?: GitTag[]; @@ -28,10 +28,10 @@ export function useGitTags(entity: Entity): { error?: Error; } { const api = useApi(azureDevOpsApiRef); - const { project, repo } = useProjectRepoFromEntity(entity); + const { project, repo } = getAnnotationValuesFromEntity(entity); - const { value, loading, error } = useAsync(() => { - return api.getGitTags(project, repo); + const { value, loading, error } = useAsync(async () => { + return await api.getGitTags(project, repo as string); }, [api, project, repo]); return { diff --git a/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts b/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts deleted file mode 100644 index b484bca009..0000000000 --- a/plugins/azure-devops/src/hooks/useProjectRepoFromEntity.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 { Entity } from '@backstage/catalog-model'; -import { AZURE_DEVOPS_REPO_ANNOTATION } from '../constants'; - -export function useProjectRepoFromEntity(entity: Entity): { - project: string; - repo: string; -} { - const [project, repo] = ( - entity.metadata.annotations?.[AZURE_DEVOPS_REPO_ANNOTATION] ?? '' - ).split('/'); - - if (!project && !repo) { - throw new Error( - 'Value for annotation dev.azure.com/project-repo was not in the correct format: /', - ); - } - - if (!project) { - throw new Error( - 'Project Name for annotation dev.azure.com/project-repo was not found; expected format is: /', - ); - } - - if (!repo) { - throw new Error( - 'Repo Name for annotation dev.azure.com/project-repo was not found; expected format is: /', - ); - } - - return { project, repo }; -} diff --git a/plugins/azure-devops/src/hooks/usePullRequests.test.tsx b/plugins/azure-devops/src/hooks/usePullRequests.test.tsx new file mode 100644 index 0000000000..3e51c003e6 --- /dev/null +++ b/plugins/azure-devops/src/hooks/usePullRequests.test.tsx @@ -0,0 +1,129 @@ +/* + * 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 React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { PullRequest } from '@backstage/plugin-azure-devops-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { AzureDevOpsApi, azureDevOpsApiRef } from '../api'; +import { usePullRequests } from './usePullRequests'; + +describe('usePullRequests', () => { + const azureDevOpsApiMock = { + getPullRequests: jest.fn(), + }; + const azureDevOpsApi = + azureDevOpsApiMock as Partial as AzureDevOpsApi; + + const Wrapper = (props: { children?: React.ReactNode }) => ( + + {props.children} + + ); + + it('should provide an array of PullRequest', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'projectName/repoName', + }, + }, + }; + const pullRequests: PullRequest[] = [ + { + pullRequestId: 1, + repoName: 'repo', + title: 'title-1', + createdBy: 'awanlin', + link: 'https://dev.azure.com/org/project/repo', + }, + { + pullRequestId: 2, + repoName: 'repo', + title: 'title-2', + createdBy: 'awanlin', + link: 'https://dev.azure.com/org/project/repo', + }, + { + pullRequestId: 3, + repoName: 'repo', + title: 'title-3', + createdBy: 'awanlin', + link: 'https://dev.azure.com/org/project/repo', + }, + ]; + azureDevOpsApiMock.getPullRequests.mockResolvedValue({ + items: pullRequests, + }); + const { result } = renderHook(() => usePullRequests(entity), { + wrapper: Wrapper, + }); + + expect(result.current.loading).toEqual(true); + + await waitFor(() => { + expect(result.current).toEqual({ + error: undefined, + items: pullRequests, + loading: false, + }); + }); + }); + + it('should return throw when annotation missing', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + }, + }; + + expect(() => + renderHook(() => usePullRequests(entity), { + wrapper: Wrapper, + }), + ).toThrow('Value for annotation "dev.azure.com/project" was not found'); + }); + + it('should return throw when annotation invalid', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'fake', + }, + }, + }; + + expect(() => + renderHook(() => usePullRequests(entity), { + wrapper: Wrapper, + }), + ).toThrow( + 'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: /, found: "fake"', + ); + }); +}); diff --git a/plugins/azure-devops/src/hooks/usePullRequests.ts b/plugins/azure-devops/src/hooks/usePullRequests.ts index 9ddca9650b..c4223871f2 100644 --- a/plugins/azure-devops/src/hooks/usePullRequests.ts +++ b/plugins/azure-devops/src/hooks/usePullRequests.ts @@ -25,7 +25,7 @@ import { Entity } from '@backstage/catalog-model'; import { azureDevOpsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; -import { useProjectRepoFromEntity } from './useProjectRepoFromEntity'; +import { getAnnotationValuesFromEntity } from '../utils'; export function usePullRequests( entity: Entity, @@ -44,10 +44,10 @@ export function usePullRequests( }; const api = useApi(azureDevOpsApiRef); - const { project, repo } = useProjectRepoFromEntity(entity); + const { project, repo } = getAnnotationValuesFromEntity(entity); - const { value, loading, error } = useAsync(() => { - return api.getPullRequests(project, repo, options); + const { value, loading, error } = useAsync(async () => { + return await api.getPullRequests(project, repo as string, options); }, [api, project, repo, top, status]); return { diff --git a/plugins/azure-devops/src/hooks/useReadme.test.tsx b/plugins/azure-devops/src/hooks/useReadme.test.tsx new file mode 100644 index 0000000000..ffc33c136b --- /dev/null +++ b/plugins/azure-devops/src/hooks/useReadme.test.tsx @@ -0,0 +1,108 @@ +/* + * 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 React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { Readme } from '@backstage/plugin-azure-devops-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { AzureDevOpsApi, azureDevOpsApiRef } from '../api'; +import { useReadme } from './useReadme'; + +describe('useReadme', () => { + const azureDevOpsApiMock = { + getReadme: jest.fn(), + }; + const azureDevOpsApi = + azureDevOpsApiMock as Partial as AzureDevOpsApi; + + const Wrapper = (props: { children?: React.ReactNode }) => ( + + {props.children} + + ); + + it('should provide a Readme', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'projectName/repoName', + }, + }, + }; + const readme: Readme = { + url: 'https://dev.azure.com/org/project/repo', + content: 'This is some fake README content', + }; + azureDevOpsApiMock.getReadme.mockResolvedValue({ + item: readme, + }); + const { result } = renderHook(() => useReadme(entity), { + wrapper: Wrapper, + }); + + expect(result.current.loading).toEqual(true); + + await waitFor(() => { + expect(result.current.item).toEqual({ + item: readme, + }); + }); + }); + + it('should return throw when annotation missing', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + }, + }; + + expect(() => + renderHook(() => useReadme(entity), { + wrapper: Wrapper, + }), + ).toThrow('Value for annotation "dev.azure.com/project" was not found'); + }); + + it('should return throw when annotation invalid', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'fake', + }, + }, + }; + + expect(() => + renderHook(() => useReadme(entity), { + wrapper: Wrapper, + }), + ).toThrow( + 'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: /, found: "fake"', + ); + }); +}); diff --git a/plugins/azure-devops/src/hooks/useReadme.ts b/plugins/azure-devops/src/hooks/useReadme.ts new file mode 100644 index 0000000000..60b2e21990 --- /dev/null +++ b/plugins/azure-devops/src/hooks/useReadme.ts @@ -0,0 +1,42 @@ +/* + * 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 { Readme } from '@backstage/plugin-azure-devops-common'; + +import { Entity } from '@backstage/catalog-model'; +import { azureDevOpsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { getAnnotationValuesFromEntity } from '../utils'; + +export function useReadme(entity: Entity): { + item?: Readme; + loading: boolean; + error?: Error; +} { + const api = useApi(azureDevOpsApiRef); + const { project, repo } = getAnnotationValuesFromEntity(entity); + + const { value, loading, error } = useAsync(async () => { + return await api.getReadme({ project, repo: repo as string }); + }, [api, project, repo]); + + return { + item: value, + loading, + error, + }; +} diff --git a/plugins/azure-devops/src/hooks/useRepoBuilds.test.tsx b/plugins/azure-devops/src/hooks/useRepoBuilds.test.tsx new file mode 100644 index 0000000000..99eaeb1ce3 --- /dev/null +++ b/plugins/azure-devops/src/hooks/useRepoBuilds.test.tsx @@ -0,0 +1,126 @@ +/* + * 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 React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { RepoBuild } from '@backstage/plugin-azure-devops-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { AzureDevOpsApi, azureDevOpsApiRef } from '../api'; +import { useRepoBuilds } from './useRepoBuilds'; + +describe('useRepoBuilds', () => { + const azureDevOpsApiMock = { + getRepoBuilds: jest.fn(), + }; + const azureDevOpsApi = + azureDevOpsApiMock as Partial as AzureDevOpsApi; + + const Wrapper = (props: { children?: React.ReactNode }) => ( + + {props.children} + + ); + + it('should provide an array of RepoBuild', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'projectName/repoName', + }, + }, + }; + const repoBuilds: RepoBuild[] = [ + { + id: 1, + title: 'title-1', + source: 'awanlin', + link: 'https://dev.azure.com/org/project/repo', + }, + { + id: 2, + title: 'title-2', + source: 'awanlin', + link: 'https://dev.azure.com/org/project/repo', + }, + { + id: 3, + title: 'title-3', + source: 'awanlin', + link: 'https://dev.azure.com/org/project/repo', + }, + ]; + azureDevOpsApiMock.getRepoBuilds.mockResolvedValue({ + items: repoBuilds, + }); + const { result } = renderHook(() => useRepoBuilds(entity), { + wrapper: Wrapper, + }); + + expect(result.current.loading).toEqual(true); + + await waitFor(() => { + expect(result.current).toEqual({ + error: undefined, + items: repoBuilds, + loading: false, + }); + }); + }); + + it('should return throw when annotation missing', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + }, + }; + + expect(() => + renderHook(() => useRepoBuilds(entity), { + wrapper: Wrapper, + }), + ).toThrow('Value for annotation "dev.azure.com/project" was not found'); + }); + + it('should return throw when annotation invalid', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'fake', + }, + }, + }; + + expect(() => + renderHook(() => useRepoBuilds(entity), { + wrapper: Wrapper, + }), + ).toThrow( + 'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: /, found: "fake"', + ); + }); +}); diff --git a/plugins/azure-devops/src/hooks/useRepoBuilds.ts b/plugins/azure-devops/src/hooks/useRepoBuilds.ts index e0fe8c2093..9292a6e035 100644 --- a/plugins/azure-devops/src/hooks/useRepoBuilds.ts +++ b/plugins/azure-devops/src/hooks/useRepoBuilds.ts @@ -24,7 +24,7 @@ import { Entity } from '@backstage/catalog-model'; import { azureDevOpsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; -import { useProjectRepoFromEntity } from './useProjectRepoFromEntity'; +import { getAnnotationValuesFromEntity } from '../utils'; export function useRepoBuilds( entity: Entity, @@ -40,10 +40,10 @@ export function useRepoBuilds( }; const api = useApi(azureDevOpsApiRef); - const { project, repo } = useProjectRepoFromEntity(entity); + const { project, repo } = getAnnotationValuesFromEntity(entity); - const { value, loading, error } = useAsync(() => { - return api.getRepoBuilds(project, repo, options); + const { value, loading, error } = useAsync(async () => { + return await api.getRepoBuilds(project, repo as string, options); }, [api, project, repo, entity]); return { diff --git a/plugins/azure-devops/src/utils/getAnnotationFromEntity.ts b/plugins/azure-devops/src/utils/getAnnotationFromEntity.ts deleted file mode 100644 index 41cc1e6628..0000000000 --- a/plugins/azure-devops/src/utils/getAnnotationFromEntity.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 { - AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION, - AZURE_DEVOPS_PROJECT_ANNOTATION, - AZURE_DEVOPS_REPO_ANNOTATION, -} from '../constants'; - -import { Entity } from '@backstage/catalog-model'; - -export function getAnnotationFromEntity(entity: Entity): { - project: string; - repo?: string; - definition?: string; -} { - const annotation = - entity.metadata.annotations?.[AZURE_DEVOPS_REPO_ANNOTATION]; - if (annotation) { - const { project, repo } = getProjectRepo(annotation); - const definition = undefined; - return { project, repo, definition }; - } - - const project = - entity.metadata.annotations?.[AZURE_DEVOPS_PROJECT_ANNOTATION]; - if (!project) { - throw new Error('Value for annotation dev.azure.com/project was not found'); - } - - const definition = - entity.metadata.annotations?.[AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION]; - if (!definition) { - throw new Error( - 'Value for annotation dev.azure.com/build-definition was not found', - ); - } - - const repo = undefined; - return { project, repo, definition }; -} - -function getProjectRepo(annotation: string): { - project: string; - repo: string; -} { - const [project, repo] = annotation.split('/'); - - if (!project && !repo) { - throw new Error( - 'Value for annotation dev.azure.com/project-repo was not in the correct format: /', - ); - } - - return { project, repo }; -} diff --git a/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.test.ts b/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.test.ts new file mode 100644 index 0000000000..67e46cee7b --- /dev/null +++ b/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.test.ts @@ -0,0 +1,310 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { getAnnotationValuesFromEntity } from './getAnnotationValuesFromEntity'; + +describe('getAnnotationValuesFromEntity', () => { + describe('with valid project-repo annotation', () => { + it('should return project and repo', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'projectName/repoName', + }, + }, + }; + const values = getAnnotationValuesFromEntity(entity); + expect(values).toEqual({ + project: 'projectName', + repo: 'repoName', + definition: undefined, + host: undefined, + org: undefined, + }); + }); + }); + + describe('with invalid project-repo annotation', () => { + it('should throw incorrect format error', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'project', + }, + }, + }; + + const test = () => { + return getAnnotationValuesFromEntity(entity); + }; + + expect(test).toThrow( + 'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: /, found: "project"', + ); + }); + }); + + describe('with project-repo annotation missing project', () => { + it('should throw missing project error', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': '/repo', + }, + }, + }; + + const test = () => { + return getAnnotationValuesFromEntity(entity); + }; + + expect(test).toThrow( + 'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: /, found: "/repo"', + ); + }); + }); + + describe('with project-repo annotation missing repo', () => { + it('should throw missing repo error', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'project/', + }, + }, + }; + + const test = () => { + return getAnnotationValuesFromEntity(entity); + }; + + expect(test).toThrow( + 'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: /, found: "project/"', + ); + }); + }); + + describe('with valid project and build-definition annotations', () => { + it('should return project and definition', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-build-definition', + annotations: { + 'dev.azure.com/project': 'projectName', + 'dev.azure.com/build-definition': 'buildDefinitionName', + }, + }, + }; + const values = getAnnotationValuesFromEntity(entity); + expect(values).toEqual({ + project: 'projectName', + repo: undefined, + definition: 'buildDefinitionName', + host: undefined, + org: undefined, + }); + }); + }); + + describe('with only project annotation', () => { + it('should should throw annotation not found error', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project', + annotations: { + 'dev.azure.com/project': 'projectName', + }, + }, + }; + const test = () => { + return getAnnotationValuesFromEntity(entity); + }; + + expect(test).toThrow( + 'Value for annotation "dev.azure.com/build-definition" was not found', + ); + }); + }); + + describe('with only build-definition annotation', () => { + it('should should throw annotation not found error', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'build-definition', + annotations: { + 'dev.azure.com/build-definition': 'buildDefinitionName', + }, + }, + }; + const test = () => { + return getAnnotationValuesFromEntity(entity); + }; + + expect(test).toThrow( + 'Value for annotation "dev.azure.com/project" was not found', + ); + }); + }); + + describe('with valid project-repo and host-org annotations', () => { + it('should return project, repo, host, and org', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-repo', + annotations: { + 'dev.azure.com/project-repo': 'projectName/repoName', + 'dev.azure.com/host-org': 'hostName/organizationName', + }, + }, + }; + const values = getAnnotationValuesFromEntity(entity); + expect(values).toEqual({ + project: 'projectName', + repo: 'repoName', + definition: undefined, + host: 'hostName', + org: 'organizationName', + }); + }); + }); + + describe('with valid project, build-definition, and host-org annotations', () => { + it('should return project, definition, host and org', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'project-build-definition', + annotations: { + 'dev.azure.com/project': 'projectName', + 'dev.azure.com/build-definition': 'buildDefinitionName', + 'dev.azure.com/host-org': 'hostName/organizationName', + }, + }, + }; + const values = getAnnotationValuesFromEntity(entity); + expect(values).toEqual({ + project: 'projectName', + repo: undefined, + definition: 'buildDefinitionName', + host: 'hostName', + org: 'organizationName', + }); + }); + }); + + describe('with invalid host-org annotation', () => { + it('should throw incorrect format error', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'host-org', + annotations: { + 'dev.azure.com/host-org': 'host', + }, + }, + }; + + const test = () => { + return getAnnotationValuesFromEntity(entity); + }; + + expect(test).toThrow( + 'Invalid value for annotation "dev.azure.com/host-org"; expected format is: /, found: "host"', + ); + }); + }); + + describe('with host-org annotation missing host', () => { + it('should throw missing project error', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'host-org', + annotations: { + 'dev.azure.com/host-org': '/org', + }, + }, + }; + + const test = () => { + return getAnnotationValuesFromEntity(entity); + }; + + expect(test).toThrow( + 'Invalid value for annotation "dev.azure.com/host-org"; expected format is: /, found: "/org"', + ); + }); + }); + + describe('with host-org annotation missing org', () => { + it('should throw missing repo error', () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'host-org', + annotations: { + 'dev.azure.com/host-org': 'host/', + }, + }, + }; + + const test = () => { + return getAnnotationValuesFromEntity(entity); + }; + + expect(test).toThrow( + 'Invalid value for annotation "dev.azure.com/host-org"; expected format is: /, found: "host/"', + ); + }); + }); +}); diff --git a/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.ts b/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.ts new file mode 100644 index 0000000000..9feec8bf95 --- /dev/null +++ b/plugins/azure-devops/src/utils/getAnnotationValuesFromEntity.ts @@ -0,0 +1,103 @@ +/* + * 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 { + AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION, + AZURE_DEVOPS_HOST_ORG_ANNOTATION, + AZURE_DEVOPS_PROJECT_ANNOTATION, + AZURE_DEVOPS_REPO_ANNOTATION, +} from '../constants'; + +import { Entity } from '@backstage/catalog-model'; + +export function getAnnotationValuesFromEntity(entity: Entity): { + project: string; + repo?: string; + definition?: string; + host?: string; + org?: string; +} { + const { host, org } = getHostOrg(entity.metadata.annotations); + + const projectRepoValues = getProjectRepo(entity.metadata.annotations); + if (projectRepoValues.project && projectRepoValues.repo) { + return { + project: projectRepoValues.project, + repo: projectRepoValues.repo, + host, + org, + }; + } + + const project = + entity.metadata.annotations?.[AZURE_DEVOPS_PROJECT_ANNOTATION]; + if (!project) { + throw new Error( + `Value for annotation "${AZURE_DEVOPS_PROJECT_ANNOTATION}" was not found`, + ); + } + + const definition = + entity.metadata.annotations?.[AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION]; + if (!definition) { + throw new Error( + `Value for annotation "${AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION}" was not found`, + ); + } + return { project, definition, host, org }; +} + +function getProjectRepo(annotations?: Record): { + project?: string; + repo?: string; +} { + const annotation = annotations?.[AZURE_DEVOPS_REPO_ANNOTATION]; + if (!annotation) { + return { project: undefined, repo: undefined }; + } + + if (annotation.includes('/')) { + const [project, repo] = annotation.split('/'); + if (project && repo) { + return { project, repo }; + } + } + + throw new Error( + `Invalid value for annotation "${AZURE_DEVOPS_REPO_ANNOTATION}"; expected format is: /, found: "${annotation}"`, + ); +} + +function getHostOrg(annotations?: Record): { + host?: string; + org?: string; +} { + const annotation = annotations?.[AZURE_DEVOPS_HOST_ORG_ANNOTATION]; + if (!annotation) { + return { host: undefined, org: undefined }; + } + + if (annotation.includes('/')) { + const [host, org] = annotation.split('/'); + if (host && org) { + return { host, org }; + } + } + + throw new Error( + `Invalid value for annotation "${AZURE_DEVOPS_HOST_ORG_ANNOTATION}"; expected format is: /, found: "${annotation}"`, + ); +} diff --git a/plugins/azure-devops/src/utils/index.ts b/plugins/azure-devops/src/utils/index.ts index 8655b261c3..998bb0e23d 100644 --- a/plugins/azure-devops/src/utils/index.ts +++ b/plugins/azure-devops/src/utils/index.ts @@ -17,4 +17,4 @@ export * from './arrayHas'; export * from './equalsIgnoreCase'; export * from './getDurationFromDates'; -export * from './getAnnotationFromEntity'; +export * from './getAnnotationValuesFromEntity'; diff --git a/plugins/bazaar-backend/alpha-api-report.md b/plugins/bazaar-backend/alpha-api-report.md new file mode 100644 index 0000000000..7ef46f20ab --- /dev/null +++ b/plugins/bazaar-backend/alpha-api-report.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/plugin-bazaar-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @alpha +const _default: () => BackendFeature; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/bazaar-backend/api-report.md b/plugins/bazaar-backend/api-report.md index d9243fd3af..1ee889c04a 100644 --- a/plugins/bazaar-backend/api-report.md +++ b/plugins/bazaar-backend/api-report.md @@ -3,17 +3,12 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { BackendFeature } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import express from 'express'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { Logger } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; -// @alpha -const bazaarPlugin: () => BackendFeature; -export default bazaarPlugin; - // @public (undocumented) export function createRouter(options: RouterOptions): Promise; diff --git a/plugins/bazaar-backend/package.json b/plugins/bazaar-backend/package.json index 3fdfa32a7f..2a0c2c8f28 100644 --- a/plugins/bazaar-backend/package.json +++ b/plugins/bazaar-backend/package.json @@ -5,10 +5,22 @@ "types": "src/index.ts", "license": "Apache-2.0", "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts", - "alphaTypes": "dist/index.alpha.d.ts" + "access": "public" + }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } }, "backstage": { "role": "backend-plugin" @@ -21,7 +33,7 @@ }, "scripts": { "start": "backstage-cli package start", - "build": "backstage-cli package build --experimental-type-build", + "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", diff --git a/plugins/bazaar-backend/src/plugin.ts b/plugins/bazaar-backend/src/alpha.ts similarity index 96% rename from plugins/bazaar-backend/src/plugin.ts rename to plugins/bazaar-backend/src/alpha.ts index 1c06bda705..466181e073 100644 --- a/plugins/bazaar-backend/src/plugin.ts +++ b/plugins/bazaar-backend/src/alpha.ts @@ -26,7 +26,7 @@ import { createRouter } from './service/router'; * * @alpha */ -export const bazaarPlugin = createBackendPlugin({ +export default createBackendPlugin({ pluginId: 'bazaar', register(env) { env.registerInit({ diff --git a/plugins/bazaar-backend/src/index.ts b/plugins/bazaar-backend/src/index.ts index d6f3df2d04..ca73cb27ba 100644 --- a/plugins/bazaar-backend/src/index.ts +++ b/plugins/bazaar-backend/src/index.ts @@ -15,4 +15,3 @@ */ export * from './service/router'; -export { bazaarPlugin as default } from './plugin'; diff --git a/plugins/catalog-backend-module-gitlab/src/lib/client.ts b/plugins/catalog-backend-module-gitlab/src/lib/client.ts index 722f079a21..c4cdd444cb 100644 --- a/plugins/catalog-backend-module-gitlab/src/lib/client.ts +++ b/plugins/catalog-backend-module-gitlab/src/lib/client.ts @@ -13,18 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import fetch from 'node-fetch'; import { getGitLabRequestOptions, GitLabIntegrationConfig, } from '@backstage/integration'; +import fetch from 'node-fetch'; import { Logger } from 'winston'; + import { - GitLabGroup, GitLabDescendantGroupsResponse, + GitLabGroup, GitLabGroupMembersResponse, GitLabUser, + PagedResponse, } from './types'; export type CommonListOptions = { @@ -44,11 +45,6 @@ interface UserListOptions extends CommonListOptions { exclude_internal?: boolean | undefined; } -export type PagedResponse = { - items: T[]; - nextPage?: number; -}; - export class GitLabClient { private readonly config: GitLabIntegrationConfig; private readonly logger: Logger; diff --git a/plugins/catalog-backend-module-gitlab/src/lib/types.ts b/plugins/catalog-backend-module-gitlab/src/lib/types.ts index 946317a11a..fa2eb73da6 100644 --- a/plugins/catalog-backend-module-gitlab/src/lib/types.ts +++ b/plugins/catalog-backend-module-gitlab/src/lib/types.ts @@ -13,9 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { TaskScheduleDefinition } from '@backstage/backend-tasks'; +export type PagedResponse = { + items: T[]; + nextPage?: number; +}; + export type GitlabGroupDescription = { id: number; web_url: string; diff --git a/plugins/catalog-backend-module-gitlab/src/providers/GitlabOrgDiscoveryEntityProvider.ts b/plugins/catalog-backend-module-gitlab/src/providers/GitlabOrgDiscoveryEntityProvider.ts index 732f9f9fac..affa8a41cf 100644 --- a/plugins/catalog-backend-module-gitlab/src/providers/GitlabOrgDiscoveryEntityProvider.ts +++ b/plugins/catalog-backend-module-gitlab/src/providers/GitlabOrgDiscoveryEntityProvider.ts @@ -13,31 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks'; +import { + ANNOTATION_LOCATION, + ANNOTATION_ORIGIN_LOCATION, + Entity, + GroupEntity, + UserEntity, +} from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { GitLabIntegration, ScmIntegrations } from '@backstage/integration'; import { EntityProvider, EntityProviderConnection, } from '@backstage/plugin-catalog-node'; +import { merge } from 'lodash'; import * as uuid from 'uuid'; import { Logger } from 'winston'; + import { GitLabClient, GitlabProviderConfig, paginated, readGitlabConfigs, } from '../lib'; -import { GitLabGroup, GitLabUser } from '../lib/types'; -import { - ANNOTATION_LOCATION, - ANNOTATION_ORIGIN_LOCATION, - Entity, - UserEntity, - GroupEntity, -} from '@backstage/catalog-model'; -import { merge } from 'lodash'; +import { GitLabGroup, GitLabUser, PagedResponse } from '../lib/types'; type Result = { scanned: number; @@ -245,9 +245,14 @@ export class GitlabOrgDiscoveryEntityProvider implements EntityProvider { groupRes.scanned++; groupRes.matches.push(group); - const groupUsers = await client.getGroupMembers(group.full_path, [ - 'DIRECT', - ]); + let groupUsers: PagedResponse = { items: [] }; + try { + groupUsers = await client.getGroupMembers(group.full_path, ['DIRECT']); + } catch (e) { + logger.error( + `Failed fetching users for group '${group.full_path}': ${e}`, + ); + } for (const groupUser of groupUsers.items) { const user = idMappedUser[groupUser.id]; diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index a72df6bb3d..7422440955 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -142,7 +142,7 @@ export const columnFactories: Readonly<{ export type DefaultEntityFilters = { kind?: EntityKindFilter; type?: EntityTypeFilter; - user?: UserListFilter; + user?: UserListFilter | EntityUserFilter; owners?: EntityOwnerFilter; lifecycles?: EntityLifecycleFilter; tags?: EntityTagFilter; @@ -224,6 +224,8 @@ export class EntityLifecycleFilter implements EntityFilter { // (undocumented) filterEntity(entity: Entity): boolean; // (undocumented) + getCatalogFilters(): Record; + // (undocumented) toQueryValue(): string[]; // (undocumented) readonly values: string[]; @@ -275,6 +277,8 @@ export class EntityNamespaceFilter implements EntityFilter { // (undocumented) filterEntity(entity: Entity): boolean; // (undocumented) + getCatalogFilters(): Record; + // (undocumented) toQueryValue(): string[]; // (undocumented) readonly values: string[]; @@ -289,6 +293,8 @@ export class EntityOrphanFilter implements EntityFilter { // (undocumented) filterEntity(entity: Entity): boolean; // (undocumented) + getCatalogFilters(): Record; + // (undocumented) readonly value: boolean; } @@ -297,6 +303,8 @@ export class EntityOwnerFilter implements EntityFilter { constructor(values: string[]); // (undocumented) filterEntity(entity: Entity): boolean; + // (undocumented) + getCatalogFilters(): Record; toQueryValue(): string[]; // (undocumented) readonly values: string[]; @@ -447,6 +455,8 @@ export class EntityTagFilter implements EntityFilter { // (undocumented) filterEntity(entity: Entity): boolean; // (undocumented) + getCatalogFilters(): Record; + // (undocumented) toQueryValue(): string[]; // (undocumented) readonly values: string[]; @@ -497,6 +507,26 @@ export interface EntityTypePickerProps { initialFilter?: string; } +// @public (undocumented) +export class EntityUserFilter implements EntityFilter { + // (undocumented) + static all(): EntityUserFilter; + // (undocumented) + filterEntity(entity: Entity): boolean; + // (undocumented) + getCatalogFilters(): Record; + // (undocumented) + static owned(ownershipEntityRefs: string[]): EntityUserFilter; + // (undocumented) + readonly refs?: string[] | undefined; + // (undocumented) + static starred(starredEntityRefs: string[]): EntityUserFilter; + // (undocumented) + toQueryValue(): string; + // (undocumented) + readonly value: UserListFilterKind; +} + // @public export const FavoriteEntity: ( props: FavoriteEntityProps, @@ -620,7 +650,7 @@ export function useRelatedEntities( error: Error | undefined; }; -// @public +// @public @deprecated export class UserListFilter implements EntityFilter { constructor( value: UserListFilterKind, diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx index c31381df20..ad56681d6c 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -16,15 +16,19 @@ import React from 'react'; import { fireEvent, render, waitFor, screen } from '@testing-library/react'; -import { - Entity, - RELATION_OWNED_BY, - UserEntity, -} from '@backstage/catalog-model'; -import { UserListPicker } from './UserListPicker'; +import { UserEntity } from '@backstage/catalog-model'; +import { UserListPicker, UserListPickerProps } from './UserListPicker'; import { MockEntityListContextProvider } from '../../testUtils/providers'; -import { EntityTagFilter, UserListFilter } from '../../filters'; -import { CatalogApi } from '@backstage/catalog-client'; +import { + EntityKindFilter, + EntityNamespaceFilter, + EntityTagFilter, + EntityUserFilter, +} from '../../filters'; +import { + CatalogApi, + QueryEntitiesInitialRequest, +} from '@backstage/catalog-client'; import { catalogApiRef } from '../../api'; import { MockStorageApi, TestApiRegistry } from '@backstage/test-utils'; import { ApiProvider } from '@backstage/core-app-api'; @@ -35,7 +39,7 @@ import { identityApiRef, storageApiRef, } from '@backstage/core-plugin-api'; -import { useEntityOwnership } from '../../hooks'; +import { MockStarredEntitiesApi, starredEntitiesApiRef } from '../../apis'; const mockUser: UserEntity = { apiVersion: 'backstage.io/v1alpha1', @@ -54,136 +58,151 @@ const mockConfigApi = { } as Partial; const mockCatalogApi = { - getEntityByRef: () => Promise.resolve(mockUser), -} as Partial; + getEntityByRef: jest.fn(), + queryEntities: jest.fn(), +} as Partial>; const mockIdentityApi = { - getUserId: () => 'testUser', - getIdToken: async () => undefined, -} as Partial; + getBackstageIdentity: jest.fn(), +} as Partial>; + +const mockStarredEntitiesApi = new MockStarredEntitiesApi(); const apis = TestApiRegistry.from( [configApiRef, mockConfigApi], [catalogApiRef, mockCatalogApi], [identityApiRef, mockIdentityApi], [storageApiRef, MockStorageApi.create()], + [starredEntitiesApiRef, mockStarredEntitiesApi], ); -const mockIsOwnedEntity = jest.fn( - (entity: Entity) => entity.metadata.name === 'component-1', -); - -const mockIsStarredEntity = jest.fn( - (entity: Entity) => entity.metadata.name === 'component-3', -); - -jest.mock('../../hooks', () => { - const actual = jest.requireActual('../../hooks'); - return { - ...actual, - useEntityOwnership: jest.fn(() => ({ - isOwnedEntity: mockIsOwnedEntity, - })), - useStarredEntities: () => ({ - isStarredEntity: mockIsStarredEntity, - }), - }; -}); - -const backendEntities: Entity[] = [ - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-1', - name: 'component-1', - tags: ['tag1'], - }, - relations: [ - { - type: RELATION_OWNED_BY, - targetRef: 'user:default/testuser', - }, - ], - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-2', - name: 'component-2', - tags: ['tag1'], - }, - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-2', - name: 'component-3', - tags: [], - }, - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-2', - name: 'component-4', - tags: [], - }, - relations: [ - { - type: RELATION_OWNED_BY, - targetRef: 'user:default/testuser', - }, - ], - }, -]; - +const ownershipEntityRefs = ['user:default/testuser']; describe('', () => { - it('renders filter groups', () => { + const mockQueryEntitiesImplementation: CatalogApi['queryEntities'] = + async request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['relations.ownedBy'] + ) { + // owned entities + return { items: [], totalItems: 3, pageInfo: {} }; + } + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['metadata.name'] + ) { + // starred entities + return { + items: [ + { + apiVersion: '1', + kind: 'component', + metadata: { name: 'e-1', namespace: 'default' }, + }, + { + apiVersion: '1', + kind: 'component', + metadata: { name: 'e-2', namespace: 'default' }, + }, + ], + totalItems: 2, + pageInfo: {}, + }; + } + // all items + return { items: [], totalItems: 10, pageInfo: {} }; + }; + + beforeAll(() => { + mockStarredEntitiesApi.toggleStarred('component:default/e-1'); + mockStarredEntitiesApi.toggleStarred('component:default/e-2'); + }); + + beforeEach(() => { + mockCatalogApi.getEntityByRef?.mockResolvedValue(mockUser); + mockIdentityApi.getBackstageIdentity?.mockResolvedValue({ + ownershipEntityRefs, + type: 'user', + userEntityRef: 'user:default/testuser', + }); + + mockCatalogApi.queryEntities?.mockImplementation( + mockQueryEntitiesImplementation, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('renders filter groups', async () => { render( - + , ); + await waitFor(() => + expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(), + ); + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalled(), + ); expect(screen.getByText('Personal')).toBeInTheDocument(); expect(screen.getByText('Test Company')).toBeInTheDocument(); }); - it('renders filters', () => { + it('renders filters', async () => { render( - + , ); - expect( - screen.getAllByRole('menuitem').map(({ textContent }) => textContent), - ).toEqual(['Owned 1', 'Starred 1', 'All 4']); - }); - - it('includes counts alongside each filter', async () => { - render( - - - - - , + await waitFor(() => + expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(), ); - - // Material UI renders ListItemSecondaryActions outside the - // menuitem itself, so we pick off the next sibling. - await waitFor(() => { + await waitFor(() => expect( screen.getAllByRole('menuitem').map(({ textContent }) => textContent), - ).toEqual(['Owned 1', 'Starred 1', 'All 4']); + ).toEqual(['Owned 3', 'Starred 2', 'All 10']), + ); + + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { + 'metadata.namespace': ['default'], + }, + limit: 0, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { + 'metadata.namespace': ['default'], + 'relations.ownedBy': ['user:default/testuser'], + }, + limit: 0, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { + 'metadata.namespace': ['default'], + 'metadata.name': ['e-1', 'e-2'], + }, + limit: 1000, }); }); @@ -192,7 +211,6 @@ describe('', () => { @@ -204,35 +222,74 @@ describe('', () => { await waitFor(() => { expect( screen.getAllByRole('menuitem').map(({ textContent }) => textContent), - ).toEqual(['Owned 1', 'Starred 0', 'All 2']); + ).toEqual(['Owned 3', 'Starred 2', 'All 10']); + }); + + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { 'metadata.tags': ['tag1'] }, + limit: 0, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { 'metadata.name': ['e-1', 'e-2'], 'metadata.tags': ['tag1'] }, + limit: 1000, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { + 'relations.ownedBy': ['user:default/testuser'], + 'metadata.tags': ['tag1'], + }, + limit: 0, }); }); - it('respects the query parameter filter value', () => { + it('respects the query parameter filter value', async () => { const updateFilters = jest.fn(); - const queryParameters = { user: 'owned' }; + const queryParameters = { user: 'owned', kind: 'component' }; render( , ); + await waitFor(() => + expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(), + ); - expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity), + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: EntityUserFilter.owned(ownershipEntityRefs), + }), + ); + + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { kind: 'component' }, + limit: 0, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { kind: 'component', 'metadata.name': ['e-1', 'e-2'] }, + limit: 1000, + }); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ + filter: { + kind: 'component', + 'relations.ownedBy': ['user:default/testuser'], + }, + limit: 0, }); }); - it('updates user filter when a menuitem is selected', () => { + it('updates user filter when a menuitem is selected', async () => { const updateFilters = jest.fn(); render( - + , @@ -240,40 +297,55 @@ describe('', () => { fireEvent.click(screen.getByText('Starred')); - expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter( - 'starred', - mockIsOwnedEntity, - mockIsStarredEntity, - ), - }); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: EntityUserFilter.starred([ + 'component:default/e-1', + 'component:default/e-2', + ]), + }), + ); }); - it('responds to external queryParameters changes', () => { + it('responds to external queryParameters changes', async () => { const updateFilters = jest.fn(); const rendered = render( , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter('all', mockIsOwnedEntity, mockIsStarredEntity), - }); + + await waitFor(() => + expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(), + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: EntityUserFilter.all(), + }), + ); + rendered.rerender( @@ -281,101 +353,237 @@ describe('', () => { , ); expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity), + user: EntityUserFilter.owned(ownershipEntityRefs), }); }); - describe.each` - type | filterFn - ${'owned'} | ${mockIsOwnedEntity} - ${'starred'} | ${mockIsStarredEntity} - `('filter resetting for $type entities', ({ type, filterFn }) => { - let updateFilters: jest.Mock; + describe('filter resetting', () => { + const updateFilters = jest.fn(); - const picker = (props: { loading: boolean }) => ( + const Picker = ({ ...props }: UserListPickerProps) => ( - + ); - beforeEach(() => { - updateFilters = jest.fn(); - }); + describe(`when there are no owned entities matching the filter`, () => { + it('does not reset the filter while entities are loading', async () => { + mockCatalogApi.queryEntities?.mockReturnValue(new Promise(() => {})); - describe(`when there are no ${type} entities match the filter`, () => { - beforeEach(() => { - filterFn.mockReturnValue(false); + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalled(), + ); + + await expect(() => + waitFor(() => expect(updateFilters).toHaveBeenCalled()), + ).rejects.toThrow(); }); - it('does not reset the filter while entities are loading', () => { - render(picker({ loading: true })); + it('does not reset the filter while owned entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation(request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['relations.ownedBy'] + ) { + return new Promise(() => {}); + } + return mockQueryEntitiesImplementation(request); + }); + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); expect(updateFilters).not.toHaveBeenCalledWith({ - user: new UserListFilter( - 'all', - mockIsOwnedEntity, - mockIsStarredEntity, - ), + user: expect.any(Object), }); }); - it('does not reset the filter while owned entities are loading', () => { - const isOwnedEntity = jest.fn(() => false); - (useEntityOwnership as jest.Mock).mockReturnValueOnce({ - loading: true, - isOwnedEntity, + it('resets the filter to "all" when entities are loaded', async () => { + mockCatalogApi.queryEntities?.mockImplementation(async request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['relations.ownedBy'] + ) { + return { items: [], totalItems: 0, pageInfo: {} }; + } + return mockQueryEntitiesImplementation(request); }); - render(picker({ loading: false })); - expect(updateFilters).not.toHaveBeenCalledWith({ - user: new UserListFilter('all', isOwnedEntity, mockIsStarredEntity), - }); - }); + render(); - it('resets the filter to "all" when entities are loaded', () => { - render(picker({ loading: false })); - - expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter( - 'all', - mockIsOwnedEntity, - mockIsStarredEntity, - ), - }); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: EntityUserFilter.all(), + }), + ); }); }); - describe(`when there are some ${type} entities present`, () => { - beforeEach(() => { - filterFn.mockReturnValue(true); + describe(`when there are no starred entities match the filter`, () => { + it('does not reset the filter while entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation( + () => new Promise(() => {}), + ); + + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalled(), + ); + expect(updateFilters).not.toHaveBeenCalled(); }); - it('does not reset the filter while entities are loading', () => { - render(picker({ loading: true })); + it('does not reset the filter while starred entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation(request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['metadata.name'] + ) { + return new Promise(() => {}); + } + return mockQueryEntitiesImplementation(request); + }); + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); expect(updateFilters).not.toHaveBeenCalledWith({ - user: new UserListFilter( - 'all', - mockIsOwnedEntity, - mockIsStarredEntity, - ), + user: expect.any(Object), }); }); - it('does not reset the filter when entities are loaded', () => { - render(picker({ loading: false })); - - expect(updateFilters).toHaveBeenLastCalledWith({ - user: new UserListFilter( - type, - mockIsOwnedEntity, - mockIsStarredEntity, - ), + it('resets the filter to "all" when entities are loaded', async () => { + mockCatalogApi.queryEntities?.mockImplementation(async request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['metadata.name'] + ) { + return { items: [], totalItems: 0, pageInfo: {} }; + } + return mockQueryEntitiesImplementation(request); }); + + render(); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: EntityUserFilter.all(), + }), + ); + }); + }); + + describe(`when there are some owned entities present`, () => { + it('does not reset the filter while entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation(request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['relations.ownedBy'] + ) { + return new Promise(() => {}); + } + return mockQueryEntitiesImplementation(request); + }); + + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); + expect(updateFilters).not.toHaveBeenCalledWith({ + user: EntityUserFilter.all(), + }); + }); + + it('does not reset the filter when entities are loaded', async () => { + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: EntityUserFilter.owned(expect.any(Array)), + }), + ); + }); + }); + + describe(`when there are some starred entities present`, () => { + it('does not reset the filter while entities are loading', async () => { + mockCatalogApi.queryEntities?.mockImplementation(request => { + if ( + ( + (request as QueryEntitiesInitialRequest).filter as Record< + string, + string + > + )['metadata.name'] + ) { + return new Promise(() => {}); + } + return mockQueryEntitiesImplementation(request); + }); + + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); + expect(updateFilters).not.toHaveBeenCalledWith({ + user: EntityUserFilter.all(), + }); + }); + + it('does not reset the filter when entities are loaded', async () => { + render(); + + await waitFor(() => + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3), + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + user: EntityUserFilter.starred([ + 'component:default/e-1', + 'component:default/e-2', + ]), + }), + ); }); }); }); diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx index e7c306e0d0..854a1cb1b7 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx @@ -32,16 +32,13 @@ import { } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; import StarIcon from '@material-ui/icons/Star'; -import { compact } from 'lodash'; import React, { Fragment, useEffect, useMemo, useState } from 'react'; -import { UserListFilter } from '../../filters'; -import { - useEntityList, - useStarredEntities, - useEntityOwnership, -} from '../../hooks'; +import { EntityUserFilter } from '../../filters'; +import { useEntityList } from '../../hooks'; import { UserListFilterKind } from '../../types'; -import { reduceEntityFilters } from '../../utils'; +import { useOwnedEntitiesCount } from './useOwnedEntitiesCount'; +import { useAllEntitiesCount } from './useAllEntitiesCount'; +import { useStarredEntitiesCount } from './useStarredEntitiesCount'; /** @public */ export type CatalogReactUserListPickerClassKey = @@ -133,9 +130,7 @@ export const UserListPicker = (props: UserListPickerProps) => { const { filters, updateFilters, - backendEntities, queryParameters: { kind: kindParameter, user: userParameter }, - loading: loadingBackendEntities, } = useEntityList(); // Remove group items that aren't in availableFilters and exclude @@ -153,21 +148,17 @@ export const UserListPicker = (props: UserListPickerProps) => { })) .filter(({ items }) => !!items.length); - const { isStarredEntity } = useStarredEntities(); - const { isOwnedEntity, loading: loadingEntityOwnership } = - useEntityOwnership(); - - const loading = loadingBackendEntities || loadingEntityOwnership; - - // Static filters; used for generating counts of potentially unselected kinds - const ownedFilter = useMemo( - () => new UserListFilter('owned', isOwnedEntity, isStarredEntity), - [isOwnedEntity, isStarredEntity], - ); - const starredFilter = useMemo( - () => new UserListFilter('starred', isOwnedEntity, isStarredEntity), - [isOwnedEntity, isStarredEntity], - ); + const { + count: ownedEntitiesCount, + loading: loadingOwnedEntities, + filter: ownedEntitiesFilter, + } = useOwnedEntitiesCount(); + const { count: allCount } = useAllEntitiesCount(); + const { + count: starredEntitiesCount, + filter: starredEntitiesFilter, + loading: loadingStarredEntities, + } = useStarredEntitiesCount(); const queryParamUserFilter = useMemo( () => [userParameter].flat()[0], @@ -175,33 +166,16 @@ export const UserListPicker = (props: UserListPickerProps) => { ); const [selectedUserFilter, setSelectedUserFilter] = useState( - queryParamUserFilter ?? initialFilter, + (queryParamUserFilter as UserListFilterKind) ?? initialFilter, ); - // To show proper counts for each section, apply all other frontend filters _except_ the user - // filter that's controlled by this picker. - const entitiesWithoutUserFilter = useMemo( - () => - backendEntities.filter( - reduceEntityFilters( - compact(Object.values({ ...filters, user: undefined })), - ), - ), - [filters, backendEntities], - ); - - const filterCounts = useMemo>( - () => ({ - all: entitiesWithoutUserFilter.length, - starred: entitiesWithoutUserFilter.filter(entity => - starredFilter.filterEntity(entity), - ).length, - owned: entitiesWithoutUserFilter.filter(entity => - ownedFilter.filterEntity(entity), - ).length, - }), - [entitiesWithoutUserFilter, starredFilter, ownedFilter], - ); + const filterCounts = useMemo(() => { + return { + all: allCount, + starred: starredEntitiesCount, + owned: ownedEntitiesCount, + }; + }, [starredEntitiesCount, ownedEntitiesCount, allCount]); // Set selected user filter on query parameter updates; this happens at initial page load and from // external updates to the page location. @@ -211,6 +185,8 @@ export const UserListPicker = (props: UserListPickerProps) => { } }, [queryParamUserFilter]); + const loading = loadingOwnedEntities || loadingStarredEntities; + useEffect(() => { if ( !loading && @@ -223,16 +199,32 @@ export const UserListPicker = (props: UserListPickerProps) => { }, [loading, filterCounts, selectedUserFilter, setSelectedUserFilter]); useEffect(() => { - updateFilters({ - user: selectedUserFilter - ? new UserListFilter( - selectedUserFilter as UserListFilterKind, - isOwnedEntity, - isStarredEntity, - ) - : undefined, - }); - }, [selectedUserFilter, isOwnedEntity, isStarredEntity, updateFilters]); + if (!selectedUserFilter) { + return; + } + if (loading) { + return; + } + + const getFilter = () => { + if (selectedUserFilter === 'owned') { + return ownedEntitiesFilter; + } + if (selectedUserFilter === 'starred') { + return starredEntitiesFilter; + } + return EntityUserFilter.all(); + }; + + updateFilters({ user: getFilter() }); + }, [ + selectedUserFilter, + starredEntitiesFilter, + ownedEntitiesFilter, + updateFilters, + + loading, + ]); return ( diff --git a/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx new file mode 100644 index 0000000000..dc3bc5d2bd --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { PropsWithChildren } from 'react'; +import { CatalogApi } from '@backstage/catalog-client'; +import { useAllEntitiesCount } from './useAllEntitiesCount'; +import { renderHook, waitFor } from '@testing-library/react'; +import { EntityListProvider, useEntityList } from '../../hooks'; +import { catalogApiRef } from '../../api'; +import { ApiRef } from '@backstage/core-plugin-api'; +import { MemoryRouter } from 'react-router-dom'; +import { EntityOwnerFilter } from '../../filters'; +import { useMountEffect } from '@react-hookz/web'; + +const mockQueryEntities: jest.MockedFn = jest.fn(); +const mockCatalogApi: jest.Mocked> = { + queryEntities: mockQueryEntities, +}; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: (ref: ApiRef) => + ref === catalogApiRef ? mockCatalogApi : actual.useApi(ref), + }; +}); + +describe('useAllEntitiesCount', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return the count', async () => { + mockQueryEntities.mockResolvedValue({ + items: [], + totalItems: 10, + pageInfo: {}, + }); + + function WrapFilters(props: PropsWithChildren<{}>) { + const { updateFilters } = useEntityList(); + + useMountEffect(() => { + updateFilters({ + owners: new EntityOwnerFilter(['user:default/owner']), + }); + }); + return <>{props.children}; + } + + const { result } = renderHook(() => useAllEntitiesCount(), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + + await waitFor(() => + expect(mockQueryEntities).toHaveBeenCalledWith({ + filter: { + 'relations.ownedBy': ['user:default/owner'], + }, + limit: 0, + }), + ); + expect(result.current).toEqual({ count: 10, loading: false }); + }); + + it(`shouldn't invoke the endpoint at startup, when filters are missing`, async () => { + mockQueryEntities.mockResolvedValue({ + items: [], + totalItems: 10, + pageInfo: {}, + }); + + const { result } = renderHook(() => useAllEntitiesCount(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await expect( + waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()), + ).rejects.toThrow(); + expect(result.current).toEqual({ count: 0, loading: false }); + }); +}); diff --git a/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts new file mode 100644 index 0000000000..ba971b088d --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { QueryEntitiesInitialRequest } from '@backstage/catalog-client'; +import { useApi } from '@backstage/core-plugin-api'; +import { compact, isEqual } from 'lodash'; +import { useMemo, useRef } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { catalogApiRef } from '../../api'; +import { useEntityList } from '../../hooks'; +import { reduceCatalogFilters } from '../../utils'; + +export function useAllEntitiesCount() { + const catalogApi = useApi(catalogApiRef); + const { filters } = useEntityList(); + + const prevRequest = useRef(); + const request = useMemo(() => { + const { user, ...allFilters } = filters; + const compacted = compact(Object.values(allFilters)); + const filter = reduceCatalogFilters(compacted); + const newRequest: QueryEntitiesInitialRequest = { + filter, + limit: 0, + }; + + if (Object.keys(filter).length === 0) { + prevRequest.current = undefined; + return prevRequest.current; + } + + if (isEqual(newRequest, prevRequest.current)) { + return prevRequest.current; + } + prevRequest.current = newRequest; + return newRequest; + }, [filters]); + + const { value: count, loading } = useAsync(async () => { + if (request === undefined) { + return 0; + } + const { totalItems } = await catalogApi.queryEntities(request); + + return totalItems; + }, [request]); + + return { count, loading }; +} diff --git a/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx new file mode 100644 index 0000000000..1f6fbfd53d --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx @@ -0,0 +1,235 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { PropsWithChildren } from 'react'; +import { CatalogApi } from '@backstage/catalog-client'; +import { renderHook, waitFor } from '@testing-library/react'; +import { + DefaultEntityFilters, + EntityListProvider, + useEntityList, +} from '../../hooks'; +import { catalogApiRef } from '../../api'; +import { + ApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { MemoryRouter } from 'react-router-dom'; +import { useOwnedEntitiesCount } from './useOwnedEntitiesCount'; +import { + EntityNamespaceFilter, + EntityOwnerFilter, + EntityUserFilter, +} from '../../filters'; +import { useMountEffect } from '@react-hookz/web'; + +const mockQueryEntities: jest.MockedFn = jest.fn(); +const mockCatalogApi: jest.Mocked> = { + queryEntities: mockQueryEntities, +}; + +const mockGetBackstageIdentity: jest.MockedFn< + IdentityApi['getBackstageIdentity'] +> = jest.fn(); + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: (ref: ApiRef) => { + if (ref === catalogApiRef) { + return mockCatalogApi; + } + if (ref === identityApiRef) { + return { + getBackstageIdentity: mockGetBackstageIdentity, + }; + } + + return actual.useApi(ref); + }, + }; +}); + +describe('useOwnedEntitiesCount', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockGetBackstageIdentity.mockResolvedValue({ + ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'], + userEntityRef: 'user:default/spiderman', + type: 'user', + }); + }); + + it(`shouldn't invoke queryEntities when filters are loading`, async () => { + mockQueryEntities.mockResolvedValue({ + items: [], + totalItems: 10, + pageInfo: {}, + }); + + const { result } = renderHook(() => useOwnedEntitiesCount(), { + wrapper: createWrapperWithInitialFilters({}), + }); + + await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled()); + + await expect( + waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()), + ).rejects.toThrow(); + + expect(result.current).toEqual({ + count: 0, + loading: false, + filter: EntityUserFilter.owned([ + 'user:default/spiderman', + 'user:group/a-group', + ]), + ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'], + }); + }); + + it(`should properly apply the filters`, async () => { + mockQueryEntities.mockResolvedValue({ + items: [], + totalItems: 10, + pageInfo: {}, + }); + + const { result } = renderHook(() => useOwnedEntitiesCount(), { + wrapper: createWrapperWithInitialFilters({ + namespace: new EntityNamespaceFilter(['a-namespace']), + }), + }); + + await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled()); + + await waitFor(() => + expect(mockQueryEntities).toHaveBeenCalledWith({ + filter: { + 'metadata.namespace': ['a-namespace'], + 'relations.ownedBy': ['user:default/spiderman', 'user:group/a-group'], + }, + limit: 0, + }), + ); + + expect(result.current).toEqual({ + count: 10, + loading: false, + filter: EntityUserFilter.owned([ + 'user:default/spiderman', + 'user:group/a-group', + ]), + ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'], + }); + }); + + it(`should return count 0 without invoking queryEntities if owners filter doesn't have claims in common with logged in user`, async () => { + mockQueryEntities.mockResolvedValue({ + items: [], + totalItems: 10, + pageInfo: {}, + }); + + const { result } = renderHook(() => useOwnedEntitiesCount(), { + wrapper: createWrapperWithInitialFilters({ + namespace: new EntityNamespaceFilter(['a-namespace']), + owners: new EntityOwnerFilter(['group:default/monsters']), + }), + }); + + await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled()); + + await expect( + waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()), + ).rejects.toThrow(); + + expect(result.current).toEqual({ + count: 0, + loading: false, + filter: EntityUserFilter.owned([ + 'user:default/spiderman', + 'user:group/a-group', + ]), + ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'], + }); + }); + + it(`should send claims in common between owners filter and logged in user`, async () => { + mockQueryEntities.mockResolvedValue({ + items: [], + totalItems: 10, + pageInfo: {}, + }); + + const { result } = renderHook(() => useOwnedEntitiesCount(), { + wrapper: createWrapperWithInitialFilters({ + namespace: new EntityNamespaceFilter(['a-namespace']), + owners: new EntityOwnerFilter([ + 'group:default/monsters', + 'user:group/a-group', + ]), + }), + }); + + await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled()); + + await waitFor(() => + expect(mockQueryEntities).toHaveBeenCalledWith({ + filter: { + 'metadata.namespace': ['a-namespace'], + 'relations.ownedBy': ['user:group/a-group'], + }, + limit: 0, + }), + ); + + expect(result.current).toEqual({ + count: 10, + loading: false, + filter: EntityUserFilter.owned([ + 'user:default/spiderman', + 'user:group/a-group', + ]), + ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'], + }); + }); +}); + +function createWrapperWithInitialFilters( + filters: Partial, +) { + function WrapFilters(props: PropsWithChildren<{}>) { + const { updateFilters } = useEntityList(); + + useMountEffect(() => { + updateFilters(filters); + }); + return <>{props.children}; + } + + return function Wrapper(props: PropsWithChildren<{}>) { + return ( + + + {props.children} + + + ); + }; +} diff --git a/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts new file mode 100644 index 0000000000..1adab49361 --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryEntitiesInitialRequest } from '@backstage/catalog-client'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import { compact, intersection, isEqual } from 'lodash'; +import { useEffect, useMemo, useRef } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { catalogApiRef } from '../../api'; +import { EntityOwnerFilter, EntityUserFilter } from '../../filters'; +import { useEntityList } from '../../hooks'; +import { reduceCatalogFilters } from '../../utils'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +export function useOwnedEntitiesCount() { + const identityApi = useApi(identityApiRef); + const catalogApi = useApi(catalogApiRef); + + const { filters } = useEntityList(); + + const { value: ownershipEntityRefs, loading: loadingEntityRefs } = useAsync( + async () => (await identityApi.getBackstageIdentity()).ownershipEntityRefs, + // load only on mount + [], + ); + + const prevRequest = useRef(); + + const request = useMemo(() => { + const { user, owners, ...allFilters } = filters; + const compacted = compact(Object.values(allFilters)); + const allFilter = reduceCatalogFilters(compacted); + const { ['metadata.name']: metadata, ...filter } = allFilter; + + const countFilter = getOwnedCountClaims(owners, ownershipEntityRefs); + + if ( + ownershipEntityRefs?.length === 0 || + countFilter === undefined || + Object.keys(filter).length === 0 + ) { + prevRequest.current = undefined; + return undefined; + } + const newRequest: QueryEntitiesInitialRequest = { + filter: { + ...filter, + 'relations.ownedBy': countFilter, + }, + limit: 0, + }; + + if (isEqual(newRequest, prevRequest.current)) { + return prevRequest.current; + } + + prevRequest.current = newRequest; + + return newRequest; + }, [filters, ownershipEntityRefs]); + + const [{ value: count, loading: loadingEntityOwnership }, fetchEntities] = + useAsyncFn( + async ( + req: QueryEntitiesInitialRequest | undefined, + ownershipEntityRefsParam: string[], + ) => { + if (ownershipEntityRefsParam && !req) { + // this implicitly means that there aren't claims in common with + // the logged in users, so avoid invoking the queryEntities endpoint + // which will implicitly returns 0 + return 0; + } + const { totalItems } = await catalogApi.queryEntities(req); + return totalItems; + }, + [], + { loading: true }, + ); + + useEffect(() => { + if (ownershipEntityRefs) { + if (request && Object.keys(request).length === 0) { + return; + } + fetchEntities(request, ownershipEntityRefs); + } + }, [fetchEntities, request, ownershipEntityRefs]); + + const loading = loadingEntityRefs || loadingEntityOwnership; + const filter = useMemo( + () => EntityUserFilter.owned(ownershipEntityRefs ?? []), + [ownershipEntityRefs], + ); + + return { + count, + loading, + filter, + ownershipEntityRefs, + }; +} + +function getOwnedCountClaims( + owners: EntityOwnerFilter | undefined, + ownershipEntityRefs: string[] | undefined, +) { + if (ownershipEntityRefs === undefined) { + return undefined; + } + const ownersRefs = owners?.values ?? []; + if (ownersRefs.length) { + const commonOwnedBy = intersection(ownersRefs, ownershipEntityRefs); + if (commonOwnedBy.length === 0) { + return undefined; + } + return commonOwnedBy; + } + return ownershipEntityRefs; +} diff --git a/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx new file mode 100644 index 0000000000..5fe648e1bc --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { CatalogApi } from '@backstage/catalog-client'; +import { EntityListProvider, useStarredEntities } from '../../hooks'; +import { catalogApiRef } from '../../api'; +import { ApiRef } from '@backstage/core-plugin-api'; +import { MemoryRouter } from 'react-router-dom'; +import { useStarredEntitiesCount } from './useStarredEntitiesCount'; +import { renderHook, waitFor } from '@testing-library/react'; + +const mockQueryEntities: jest.MockedFn = jest.fn(); +const mockCatalogApi: jest.Mocked> = { + queryEntities: mockQueryEntities, +}; + +const mockStarredEntities: jest.MockedFn<() => Set> = jest.fn(); + +const mockUseStarredEntities: ReturnType = { + get starredEntities() { + return mockStarredEntities(); + }, +} as ReturnType; + +jest.mock('../../hooks', () => { + const actual = jest.requireActual('../../hooks'); + return { ...actual, useStarredEntities: () => mockUseStarredEntities }; +}); + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: (ref: ApiRef) => + ref === catalogApiRef ? mockCatalogApi : actual.useApi(ref), + }; +}); + +describe('useStarredEntitiesCount', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return the count', async () => { + mockStarredEntities.mockReturnValue( + new Set(['component:default/favourite1', 'component:default/favourite2']), + ); + mockQueryEntities.mockResolvedValue({ + items: [ + { + apiVersion: '1', + kind: 'component', + metadata: { name: 'favourite1' }, + }, + { + apiVersion: '1', + kind: 'component', + metadata: { name: 'favourite2' }, + }, + ], + totalItems: 2, + pageInfo: {}, + }); + + const { result } = renderHook(() => useStarredEntitiesCount(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(mockQueryEntities).toHaveBeenCalledWith({ + filter: { + 'metadata.name': ['favourite1', 'favourite2'], + }, + limit: 1000, + }); + expect(result.current).toEqual({ + count: 2, + loading: false, + filter: { + refs: [ + 'component:default/favourite1', + 'component:default/favourite2', + ], + value: 'starred', + }, + }); + }); + }); + + it(`shouldn't invoke the endpoint if there are no starred entities`, async () => { + mockStarredEntities.mockReturnValue(new Set()); + + const { result } = renderHook(() => useStarredEntitiesCount(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await expect( + waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()), + ).rejects.toThrow(); + expect(result.current).toEqual({ + count: 0, + loading: false, + filter: { refs: [], value: 'starred' }, + }); + }); +}); diff --git a/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts new file mode 100644 index 0000000000..f7b2b101f3 --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryEntitiesInitialRequest } from '@backstage/catalog-client'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { compact, isEqual } from 'lodash'; +import { useMemo, useRef } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { catalogApiRef } from '../../api'; +import { EntityUserFilter } from '../../filters'; +import { useEntityList, useStarredEntities } from '../../hooks'; +import { reduceCatalogFilters } from '../../utils'; + +export function useStarredEntitiesCount() { + const catalogApi = useApi(catalogApiRef); + const { filters } = useEntityList(); + const { starredEntities } = useStarredEntities(); + + const prevRequest = useRef(); + const request = useMemo(() => { + const { user, ...allFilters } = filters; + const compacted = compact(Object.values(allFilters)); + const filter = reduceCatalogFilters(compacted); + + const facet = 'metadata.name'; + + const newRequest: QueryEntitiesInitialRequest = { + filter: { + ...filter, + /** + * here we are filtering entities by `name`. Given this filter, + * the response might contain more entities than expected, in case multiple entities + * of different kind or namespace share the same name. Those extra entities are filtered out + * client side by `EntityUserFilter`, so they won't be visible to the user. + */ + [facet]: Array.from(starredEntities).map(e => parseEntityRef(e).name), + }, + /** + * limit is set to a high value as we are not expecting many starred entities + */ + limit: 1000, + }; + if (isEqual(newRequest, prevRequest.current)) { + return prevRequest.current; + } + prevRequest.current = newRequest; + + return newRequest; + }, [filters, starredEntities]); + + const { value: count, loading } = useAsync(async () => { + if (!starredEntities.size) { + return 0; + } + + /** + * given a list of starred entity refs and some filters coming from CatalogPage, + * it reduces the list of starred entities, to a list of entities that matches the + * provided filters. It won't be possible to getEntitiesByRefs + * as the method doesn't accept any filter. + */ + const response = await catalogApi.queryEntities(request); + + return response.items + .map(e => + stringifyEntityRef({ + kind: e.kind, + namespace: e.metadata.namespace, + name: e.metadata.name, + }), + ) + .filter(e => starredEntities.has(e)).length; + }, [request, starredEntities]); + + const filter = useMemo( + () => EntityUserFilter.starred(Array.from(starredEntities)), + [starredEntities], + ); + + return { count, loading, filter }; +} diff --git a/plugins/catalog-react/src/filters.ts b/plugins/catalog-react/src/filters.ts index c717a35202..55983d124b 100644 --- a/plugins/catalog-react/src/filters.ts +++ b/plugins/catalog-react/src/filters.ts @@ -72,6 +72,10 @@ export class EntityTagFilter implements EntityFilter { return this.values.every(v => (entity.metadata.tags ?? []).includes(v)); } + getCatalogFilters(): Record { + return { 'metadata.tags': this.values }; + } + toQueryValue(): string[] { return this.values; } @@ -136,6 +140,10 @@ export class EntityOwnerFilter implements EntityFilter { }, [] as string[]); } + getCatalogFilters(): Record { + return { 'relations.ownedBy': this.values }; + } + filterEntity(entity: Entity): boolean { return this.values.some(v => getEntityRelations(entity, RELATION_OWNED_BY).some( @@ -160,6 +168,10 @@ export class EntityOwnerFilter implements EntityFilter { export class EntityLifecycleFilter implements EntityFilter { constructor(readonly values: string[]) {} + getCatalogFilters(): Record { + return { 'spec.lifecycle': this.values }; + } + filterEntity(entity: Entity): boolean { return this.values.some(v => entity.spec?.lifecycle === v); } @@ -176,6 +188,9 @@ export class EntityLifecycleFilter implements EntityFilter { export class EntityNamespaceFilter implements EntityFilter { constructor(readonly values: string[]) {} + getCatalogFilters(): Record { + return { 'metadata.namespace': this.values }; + } filterEntity(entity: Entity): boolean { return this.values.some(v => entity.metadata.namespace === v); } @@ -185,8 +200,66 @@ export class EntityNamespaceFilter implements EntityFilter { } } +/** + * @public + */ +export class EntityUserFilter implements EntityFilter { + private constructor( + readonly value: UserListFilterKind, + readonly refs?: string[], + ) {} + + static owned(ownershipEntityRefs: string[]) { + return new EntityUserFilter('owned', ownershipEntityRefs); + } + + static all() { + return new EntityUserFilter('all'); + } + + static starred(starredEntityRefs: string[]) { + return new EntityUserFilter('starred', starredEntityRefs); + } + + getCatalogFilters(): Record { + if (this.value === 'owned') { + return { 'relations.ownedBy': this.refs ?? [] }; + } + if (this.value === 'starred') { + return { + 'metadata.name': this.refs?.map(e => parseEntityRef(e).name) ?? [], + }; + } + return {}; + } + + filterEntity(entity: Entity) { + if (this.value === 'starred') { + return this.refs?.includes(stringifyEntityRef(entity)) ?? true; + } + // used only for retro-compatibility with non paginated data. + // This is supposed to return always true for paginated + // owned entities, since the filters are applied server side. + if (this.value === 'owned') { + const relations = getEntityRelations(entity, RELATION_OWNED_BY); + + return ( + this.refs?.some(v => + relations.some(o => stringifyEntityRef(o) === v), + ) ?? false + ); + } + return true; + } + + toQueryValue(): string { + return this.value; + } +} + /** * Filters entities based on whatever the user has starred or owns them. + * @deprecated use EntityUserFilter * @public */ export class UserListFilter implements EntityFilter { @@ -218,6 +291,14 @@ export class UserListFilter implements EntityFilter { */ export class EntityOrphanFilter implements EntityFilter { constructor(readonly value: boolean) {} + + getCatalogFilters(): Record { + if (this.value) { + return { 'metadata.annotations.backstage.io/orphan': String(this.value) }; + } + return {}; + } + filterEntity(entity: Entity): boolean { const orphan = entity.metadata.annotations?.['backstage.io/orphan']; return orphan !== undefined && this.value.toString() === orphan; @@ -230,6 +311,7 @@ export class EntityOrphanFilter implements EntityFilter { */ export class EntityErrorFilter implements EntityFilter { constructor(readonly value: boolean) {} + filterEntity(entity: Entity): boolean { const error = ((entity as AlphaEntity)?.status?.items?.length as number) > 0; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index d77655ebcc..edbfb65c0c 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -32,7 +32,11 @@ import { MemoryRouter } from 'react-router-dom'; import { catalogApiRef } from '../api'; import { starredEntitiesApiRef, MockStarredEntitiesApi } from '../apis'; import { EntityKindPicker, UserListPicker } from '../components'; -import { EntityKindFilter, EntityTypeFilter, UserListFilter } from '../filters'; +import { + EntityKindFilter, + EntityTypeFilter, + EntityUserFilter, +} from '../filters'; import { UserListFilterKind } from '../types'; import { EntityListProvider, useEntityList } from './useEntityListProvider'; @@ -62,11 +66,14 @@ const entities: Entity[] = [ const mockConfigApi = { getOptionalString: () => '', } as Partial; + +const ownershipEntityRefs = ['user:default/guest']; + const mockIdentityApi: Partial = { getBackstageIdentity: async () => ({ type: 'user', userEntityRef: 'user:default/guest', - ownershipEntityRefs: [], + ownershipEntityRefs, }), getCredentials: async () => ({ token: undefined }), }; @@ -148,11 +155,7 @@ describe('', () => { act(() => result.current.updateFilters({ - user: new UserListFilter( - 'owned', - entity => entity.metadata.name === 'component-1', - () => true, - ), + user: EntityUserFilter.owned(ownershipEntityRefs), }), ); @@ -193,11 +196,7 @@ describe('', () => { act(() => result.current.updateFilters({ - user: new UserListFilter( - 'owned', - entity => entity.metadata.name === 'component-1', - () => true, - ), + user: EntityUserFilter.owned(ownershipEntityRefs), }), ); diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index b4c464a26c..948efc969f 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -41,16 +41,17 @@ import { EntityTypeFilter, UserListFilter, EntityNamespaceFilter, + EntityUserFilter, } from '../filters'; import { EntityFilter } from '../types'; -import { reduceCatalogFilters, reduceEntityFilters } from '../utils'; +import { reduceBackendCatalogFilters, reduceEntityFilters } from '../utils'; import { useApi } from '@backstage/core-plugin-api'; /** @public */ export type DefaultEntityFilters = { kind?: EntityKindFilter; type?: EntityTypeFilter; - user?: UserListFilter; + user?: UserListFilter | EntityUserFilter; owners?: EntityOwnerFilter; lifecycles?: EntityLifecycleFilter; tags?: EntityTagFilter; @@ -156,8 +157,8 @@ export const EntityListProvider = ( async () => { const compacted = compact(Object.values(requestedFilters)); const entityFilter = reduceEntityFilters(compacted); - const backendFilter = reduceCatalogFilters(compacted); - const previousBackendFilter = reduceCatalogFilters( + const backendFilter = reduceBackendCatalogFilters(compacted); + const previousBackendFilter = reduceBackendCatalogFilters( compact(Object.values(outputState.appliedFilters)), ); diff --git a/plugins/catalog-react/src/hooks/useEntityOwnership.ts b/plugins/catalog-react/src/hooks/useEntityOwnership.ts index f8a37a2c83..fdd73434ec 100644 --- a/plugins/catalog-react/src/hooks/useEntityOwnership.ts +++ b/plugins/catalog-react/src/hooks/useEntityOwnership.ts @@ -41,13 +41,18 @@ export function useEntityOwnership(): { const identityApi = useApi(identityApiRef); // Trigger load only on mount - const { loading, value: refs } = useAsync(async () => { - const { ownershipEntityRefs } = await identityApi.getBackstageIdentity(); - return ownershipEntityRefs; - }, []); + const { loading, value: refs } = useAsync( + async () => { + const { ownershipEntityRefs } = await identityApi.getBackstageIdentity(); + return ownershipEntityRefs; + }, + // load only on mount + [], + ); const isOwnedEntity = useMemo(() => { const myOwnerRefs = new Set(refs ?? []); + return (entity: Entity) => { const entityOwnerRefs = getEntityRelations(entity, RELATION_OWNED_BY).map( stringifyEntityRef, @@ -61,5 +66,5 @@ export function useEntityOwnership(): { }; }, [refs]); - return useMemo(() => ({ loading, isOwnedEntity }), [loading, isOwnedEntity]); + return { loading, isOwnedEntity }; } diff --git a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx index e3010a0f4a..07738fd3d9 100644 --- a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx +++ b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx @@ -16,7 +16,8 @@ import { Entity } from '@backstage/catalog-model'; import { TestApiProvider } from '@backstage/test-utils'; -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import React, { PropsWithChildren } from 'react'; import { starredEntitiesApiRef, diff --git a/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx index e64a9bbdf8..44a4c7aaa3 100644 --- a/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx +++ b/plugins/catalog-react/src/hooks/useStarredEntity.test.tsx @@ -16,7 +16,8 @@ import { Entity, CompoundEntityRef } from '@backstage/catalog-model'; import { TestApiProvider } from '@backstage/test-utils'; -import { renderHook, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import React, { PropsWithChildren } from 'react'; import Observable from 'zen-observable'; import { StarredEntitiesApi, starredEntitiesApiRef } from '../apis'; diff --git a/plugins/catalog-react/src/utils/filters.ts b/plugins/catalog-react/src/utils/filters.ts index 109e864c52..580d7b0b76 100644 --- a/plugins/catalog-react/src/utils/filters.ts +++ b/plugins/catalog-react/src/utils/filters.ts @@ -16,6 +16,16 @@ import { Entity } from '@backstage/catalog-model'; import { EntityFilter } from '../types'; +import { + EntityLifecycleFilter, + EntityNamespaceFilter, + EntityOrphanFilter, + EntityOwnerFilter, + EntityTagFilter, + EntityTextFilter, + EntityUserFilter, + UserListFilter, +} from '../filters'; export function reduceCatalogFilters( filters: EntityFilter[], @@ -28,6 +38,38 @@ export function reduceCatalogFilters( }, {} as Record); } +/** + * This function computes and returns an object containing the filters to be sent + * to the backend. Any filter coming from `EntityKindFilter` and `EntityTypeFilter`, together + * with custom filter set by the adopters is allowed. This function is used by `EntityListProvider` + * and it won't be needed anymore in the future once pagination is implemented, as all the filters + * will be applied backend-side. + */ +export function reduceBackendCatalogFilters(filters: EntityFilter[]) { + const backendCatalogFilters: Record< + string, + string | symbol | (string | symbol)[] + > = {}; + + filters.forEach(filter => { + if ( + filter instanceof EntityTagFilter || + filter instanceof EntityOwnerFilter || + filter instanceof EntityLifecycleFilter || + filter instanceof EntityNamespaceFilter || + filter instanceof EntityUserFilter || + filter instanceof EntityOrphanFilter || + filter instanceof EntityTextFilter || + filter instanceof UserListFilter + ) { + return; + } + Object.assign(backendCatalogFilters, filter.getCatalogFilters?.() || {}); + }); + + return backendCatalogFilters; +} + export function reduceEntityFilters( filters: EntityFilter[], ): (entity: Entity) => boolean { diff --git a/plugins/catalog/src/alpha/apis.tsx b/plugins/catalog/src/alpha/apis.tsx new file mode 100644 index 0000000000..a887a33a04 --- /dev/null +++ b/plugins/catalog/src/alpha/apis.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createApiFactory, + discoveryApiRef, + fetchApiRef, + storageApiRef, +} from '@backstage/core-plugin-api'; +import { CatalogClient } from '@backstage/catalog-client'; +import { createApiExtension } from '@backstage/frontend-plugin-api'; +import { + catalogApiRef, + starredEntitiesApiRef, +} from '@backstage/plugin-catalog-react'; +import { DefaultStarredEntitiesApi } from '../apis'; + +export const CatalogApi = createApiExtension({ + factory: createApiFactory({ + api: catalogApiRef, + deps: { + discoveryApi: discoveryApiRef, + fetchApi: fetchApiRef, + }, + factory: ({ discoveryApi, fetchApi }) => + new CatalogClient({ discoveryApi, fetchApi }), + }), +}); + +export const StarredEntitiesApi = createApiExtension({ + factory: createApiFactory({ + api: starredEntitiesApiRef, + deps: { storageApi: storageApiRef }, + factory: ({ storageApi }) => new DefaultStarredEntitiesApi({ storageApi }), + }), +}); + +export default [CatalogApi, StarredEntitiesApi]; diff --git a/plugins/catalog/src/alpha/entityCards.tsx b/plugins/catalog/src/alpha/entityCards.tsx new file mode 100644 index 0000000000..cc30928fcf --- /dev/null +++ b/plugins/catalog/src/alpha/entityCards.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { createEntityCardExtension } from '@backstage/plugin-catalog-react/alpha'; + +export const EntityAboutCard = createEntityCardExtension({ + id: 'about', + loader: async () => + import('../components/AboutCard').then(m => ( + + )), +}); + +export const EntityLinksCard = createEntityCardExtension({ + id: 'links', + loader: async () => + import('../components/EntityLinksCard').then(m => { + return ; + }), +}); + +export const EntityLabelsCard = createEntityCardExtension({ + id: 'labels', + loader: async () => + import('../components/EntityLabelsCard').then(m => ( + + )), +}); + +export const EntityDependsOnComponentsCard = createEntityCardExtension({ + id: 'dependsOn.components', + loader: async () => + import('../components/DependsOnComponentsCard').then(m => ( + + )), +}); + +export const EntityDependsOnResourcesCard = createEntityCardExtension({ + id: 'dependsOn.resources', + loader: async () => + import('../components/DependsOnResourcesCard').then(m => ( + + )), +}); + +export const EntityHasComponentsCard = createEntityCardExtension({ + id: 'has.components', + loader: async () => + import('../components/HasComponentsCard').then(m => ( + + )), +}); + +export const EntityHasResourcesCard = createEntityCardExtension({ + id: 'has.resources', + loader: async () => + import('../components/HasResourcesCard').then(m => ( + + )), +}); + +export const EntityHasSubcomponentsCard = createEntityCardExtension({ + id: 'has.subcomponents', + loader: async () => + import('../components/HasSubcomponentsCard').then(m => ( + + )), +}); + +export const EntityHasSystemsCard = createEntityCardExtension({ + id: 'has.systems', + loader: async () => + import('../components/HasSystemsCard').then(m => ( + + )), +}); + +export default [ + EntityAboutCard, + EntityLinksCard, + EntityLabelsCard, + EntityDependsOnComponentsCard, + EntityDependsOnResourcesCard, + EntityHasComponentsCard, + EntityHasResourcesCard, + EntityHasSubcomponentsCard, + EntityHasSystemsCard, +]; diff --git a/plugins/catalog/src/alpha/entityContents.tsx b/plugins/catalog/src/alpha/entityContents.tsx new file mode 100644 index 0000000000..0e26373b5e --- /dev/null +++ b/plugins/catalog/src/alpha/entityContents.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Grid } from '@material-ui/core'; +import { + coreExtensionData, + createExtensionInput, +} from '@backstage/frontend-plugin-api'; +import { createEntityContentExtension } from '@backstage/plugin-catalog-react/alpha'; + +export const OverviewEntityContent = createEntityContentExtension({ + id: 'overview', + defaultPath: '/', + defaultTitle: 'Overview', + disabled: false, + inputs: { + cards: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + loader: async ({ inputs }) => ( + + {inputs.cards.map(card => ( + + {card.element} + + ))} + + ), +}); + +export default [OverviewEntityContent]; diff --git a/plugins/catalog/src/alpha/builtInFilterExtensions.tsx b/plugins/catalog/src/alpha/filters.tsx similarity index 98% rename from plugins/catalog/src/alpha/builtInFilterExtensions.tsx rename to plugins/catalog/src/alpha/filters.tsx index 5a7575c4f5..7b73c2d14b 100644 --- a/plugins/catalog/src/alpha/builtInFilterExtensions.tsx +++ b/plugins/catalog/src/alpha/filters.tsx @@ -109,7 +109,7 @@ const CatalogUserListFilter = createCatalogFilterExtension({ }, }); -export const builtInFilterExtensions = [ +export default [ CatalogEntityTagFilter, CatalogEntityKindFilter, CatalogEntityTypeFilter, diff --git a/plugins/catalog/src/alpha/navItems.tsx b/plugins/catalog/src/alpha/navItems.tsx new file mode 100644 index 0000000000..b6481fd764 --- /dev/null +++ b/plugins/catalog/src/alpha/navItems.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import HomeIcon from '@material-ui/icons/Home'; +import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; +import { createNavItemExtension } from '@backstage/frontend-plugin-api'; +import { rootRouteRef } from '../routes'; + +export const CatalogIndexNavItem = createNavItemExtension({ + id: 'catalog.nav.index', + routeRef: convertLegacyRouteRef(rootRouteRef), + title: 'Catalog', + icon: HomeIcon, +}); + +export default [CatalogIndexNavItem]; diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx new file mode 100644 index 0000000000..6270ae777d --- /dev/null +++ b/plugins/catalog/src/alpha/pages.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; +import { + createPageExtension, + coreExtensionData, + createExtensionInput, +} from '@backstage/frontend-plugin-api'; +import { + AsyncEntityProvider, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { entityContentTitleExtensionDataRef } from '@backstage/plugin-catalog-react/alpha'; +import { rootRouteRef } from '../routes'; +import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl'; + +export const CatalogIndexPage = createPageExtension({ + id: 'plugin.catalog.page.index', + defaultPath: '/catalog', + routeRef: convertLegacyRouteRef(rootRouteRef), + inputs: { + filters: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + loader: async ({ inputs }) => { + const { BaseCatalogPage } = await import('../components/CatalogPage'); + const filters = inputs.filters.map(filter => filter.element); + return {filters}} />; + }, +}); + +export const CatalogEntityPage = createPageExtension({ + id: 'plugin.catalog.page.entity', + defaultPath: '/catalog/:namespace/:kind/:name', + routeRef: convertLegacyRouteRef(entityRouteRef), + inputs: { + contents: createExtensionInput({ + element: coreExtensionData.reactElement, + path: coreExtensionData.routePath, + routeRef: coreExtensionData.routeRef.optional(), + title: entityContentTitleExtensionDataRef, + }), + }, + loader: async ({ inputs }) => { + const { EntityLayout } = await import('../components/EntityLayout'); + const Component = () => { + return ( + + + {inputs.contents.map(content => ( + + {content.element} + + ))} + + + ); + }; + return ; + }, +}); + +export default [CatalogIndexPage, CatalogEntityPage]; diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index d9d2878213..2f019ca33e 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -14,166 +14,25 @@ * limitations under the License. */ -import React from 'react'; -import HomeIcon from '@material-ui/icons/Home'; -import { - createApiFactory, - discoveryApiRef, - fetchApiRef, - storageApiRef, -} from '@backstage/core-plugin-api'; import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha'; -import { CatalogClient } from '@backstage/catalog-client'; -import { - createApiExtension, - createPageExtension, - createPlugin, - createNavItemExtension, - coreExtensionData, - createExtensionInput, -} from '@backstage/frontend-plugin-api'; -import { - AsyncEntityProvider, - catalogApiRef, - entityRouteRef, - starredEntitiesApiRef, -} from '@backstage/plugin-catalog-react'; -import { - createEntityContentExtension, - createEntityCardExtension, - entityContentTitleExtensionDataRef, -} from '@backstage/plugin-catalog-react/alpha'; -import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha'; -import { DefaultStarredEntitiesApi } from '../apis'; +import { createPlugin } from '@backstage/frontend-plugin-api'; + +import { entityRouteRef } from '@backstage/plugin-catalog-react'; + import { createComponentRouteRef, createFromTemplateRouteRef, rootRouteRef, viewTechDocRouteRef, } from '../routes'; -import { builtInFilterExtensions } from './builtInFilterExtensions'; -import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl'; -import Grid from '@material-ui/core/Grid'; -/** @alpha */ -export const CatalogApi = createApiExtension({ - factory: createApiFactory({ - api: catalogApiRef, - deps: { - discoveryApi: discoveryApiRef, - fetchApi: fetchApiRef, - }, - factory: ({ discoveryApi, fetchApi }) => - new CatalogClient({ discoveryApi, fetchApi }), - }), -}); - -/** @alpha */ -export const StarredEntitiesApi = createApiExtension({ - factory: createApiFactory({ - api: starredEntitiesApiRef, - deps: { storageApi: storageApiRef }, - factory: ({ storageApi }) => new DefaultStarredEntitiesApi({ storageApi }), - }), -}); - -/** @alpha */ -export const CatalogSearchResultListItemExtension = - createSearchResultListItemExtension({ - id: 'catalog', - predicate: result => result.type === 'software-catalog', - component: () => - import('../components/CatalogSearchResultListItem').then( - m => m.CatalogSearchResultListItem, - ), - }); - -const CatalogIndexPage = createPageExtension({ - id: 'plugin.catalog.page.index', - defaultPath: '/catalog', - routeRef: convertLegacyRouteRef(rootRouteRef), - inputs: { - filters: createExtensionInput({ - element: coreExtensionData.reactElement, - }), - }, - loader: async ({ inputs }) => { - const { BaseCatalogPage } = await import('../components/CatalogPage'); - const filters = inputs.filters.map(filter => filter.element); - return {filters}} />; - }, -}); - -const CatalogEntityPage = createPageExtension({ - id: 'plugin.catalog.page.entity', - defaultPath: '/catalog/:namespace/:kind/:name', - routeRef: convertLegacyRouteRef(entityRouteRef), - inputs: { - contents: createExtensionInput({ - element: coreExtensionData.reactElement, - path: coreExtensionData.routePath, - routeRef: coreExtensionData.routeRef.optional(), - title: entityContentTitleExtensionDataRef, - }), - }, - loader: async ({ inputs }) => { - const { EntityLayout } = await import('../components/EntityLayout'); - const Component = () => { - return ( - - - {inputs.contents.map(content => ( - - {content.element} - - ))} - - - ); - }; - return ; - }, -}); - -const EntityAboutCard = createEntityCardExtension({ - id: 'about', - loader: async () => - import('../components/AboutCard').then(m => ( - - )), -}); - -const OverviewEntityContent = createEntityContentExtension({ - id: 'overview', - defaultPath: '/', - defaultTitle: 'Overview', - disabled: false, - inputs: { - cards: createExtensionInput({ - element: coreExtensionData.reactElement, - }), - }, - loader: async ({ inputs }) => ( - - {inputs.cards.map(card => ( - - {card.element} - - ))} - - ), -}); - -const CatalogNavItem = createNavItemExtension({ - id: 'catalog.nav.index', - routeRef: convertLegacyRouteRef(rootRouteRef), - title: 'Catalog', - icon: HomeIcon, -}); +import apis from './apis'; +import pages from './pages'; +import filters from './filters'; +import navItems from './navItems'; +import entityCards from './entityCards'; +import entityContents from './entityContents'; +import searchResultItems from './searchResultItems'; /** @alpha */ export default createPlugin({ @@ -188,14 +47,12 @@ export default createPlugin({ createFromTemplate: convertLegacyRouteRef(createFromTemplateRouteRef), }, extensions: [ - CatalogApi, - StarredEntitiesApi, - CatalogSearchResultListItemExtension, - CatalogIndexPage, - CatalogEntityPage, - CatalogNavItem, - OverviewEntityContent, - EntityAboutCard, - ...builtInFilterExtensions, + ...apis, + ...pages, + ...filters, + ...navItems, + ...entityCards, + ...entityContents, + ...searchResultItems, ], }); diff --git a/plugins/catalog/src/alpha/searchResultItems.tsx b/plugins/catalog/src/alpha/searchResultItems.tsx new file mode 100644 index 0000000000..f677869136 --- /dev/null +++ b/plugins/catalog/src/alpha/searchResultItems.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha'; + +export const CatalogSearchResultListItemExtension = + createSearchResultListItemExtension({ + id: 'catalog', + predicate: result => result.type === 'software-catalog', + component: () => + import('../components/CatalogSearchResultListItem').then( + m => m.CatalogSearchResultListItem, + ), + }); + +export default [CatalogSearchResultListItemExtension]; diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx index b7d3ca3ed8..0ced712344 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx @@ -14,7 +14,10 @@ * limitations under the License. */ -import { CatalogApi } from '@backstage/catalog-client'; +import { + CatalogApi, + QueryEntitiesInitialRequest, +} from '@backstage/catalog-client'; import { RELATION_OWNED_BY } from '@backstage/catalog-model'; import { TableColumn, TableProps } from '@backstage/core-components'; import { @@ -36,7 +39,7 @@ import { renderInTestApp, } from '@backstage/test-utils'; import DashboardIcon from '@material-ui/icons/Dashboard'; -import { fireEvent, screen } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { createComponentRouteRef } from '../../routes'; import { CatalogTableRow } from '../CatalogTable'; @@ -49,10 +52,12 @@ describe('DefaultCatalogPage', () => { }); afterEach(() => { window.history.replaceState = origReplaceState; + + jest.clearAllMocks(); }); - const catalogApi: Partial = { - getEntities: () => + const catalogApi: jest.Mocked> = { + getEntities: jest.fn().mockImplementation(() => Promise.resolve({ items: [ { @@ -78,6 +83,7 @@ describe('DefaultCatalogPage', () => { kind: 'Component', metadata: { name: 'Entity2', + namespace: 'default', }, spec: { owner: 'not-tools', @@ -97,17 +103,47 @@ describe('DefaultCatalogPage', () => { }, ], }), - getLocationByRef: () => - Promise.resolve({ id: 'id', type: 'url', target: 'url' }), - getEntityFacets: async () => ({ + ), + getLocationByRef: jest + .fn() + .mockImplementation(() => + Promise.resolve({ id: 'id', type: 'url', target: 'url' }), + ), + getEntityFacets: jest.fn().mockImplementation(async () => ({ facets: { 'relations.ownedBy': [ { count: 1, value: 'group:default/not-tools' }, { count: 1, value: 'group:default/tools' }, ], }, - }), + })), + queryEntities: jest + .fn() + .mockImplementation(async (request: QueryEntitiesInitialRequest) => { + if ((request.filter as any)['relations.ownedBy']) { + // owned entities + return { items: [], totalItems: 3, pageInfo: {} }; + } + + if ((request.filter as any)['metadata.name']) { + // starred entities + return { + items: [ + { + apiVersion: '1', + kind: 'component', + metadata: { name: 'Entity1', namespace: 'default' }, + }, + ], + totalItems: 1, + pageInfo: {}, + }; + } + // all items + return { items: [], totalItems: 2, pageInfo: {} }; + }), }; + const testProfile: Partial = { displayName: 'Display Name', }; @@ -183,6 +219,8 @@ describe('DefaultCatalogPage', () => { it('should render the default actions of an item in the grid', async () => { await renderWrapped(); + await waitFor(() => expect(catalogApi.queryEntities).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId('user-picker-owned')); await expect( screen.findByText(/Owned components \(1\)/), @@ -215,6 +253,8 @@ describe('DefaultCatalogPage', () => { ]; await renderWrapped(); + await waitFor(() => expect(catalogApi.queryEntities).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId('user-picker-owned')); await expect( screen.findByText(/Owned components \(1\)/), @@ -231,7 +271,10 @@ describe('DefaultCatalogPage', () => { // https://github.com/mbrn/material-table/issues/1293 it('should render', async () => { await renderWrapped(); + await waitFor(() => expect(catalogApi.queryEntities).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId('user-picker-owned')); + await expect( screen.findByText(/Owned components \(1\)/), ).resolves.toBeInTheDocument(); @@ -252,6 +295,8 @@ describe('DefaultCatalogPage', () => { // entities defaulting to "owned" filter and not based on the selected filter it('should render the correct entities filtered on the selected filter', async () => { await renderWrapped(); + await waitFor(() => expect(catalogApi.queryEntities).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId('user-picker-owned')); await expect( screen.findByText(/Owned components \(1\)/), @@ -275,10 +320,9 @@ describe('DefaultCatalogPage', () => { // Now that we've starred an entity, the "Starred" menu option should be // enabled. - expect(screen.getByTestId('user-picker-starred')).not.toHaveAttribute( - 'aria-disabled', - 'true', - ); + expect( + await screen.findByTestId('user-picker-starred'), + ).not.toHaveAttribute('aria-disabled', 'true'); fireEvent.click(screen.getByTestId('user-picker-starred')); await expect( screen.findByText(/Starred components \(1\)/), diff --git a/plugins/code-coverage-backend/README.md b/plugins/code-coverage-backend/README.md index 54cf201015..2b22b0ad1d 100644 --- a/plugins/code-coverage-backend/README.md +++ b/plugins/code-coverage-backend/README.md @@ -67,6 +67,16 @@ diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts apiRouter.use(notFoundHandler()); ``` +## New Backend System + +The code coverage backend plugin has support for the [new backend system](https://backstage.io/docs/backend-system/), here's how you can set that up: + +In your `packages/backend/src/index.ts` make the following changes: + +```diff ++ backend.add(import('@backstage/plugin-explore-backend')); +``` + ## Configuring your entity In order to use this plugin, you must set the `backstage.io/code-coverage` annotation. diff --git a/plugins/code-coverage-backend/api-report.md b/plugins/code-coverage-backend/api-report.md index 440af50714..8a64361391 100644 --- a/plugins/code-coverage-backend/api-report.md +++ b/plugins/code-coverage-backend/api-report.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import express from 'express'; @@ -11,6 +12,10 @@ import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { UrlReader } from '@backstage/backend-common'; +// @public +const codeCoveragePlugin: () => BackendFeature; +export default codeCoveragePlugin; + // @public export function createRouter(options: RouterOptions): Promise; diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index 9ee98d52ad..d1fa4e4aed 100644 --- a/plugins/code-coverage-backend/package.json +++ b/plugins/code-coverage-backend/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", diff --git a/plugins/code-coverage-backend/src/index.ts b/plugins/code-coverage-backend/src/index.ts index 8511c9b71e..31de2ab3e3 100644 --- a/plugins/code-coverage-backend/src/index.ts +++ b/plugins/code-coverage-backend/src/index.ts @@ -22,3 +22,4 @@ export { createRouter } from './service/router'; export type { RouterOptions } from './service/router'; +export { codeCoveragePlugin as default } from './plugin'; diff --git a/plugins/code-coverage-backend/src/plugin.ts b/plugins/code-coverage-backend/src/plugin.ts new file mode 100644 index 0000000000..340280795f --- /dev/null +++ b/plugins/code-coverage-backend/src/plugin.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { loggerToWinstonLogger } from '@backstage/backend-common'; +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './service/router'; + +/** + * Code coverage backend plugin + * + * @public + */ +export const codeCoveragePlugin = createBackendPlugin({ + pluginId: 'codeCoverage', + register(env) { + env.registerInit({ + deps: { + config: coreServices.rootConfig, + logger: coreServices.logger, + urlReader: coreServices.urlReader, + httpRouter: coreServices.httpRouter, + discovery: coreServices.discovery, + database: coreServices.database, + }, + async init({ + config, + logger, + urlReader, + httpRouter, + discovery, + database, + }) { + httpRouter.use( + await createRouter({ + config, + logger: loggerToWinstonLogger(logger), + urlReader, + discovery, + database, + }), + ); + }, + }); + }, +}); diff --git a/plugins/example-todo-list-backend/api-report.md b/plugins/example-todo-list-backend/api-report.md index d78f5e0796..4c27733124 100644 --- a/plugins/example-todo-list-backend/api-report.md +++ b/plugins/example-todo-list-backend/api-report.md @@ -11,7 +11,7 @@ import { Logger } from 'winston'; // @public export function createRouter(options: RouterOptions): Promise; -// @alpha +// @public const exampleTodoListPlugin: () => BackendFeature; export default exampleTodoListPlugin; diff --git a/plugins/example-todo-list-backend/package.json b/plugins/example-todo-list-backend/package.json index c13bb571e2..21759dffa2 100644 --- a/plugins/example-todo-list-backend/package.json +++ b/plugins/example-todo-list-backend/package.json @@ -17,12 +17,11 @@ "publishConfig": { "access": "public", "main": "dist/index.cjs.js", - "types": "dist/index.d.ts", - "alphaTypes": "dist/index.alpha.d.ts" + "types": "dist/index.d.ts" }, "scripts": { "start": "backstage-cli package start", - "build": "backstage-cli package build --experimental-type-build", + "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", diff --git a/plugins/example-todo-list-backend/src/plugin.ts b/plugins/example-todo-list-backend/src/plugin.ts index 42985f6e88..6bdae201f1 100644 --- a/plugins/example-todo-list-backend/src/plugin.ts +++ b/plugins/example-todo-list-backend/src/plugin.ts @@ -24,7 +24,7 @@ import { createRouter } from './service/router'; /** * The example TODO list backend plugin. * - * @alpha + * @public */ export const exampleTodoListPlugin = createBackendPlugin({ pluginId: 'exampleTodoList', diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 52cff1fdb7..409b3e99f7 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -54,7 +54,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@types/react": "^16.13.1 || ^17.0.0", - "graphiql": "^1.5.12", + "graphiql": "^3.0.6", "graphql": "^16.0.0", "graphql-ws": "^5.4.1", "react-use": "^17.2.4" diff --git a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx index dd38f513be..40c2c168a8 100644 --- a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx +++ b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx @@ -77,12 +77,7 @@ export const GraphiQLBrowser = (props: GraphiQLBrowserProps) => {
- +
diff --git a/plugins/kafka-backend/alpha-api-report.md b/plugins/kafka-backend/alpha-api-report.md new file mode 100644 index 0000000000..01c035aa0d --- /dev/null +++ b/plugins/kafka-backend/alpha-api-report.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/plugin-kafka-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @alpha +const _default: () => BackendFeature; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/kafka-backend/api-report.md b/plugins/kafka-backend/api-report.md index b03bad12e5..20c8260b52 100644 --- a/plugins/kafka-backend/api-report.md +++ b/plugins/kafka-backend/api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { BackendFeature } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; @@ -11,10 +10,6 @@ import { Logger } from 'winston'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; -// @alpha -const kafkaPlugin: () => BackendFeature; -export default kafkaPlugin; - // @public (undocumented) export interface RouterOptions { // (undocumented) diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index 68060f254c..8533f54b35 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -6,10 +6,22 @@ "types": "src/index.ts", "license": "Apache-2.0", "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts", - "alphaTypes": "dist/index.alpha.d.ts" + "access": "public" + }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } }, "backstage": { "role": "backend-plugin" @@ -27,7 +39,7 @@ "configSchema": "config.d.ts", "scripts": { "start": "backstage-cli package start", - "build": "backstage-cli package build --experimental-type-build", + "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", diff --git a/plugins/kafka-backend/src/plugin.ts b/plugins/kafka-backend/src/alpha.ts similarity index 96% rename from plugins/kafka-backend/src/plugin.ts rename to plugins/kafka-backend/src/alpha.ts index 0548a5f128..bc627eb9d3 100644 --- a/plugins/kafka-backend/src/plugin.ts +++ b/plugins/kafka-backend/src/alpha.ts @@ -26,7 +26,7 @@ import { createRouter } from './service/router'; * * @alpha */ -export const kafkaPlugin = createBackendPlugin({ +export default createBackendPlugin({ pluginId: 'kafka', register(env) { env.registerInit({ diff --git a/plugins/kafka-backend/src/index.ts b/plugins/kafka-backend/src/index.ts index d87d313941..70b7fb45a4 100644 --- a/plugins/kafka-backend/src/index.ts +++ b/plugins/kafka-backend/src/index.ts @@ -22,4 +22,3 @@ export type { RouterOptions } from './service/router'; export { createRouter } from './service/router'; -export { kafkaPlugin as default } from './plugin'; diff --git a/plugins/periskop-backend/alpha-api-report.md b/plugins/periskop-backend/alpha-api-report.md new file mode 100644 index 0000000000..d6ace05993 --- /dev/null +++ b/plugins/periskop-backend/alpha-api-report.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/plugin-periskop-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @alpha +const _default: () => BackendFeature; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/periskop-backend/api-report.md b/plugins/periskop-backend/api-report.md index 46d5de8d93..caaed109f9 100644 --- a/plugins/periskop-backend/api-report.md +++ b/plugins/periskop-backend/api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { BackendFeature } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; @@ -11,10 +10,6 @@ import { Logger } from 'winston'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; -// @alpha -const periskopPlugin: () => BackendFeature; -export default periskopPlugin; - // @public (undocumented) export interface RouterOptions { // (undocumented) diff --git a/plugins/periskop-backend/package.json b/plugins/periskop-backend/package.json index 1ef6c62431..73977e626d 100644 --- a/plugins/periskop-backend/package.json +++ b/plugins/periskop-backend/package.json @@ -14,14 +14,26 @@ "directory": "plugins/periskop-backend" }, "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts", - "alphaTypes": "dist/index.alpha.d.ts" + "access": "public" + }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } }, "scripts": { "start": "backstage-cli package start", - "build": "backstage-cli package build --experimental-type-build", + "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", diff --git a/plugins/periskop-backend/src/plugin.ts b/plugins/periskop-backend/src/alpha.ts similarity index 96% rename from plugins/periskop-backend/src/plugin.ts rename to plugins/periskop-backend/src/alpha.ts index 2123dcbece..59271e02fc 100644 --- a/plugins/periskop-backend/src/plugin.ts +++ b/plugins/periskop-backend/src/alpha.ts @@ -26,7 +26,7 @@ import { createRouter } from './service/router'; * * @alpha */ -export const periskopPlugin = createBackendPlugin({ +export default createBackendPlugin({ pluginId: 'periskop', register(env) { env.registerInit({ diff --git a/plugins/periskop-backend/src/index.ts b/plugins/periskop-backend/src/index.ts index 17a6d0d054..ca73cb27ba 100644 --- a/plugins/periskop-backend/src/index.ts +++ b/plugins/periskop-backend/src/index.ts @@ -15,4 +15,3 @@ */ export * from './service/router'; -export { periskopPlugin as default } from './plugin'; diff --git a/plugins/proxy-backend/alpha-api-report.md b/plugins/proxy-backend/alpha-api-report.md new file mode 100644 index 0000000000..73c3052acb --- /dev/null +++ b/plugins/proxy-backend/alpha-api-report.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/plugin-proxy-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @alpha +const _default: () => BackendFeature; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/proxy-backend/api-report.md b/plugins/proxy-backend/api-report.md index ae327d485a..12e6bfa1c9 100644 --- a/plugins/proxy-backend/api-report.md +++ b/plugins/proxy-backend/api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { BackendFeature } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; @@ -12,10 +11,6 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; // @public export function createRouter(options: RouterOptions): Promise; -// @alpha -const proxyPlugin: () => BackendFeature; -export default proxyPlugin; - // @public (undocumented) export interface RouterOptions { // (undocumented) diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index adbfe9fce9..4506f62107 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -6,10 +6,22 @@ "types": "src/index.ts", "license": "Apache-2.0", "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts", - "alphaTypes": "dist/index.alpha.d.ts" + "access": "public" + }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } }, "backstage": { "role": "backend-plugin" @@ -25,7 +37,7 @@ ], "scripts": { "start": "backstage-cli package start", - "build": "backstage-cli package build --experimental-type-build", + "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", diff --git a/plugins/proxy-backend/src/plugin.ts b/plugins/proxy-backend/src/alpha.ts similarity index 96% rename from plugins/proxy-backend/src/plugin.ts rename to plugins/proxy-backend/src/alpha.ts index e4c14b0037..9f6bad4832 100644 --- a/plugins/proxy-backend/src/plugin.ts +++ b/plugins/proxy-backend/src/alpha.ts @@ -26,7 +26,7 @@ import { createRouter } from './service/router'; * * @alpha */ -export const proxyPlugin = createBackendPlugin({ +export default createBackendPlugin({ pluginId: 'proxy', register(env) { env.registerInit({ diff --git a/plugins/proxy-backend/src/index.ts b/plugins/proxy-backend/src/index.ts index 3163abd381..8f5d5c79dc 100644 --- a/plugins/proxy-backend/src/index.ts +++ b/plugins/proxy-backend/src/index.ts @@ -21,4 +21,3 @@ */ export * from './service'; -export { proxyPlugin as default } from './plugin'; diff --git a/plugins/scaffolder-backend-module-gitlab/package.json b/plugins/scaffolder-backend-module-gitlab/package.json index 99c84ed4b9..deecc3b333 100644 --- a/plugins/scaffolder-backend-module-gitlab/package.json +++ b/plugins/scaffolder-backend-module-gitlab/package.json @@ -36,6 +36,7 @@ "@backstage/integration": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", "@gitbeaker/node": "^35.8.0", + "yaml": "^2.0.0", "zod": "^3.21.4" }, "devDependencies": { diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts new file mode 100644 index 0000000000..6a809fc386 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.test.ts @@ -0,0 +1,303 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import yaml from 'yaml'; +import { createGitlabProjectAccessTokenAction } from './createGitlabProjectAccessTokenAction'; // Adjust the import based on your project structure +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { PassThrough } from 'stream'; +import { examples } from './createGitlabProjectAccessTokenAction.examples'; + +jest.mock('node-fetch'); + +const mockGitlabClient = { + ProjectDeployTokens: { + create: jest.fn(), + }, +}; + +jest.mock('@gitbeaker/node', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:projectAccessToken:create examples', () => { + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://api.gitlab.com', + }, + { + host: 'hosted.gitlab.com', + apiBaseUrl: 'https://api.hosted.gitlab.com', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createGitlabProjectAccessTokenAction({ integrations }); + + const mockContext = { + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + }, + workspacePath: 'lol', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('Create a GitLab project access token with minimal options.', async () => { + const fetchMock = jest.spyOn(global, 'fetch'); + const mockResponse = { + token: 'mock-access-token', + }; + + fetchMock.mockResolvedValue({ + json: async () => mockResponse, + } as Response); + + jest.mock('../util', () => ({ + getToken: jest.fn().mockReturnValue({ + token: 'mock-api-token', + integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, + }), + })); + + const input = yaml.parse(examples[0].example).steps[0].input; + + await action.handler({ + ...mockContext, + input, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/projects/456/access_tokens', + { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': 'tokenlols', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: undefined, + scopes: undefined, + access_level: undefined, + }), + }, + ); + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'mock-access-token', + ); + }); + + it('Create a GitLab project access token with custom scopes.', async () => { + const input = yaml.parse(examples[1].example).steps[0].input; + + const mockResponse = { + token: 'mock-access-token', + }; + + const fetchMock = jest.spyOn(global, 'fetch'); + fetchMock.mockResolvedValue({ + json: async () => mockResponse, + } as Response); + + jest.mock('../util', () => ({ + getToken: jest.fn().mockReturnValue({ + token: 'mock-api-token', + integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, + }), + })); + + await action.handler({ + ...mockContext, + input, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/projects/789/access_tokens', + { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': 'tokenlols', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: undefined, + scopes: ['read_registry', 'write_repository'], + access_level: undefined, + }), + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'mock-access-token', + ); + }); + + it('Create a GitLab project access token with a specified name.', async () => { + const input = yaml.parse(examples[2].example).steps[0].input; + + const mockResponse = { + token: 'mock-access-token', + }; + + const fetchMock = jest.spyOn(global, 'fetch'); + fetchMock.mockResolvedValue({ + json: async () => mockResponse, + } as Response); + + jest.mock('../util', () => ({ + getToken: jest.fn().mockReturnValue({ + token: 'mock-api-token', + integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, + }), + })); + + await action.handler({ + ...mockContext, + input, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/projects/101112/access_tokens', + { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': 'tokenlols', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'my-custom-token', + scopes: undefined, + access_level: undefined, + }), + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'mock-access-token', + ); + }); + + it('Create a GitLab project access token with a numeric project ID.', async () => { + const input = yaml.parse(examples[3].example).steps[0].input; + + const mockResponse = { + token: 'mock-access-token', + }; + + const fetchMock = jest.spyOn(global, 'fetch'); + fetchMock.mockResolvedValue({ + json: async () => mockResponse, + } as Response); + + jest.mock('../util', () => ({ + getToken: jest.fn().mockReturnValue({ + token: 'mock-api-token', + integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, + }), + })); + + await action.handler({ + ...mockContext, + input, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/projects/42/access_tokens', + { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': 'tokenlols', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: undefined, + scopes: undefined, + access_level: undefined, + }), + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'mock-access-token', + ); + }); + + it('Create a GitLab project access token using specific GitLab integrations.', async () => { + const input = yaml.parse(examples[4].example).steps[0].input; + + const mockResponse = { + token: 'mock-access-token', + }; + + const fetchMock = jest.spyOn(global, 'fetch'); + fetchMock.mockResolvedValue({ + json: async () => mockResponse, + } as Response); + + jest.mock('../util', () => ({ + getToken: jest.fn().mockReturnValue({ + token: 'mock-api-token', + integrationConfig: { config: { baseUrl: 'https://api.gitlab.com' } }, + }), + })); + + await action.handler({ + ...mockContext, + input, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/projects/123/access_tokens', + { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': 'tokenlols', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: undefined, + scopes: undefined, + access_level: undefined, + }), + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith( + 'access_token', + 'mock-access-token', + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.ts new file mode 100644 index 0000000000..5fe157badf --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.examples.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Create a GitLab project access token with minimal options.', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '456', + }, + }, + ], + }), + }, + { + description: 'Create a GitLab project access token with custom scopes.', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '789', + scopes: ['read_registry', 'write_repository'], + }, + }, + ], + }), + }, + { + description: 'Create a GitLab project access token with a specified name.', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '101112', + name: 'my-custom-token', + }, + }, + ], + }), + }, + { + description: + 'Create a GitLab project access token with a numeric project ID.', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 42, + }, + }, + ], + }), + }, + { + description: + 'Create a GitLab project access token using specific GitLab integrations.', + example: yaml.stringify({ + steps: [ + { + id: 'createAccessToken', + action: 'gitlab:projectAccessToken:create', + name: 'Create GitLab Project Access Token', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: '123', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts index 614de0e221..a050b3c1b4 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabProjectAccessTokenAction.ts @@ -19,6 +19,7 @@ import { ScmIntegrationRegistry } from '@backstage/integration'; import commonGitlabConfig from '../commonGitlabConfig'; import { getToken } from '../util'; import { z } from 'zod'; +import { examples } from './createGitlabProjectAccessTokenAction.examples'; /** * Creates a `gitlab:projectAccessToken:create` Scaffolder action. @@ -32,6 +33,7 @@ export const createGitlabProjectAccessTokenAction = (options: { const { integrations } = options; return createTemplateAction({ id: 'gitlab:projectAccessToken:create', + examples, schema: { input: commonGitlabConfig.merge( z.object({ diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index ba7576b1bb..1f8615b477 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -659,6 +659,7 @@ export const createPublishGithubPullRequestAction: ( reviewers?: string[] | undefined; teamReviewers?: string[] | undefined; commitMessage?: string | undefined; + update?: boolean | undefined; }, JsonObject >; diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 98bd4a1d66..beabaa50aa 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -22,14 +22,16 @@ import { loggerToWinstonLogger } from '@backstage/backend-common'; import { ScmIntegrations } from '@backstage/integration'; import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha'; import { - scaffolderActionsExtensionPoint, - scaffolderTaskBrokerExtensionPoint, - scaffolderTemplatingExtensionPoint, TaskBroker, TemplateAction, TemplateFilter, TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; +import { + scaffolderActionsExtensionPoint, + scaffolderTaskBrokerExtensionPoint, + scaffolderTemplatingExtensionPoint, +} from '@backstage/plugin-scaffolder-node/alpha'; import { createBuiltinActions } from './scaffolder'; import { createRouter } from './service/router'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index a02f21cfc2..3b6e7c3ced 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -142,6 +142,7 @@ export const createPublishGithubPullRequestAction = ( reviewers?: string[]; teamReviewers?: string[]; commitMessage?: string; + update?: boolean; }>({ id: 'publish:github:pull-request', examples, @@ -219,6 +220,11 @@ export const createPublishGithubPullRequestAction = ( title: 'Commit Message', description: 'The commit message for the pull request commit', }, + update: { + type: 'boolean', + title: 'Update', + description: 'Update pull request if already exists', + }, }, }, output: { @@ -256,6 +262,7 @@ export const createPublishGithubPullRequestAction = ( reviewers, teamReviewers, commitMessage, + update, } = ctx.input; const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); @@ -327,6 +334,7 @@ export const createPublishGithubPullRequestAction = ( body: description, head: branchName, draft, + update, }; if (targetBranchName) { createOptions.base = targetBranchName; diff --git a/plugins/scaffolder-node/alpha-api-report.md b/plugins/scaffolder-node/alpha-api-report.md new file mode 100644 index 0000000000..c2a2a1597e --- /dev/null +++ b/plugins/scaffolder-node/alpha-api-report.md @@ -0,0 +1,42 @@ +## API Report File for "@backstage/plugin-scaffolder-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { TaskBroker } from '@backstage/plugin-scaffolder-node'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { TemplateFilter } from '@backstage/plugin-scaffolder-node'; +import { TemplateGlobal } from '@backstage/plugin-scaffolder-node'; + +// @alpha +export interface ScaffolderActionsExtensionPoint { + // (undocumented) + addActions(...actions: TemplateAction[]): void; +} + +// @alpha +export const scaffolderActionsExtensionPoint: ExtensionPoint; + +// @alpha +export interface ScaffolderTaskBrokerExtensionPoint { + // (undocumented) + setTaskBroker(taskBroker: TaskBroker): void; +} + +// @alpha +export const scaffolderTaskBrokerExtensionPoint: ExtensionPoint; + +// @alpha +export interface ScaffolderTemplatingExtensionPoint { + // (undocumented) + addTemplateFilters(filters: Record): void; + // (undocumented) + addTemplateGlobals(filters: Record): void; +} + +// @alpha +export const scaffolderTemplatingExtensionPoint: ExtensionPoint; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/scaffolder-node/api-report.md b/plugins/scaffolder-node/api-report.md index 52ce3c8d13..08caf36812 100644 --- a/plugins/scaffolder-node/api-report.md +++ b/plugins/scaffolder-node/api-report.md @@ -5,7 +5,6 @@ ```ts /// -import { ExtensionPoint } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { Logger } from 'winston'; @@ -13,11 +12,7 @@ import { Observable } from '@backstage/types'; import { Schema } from 'jsonschema'; import { ScmIntegrations } from '@backstage/integration'; import { SpawnOptionsWithoutStdio } from 'child_process'; -import { TaskBroker as TaskBroker_2 } from '@backstage/plugin-scaffolder-node'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; -import { TemplateAction as TemplateAction_2 } from '@backstage/plugin-scaffolder-node'; -import { TemplateFilter as TemplateFilter_2 } from '@backstage/plugin-scaffolder-node'; -import { TemplateGlobal as TemplateGlobal_2 } from '@backstage/plugin-scaffolder-node'; import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; import { UrlReader } from '@backstage/backend-common'; import { UserEntity } from '@backstage/catalog-model'; @@ -109,35 +104,6 @@ export function fetchFile(options: { outputPath: string; }): Promise; -// @alpha -export interface ScaffolderActionsExtensionPoint { - // (undocumented) - addActions(...actions: TemplateAction_2[]): void; -} - -// @alpha -export const scaffolderActionsExtensionPoint: ExtensionPoint; - -// @alpha -export interface ScaffolderTaskBrokerExtensionPoint { - // (undocumented) - setTaskBroker(taskBroker: TaskBroker_2): void; -} - -// @alpha -export const scaffolderTaskBrokerExtensionPoint: ExtensionPoint; - -// @alpha -export interface ScaffolderTemplatingExtensionPoint { - // (undocumented) - addTemplateFilters(filters: Record): void; - // (undocumented) - addTemplateGlobals(filters: Record): void; -} - -// @alpha -export const scaffolderTemplatingExtensionPoint: ExtensionPoint; - // @public export type SerializedTask = { id: string; diff --git a/plugins/scaffolder-node/package.json b/plugins/scaffolder-node/package.json index 3194f0c0d6..8e887b480b 100644 --- a/plugins/scaffolder-node/package.json +++ b/plugins/scaffolder-node/package.json @@ -6,10 +6,22 @@ "types": "src/index.ts", "license": "Apache-2.0", "publishConfig": { - "access": "public", - "alphaTypes": "dist/index.alpha.d.ts", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" + "access": "public" + }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } }, "backstage": { "role": "node-library" @@ -22,7 +34,7 @@ }, "scripts": { "start": "backstage-cli package start", - "build": "backstage-cli package build --experimental-type-build", + "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", "clean": "backstage-cli package clean", diff --git a/plugins/scaffolder-node/src/extensions.ts b/plugins/scaffolder-node/src/alpha.ts similarity index 100% rename from plugins/scaffolder-node/src/extensions.ts rename to plugins/scaffolder-node/src/alpha.ts diff --git a/plugins/scaffolder-node/src/index.ts b/plugins/scaffolder-node/src/index.ts index dcce065108..98691c2afa 100644 --- a/plugins/scaffolder-node/src/index.ts +++ b/plugins/scaffolder-node/src/index.ts @@ -23,11 +23,3 @@ export * from './actions'; export * from './tasks'; export type { TemplateFilter, TemplateGlobal } from './types'; -export { - scaffolderActionsExtensionPoint, - type ScaffolderActionsExtensionPoint, - scaffolderTaskBrokerExtensionPoint, - type ScaffolderTaskBrokerExtensionPoint, - scaffolderTemplatingExtensionPoint, - type ScaffolderTemplatingExtensionPoint, -} from './extensions'; diff --git a/plugins/scaffolder-react/src/next/components/Form/Form.tsx b/plugins/scaffolder-react/src/next/components/Form/Form.tsx index 85e75e2c0d..8717c8b55a 100644 --- a/plugins/scaffolder-react/src/next/components/Form/Form.tsx +++ b/plugins/scaffolder-react/src/next/components/Form/Form.tsx @@ -32,27 +32,34 @@ const WrappedForm = withTheme(MuiTheme); export const Form = (props: PropsWithChildren) => { // This is where we unbreak the changes from RJSF, and make it work with our custom fields so we don't pass on this // breaking change to our users. We will look more into a better API for this in scaffolderv2. - const wrappedFields = Object.fromEntries( - Object.entries(props.fields ?? {}).map(([key, Component]) => [ - key, - (wrapperProps: FieldProps) => { - return ( - - ); - }, - ]), + const wrappedFields = React.useMemo( + () => + Object.fromEntries( + Object.entries(props.fields ?? {}).map(([key, Component]) => [ + key, + (wrapperProps: FieldProps) => { + return ( + + ); + }, + ]), + ), + [props.fields], ); - const templates = { - FieldTemplate, - DescriptionFieldTemplate, - ...props.templates, - }; + const templates = React.useMemo( + () => ({ + FieldTemplate, + DescriptionFieldTemplate, + ...props.templates, + }), + [props.templates], + ); return ( diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index e52560e2a5..9a333944b9 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -117,6 +117,11 @@ export const Stepper = (stepperProps: StepperProps) => { ); }, [props.extensions]); + const fields = useMemo( + () => ({ ...FieldOverrides, ...extensions }), + [extensions], + ); + const validators = useMemo(() => { return Object.fromEntries( props.extensions.map(({ name, validation }) => [name, validation]), @@ -206,7 +211,7 @@ export const Stepper = (stepperProps: StepperProps) => { schema={currentStep.schema} uiSchema={currentStep.uiSchema} onSubmit={handleNext} - fields={{ ...FieldOverrides, ...extensions }} + fields={fields} showErrorList={false} onChange={handleChange} {...(props.formProps ?? {})} diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 00d6c494db..4244c74759 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -8,8 +8,7 @@ "publishConfig": { "access": "public", "main": "dist/index.esm.js", - "types": "dist/index.d.ts", - "alphaTypes": "dist/index.alpha.d.ts" + "types": "dist/index.d.ts" }, "backstage": { "role": "frontend-plugin" @@ -27,7 +26,7 @@ ], "sideEffects": false, "scripts": { - "build": "backstage-cli package build --experimental-type-build", + "build": "backstage-cli package build", "start": "backstage-cli package start", "lint": "backstage-cli package lint", "test": "backstage-cli package test", diff --git a/plugins/todo/package.json b/plugins/todo/package.json index ec27528d72..6c230d7a4b 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -8,8 +8,7 @@ "publishConfig": { "access": "public", "main": "dist/index.esm.js", - "types": "dist/index.d.ts", - "alphaTypes": "dist/index.alpha.d.ts" + "types": "dist/index.d.ts" }, "backstage": { "role": "frontend-plugin" @@ -22,7 +21,7 @@ }, "sideEffects": false, "scripts": { - "build": "backstage-cli package build --experimental-type-build", + "build": "backstage-cli package build", "start": "backstage-cli package start", "lint": "backstage-cli package lint", "test": "backstage-cli package test", diff --git a/plugins/user-settings-backend/alpha-api-report.md b/plugins/user-settings-backend/alpha-api-report.md new file mode 100644 index 0000000000..aa6b246616 --- /dev/null +++ b/plugins/user-settings-backend/alpha-api-report.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/plugin-user-settings-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @alpha +const _default: () => BackendFeature; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/user-settings-backend/api-report.md b/plugins/user-settings-backend/api-report.md index 808bd0f29b..1bc37bbec7 100644 --- a/plugins/user-settings-backend/api-report.md +++ b/plugins/user-settings-backend/api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { BackendFeature } from '@backstage/backend-plugin-api'; import express from 'express'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -19,9 +18,5 @@ export interface RouterOptions { identity: IdentityApi; } -// @alpha -const userSettingsPlugin: () => BackendFeature; -export default userSettingsPlugin; - // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index c72f349301..cf68c43c3b 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -9,10 +9,22 @@ "role": "backend-plugin" }, "publishConfig": { - "access": "public", - "main": "dist/index.cjs.js", - "types": "dist/index.d.ts", - "alphaTypes": "dist/index.alpha.d.ts" + "access": "public" + }, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } }, "homepage": "https://backstage.io", "repository": { @@ -22,7 +34,7 @@ }, "scripts": { "start": "backstage-cli package start", - "build": "backstage-cli package build --experimental-type-build", + "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", diff --git a/plugins/user-settings-backend/src/plugin.ts b/plugins/user-settings-backend/src/alpha.ts similarity index 95% rename from plugins/user-settings-backend/src/plugin.ts rename to plugins/user-settings-backend/src/alpha.ts index e200d22ffe..8404c4ae94 100644 --- a/plugins/user-settings-backend/src/plugin.ts +++ b/plugins/user-settings-backend/src/alpha.ts @@ -25,7 +25,7 @@ import { createRouter } from './service/router'; * * @alpha */ -export const userSettingsPlugin = createBackendPlugin({ +export default createBackendPlugin({ pluginId: 'userSettings', register(env) { env.registerInit({ diff --git a/plugins/user-settings-backend/src/index.ts b/plugins/user-settings-backend/src/index.ts index 14a9704e53..04d8accdaf 100644 --- a/plugins/user-settings-backend/src/index.ts +++ b/plugins/user-settings-backend/src/index.ts @@ -16,4 +16,3 @@ export * from './service'; export * from './database'; -export { userSettingsPlugin as default } from './plugin'; diff --git a/yarn.lock b/yarn.lock index deb4f1a532..70667644f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3327,12 +3327,12 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.1, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": - version: 7.23.1 - resolution: "@babel/runtime@npm:7.23.1" +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.1, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": + version: 7.23.2 + resolution: "@babel/runtime@npm:7.23.2" dependencies: regenerator-runtime: ^0.14.0 - checksum: 0cd0d43e6e7dc7f9152fda8c8312b08321cda2f56ef53d6c22ebdd773abdc6f5d0a69008de90aa41908d00e2c1facb24715ff121274e689305c858355ff02c70 + checksum: 6c4df4839ec75ca10175f636d6362f91df8a3137f86b38f6cd3a4c90668a0fe8e9281d320958f4fbd43b394988958585a17c3aab2a4ea6bf7316b22916a371fb languageName: node linkType: hard @@ -3869,14 +3869,11 @@ __metadata: yn: ^4.0.0 zod: ^3.21.4 peerDependencies: - "@microsoft/api-extractor": ^7.21.2 "@vitejs/plugin-react": ^4.0.4 vite: ^4.4.9 vite-plugin-html: ^3.2.0 vite-plugin-node-polyfills: ^0.14.1 peerDependenciesMeta: - "@microsoft/api-extractor": - optional: true "@vitejs/plugin-react": optional: true vite: @@ -4827,7 +4824,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-microsoft-provider@workspace:^, @backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider": +"@backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider" dependencies: @@ -4904,7 +4901,6 @@ __metadata: "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^" "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" - "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" @@ -6133,6 +6129,7 @@ __metadata: resolution: "@backstage/plugin-code-coverage-backend@workspace:plugins/code-coverage-backend" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" @@ -7151,7 +7148,7 @@ __metadata: "@types/codemirror": ^5.0.0 "@types/react": ^16.13.1 || ^17.0.0 cross-fetch: ^3.1.5 - graphiql: ^1.5.12 + graphiql: ^3.0.6 graphql: ^16.0.0 graphql-ws: ^5.4.1 msw: ^1.0.0 @@ -8522,6 +8519,7 @@ __metadata: "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" "@gitbeaker/node": ^35.8.0 + yaml: ^2.0.0 zod: ^3.21.4 languageName: unknown linkType: soft @@ -10554,6 +10552,15 @@ __metadata: languageName: node linkType: hard +"@emotion/is-prop-valid@npm:^0.8.2": + version: 0.8.8 + resolution: "@emotion/is-prop-valid@npm:0.8.8" + dependencies: + "@emotion/memoize": 0.7.4 + checksum: bb7ec6d48c572c540e24e47cc94fc2f8dec2d6a342ae97bc9c8b6388d9b8d283862672172a1bb62d335c02662afe6291e10c71e9b8642664a8b43416cdceffac + languageName: node + linkType: hard + "@emotion/is-prop-valid@npm:^1.1.0, @emotion/is-prop-valid@npm:^1.2.1": version: 1.2.1 resolution: "@emotion/is-prop-valid@npm:1.2.1" @@ -10563,6 +10570,13 @@ __metadata: languageName: node linkType: hard +"@emotion/memoize@npm:0.7.4": + version: 0.7.4 + resolution: "@emotion/memoize@npm:0.7.4" + checksum: 4e3920d4ec95995657a37beb43d3f4b7d89fed6caa2b173a4c04d10482d089d5c3ea50bbc96618d918b020f26ed6e9c4026bbd45433566576c1f7b056c3271dc + languageName: node + linkType: hard + "@emotion/memoize@npm:^0.8.1": version: 0.8.1 resolution: "@emotion/memoize@npm:0.8.1" @@ -11255,7 +11269,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/react-dom@npm:^2.0.2": +"@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.0.2": version: 2.0.2 resolution: "@floating-ui/react-dom@npm:2.0.2" dependencies: @@ -11431,6 +11445,33 @@ __metadata: languageName: node linkType: hard +"@graphiql/react@npm:^0.19.4": + version: 0.19.4 + resolution: "@graphiql/react@npm:0.19.4" + dependencies: + "@graphiql/toolkit": ^0.9.1 + "@headlessui/react": ^1.7.15 + "@radix-ui/react-dialog": ^1.0.4 + "@radix-ui/react-dropdown-menu": ^2.0.5 + "@radix-ui/react-tooltip": ^1.0.6 + "@radix-ui/react-visually-hidden": ^1.0.3 + "@types/codemirror": ^5.60.8 + clsx: ^1.2.1 + codemirror: ^5.65.3 + codemirror-graphql: ^2.0.10 + copy-to-clipboard: ^3.2.0 + framer-motion: ^6.5.1 + graphql-language-service: ^5.2.0 + markdown-it: ^12.2.0 + set-value: ^4.1.0 + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 + react: ^16.8.0 || ^17 || ^18 + react-dom: ^16.8.0 || ^17 || ^18 + checksum: 08eb53fc0449d5814ba92faff8e03ba77e97ecd4d3d473d115d1219e52b59d9a1cc1b0f34c41315b3a0185fb862646c1affd2094e1f5f524328a95c8e4e8b591 + languageName: node + linkType: hard + "@graphiql/toolkit@npm:^0.6.1": version: 0.6.1 resolution: "@graphiql/toolkit@npm:0.6.1" @@ -11444,6 +11485,22 @@ __metadata: languageName: node linkType: hard +"@graphiql/toolkit@npm:^0.9.1": + version: 0.9.1 + resolution: "@graphiql/toolkit@npm:0.9.1" + dependencies: + "@n1ru4l/push-pull-async-iterable-iterator": ^3.1.0 + meros: ^1.1.4 + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 + graphql-ws: ">= 4.5.0" + peerDependenciesMeta: + graphql-ws: + optional: true + checksum: 5328426051b7f9a9ffbd569c950d1a103ce0e2ee7b5d7a57f3d899488ad43d1a5101e8aeced7416e106c7687d67bb7981aa7e87dea5b0f17b77569aa738bf3b5 + languageName: node + linkType: hard + "@graphql-codegen/cli@npm:^3.0.0": version: 3.3.1 resolution: "@graphql-codegen/cli@npm:3.3.1" @@ -12226,6 +12283,18 @@ __metadata: languageName: node linkType: hard +"@headlessui/react@npm:^1.7.15": + version: 1.7.17 + resolution: "@headlessui/react@npm:1.7.17" + dependencies: + client-only: ^0.0.1 + peerDependencies: + react: ^16 || ^17 || ^18 + react-dom: ^16 || ^17 || ^18 + checksum: 0cdb67747e7f606f78214dac0b48573247779e70534b4471515c094b74addda173dc6a9847d33aea9c6e6bc151016c034125328953077e32aa7947ebabed91f7 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.11.11": version: 0.11.11 resolution: "@humanwhocodes/config-array@npm:0.11.11" @@ -13369,6 +13438,71 @@ __metadata: languageName: node linkType: hard +"@motionone/animation@npm:^10.12.0": + version: 10.16.3 + resolution: "@motionone/animation@npm:10.16.3" + dependencies: + "@motionone/easing": ^10.16.3 + "@motionone/types": ^10.16.3 + "@motionone/utils": ^10.16.3 + tslib: ^2.3.1 + checksum: 797cacea335e6f892af27579eff51450dcf18c5bbc5c0ca44a000929b21857f4afb974ffb411c4935bfbd01ef2ddb3ef542ba3313ae66e1e5392b5d314df6ad3 + languageName: node + linkType: hard + +"@motionone/dom@npm:10.12.0": + version: 10.12.0 + resolution: "@motionone/dom@npm:10.12.0" + dependencies: + "@motionone/animation": ^10.12.0 + "@motionone/generators": ^10.12.0 + "@motionone/types": ^10.12.0 + "@motionone/utils": ^10.12.0 + hey-listen: ^1.0.8 + tslib: ^2.3.1 + checksum: 123356f28e44362c4f081aae3df22e576f46bfcb07e01257b2ac64a115668448f29b8de67e4b6e692c5407cffb78ffe7cf9fa1bc064007482bab5dd23a69d380 + languageName: node + linkType: hard + +"@motionone/easing@npm:^10.16.3": + version: 10.16.3 + resolution: "@motionone/easing@npm:10.16.3" + dependencies: + "@motionone/utils": ^10.16.3 + tslib: ^2.3.1 + checksum: 03e2460cdd35ee4967a86ce28ffbaaaca589263f659f652801cf6bd667baba9b3d5ce6d134df6b64413b60b34dd21d7c38b0cd8a4c3e1ed789789cdb971905b2 + languageName: node + linkType: hard + +"@motionone/generators@npm:^10.12.0": + version: 10.16.4 + resolution: "@motionone/generators@npm:10.16.4" + dependencies: + "@motionone/types": ^10.16.3 + "@motionone/utils": ^10.16.3 + tslib: ^2.3.1 + checksum: 185091c5cfbe67c38e84bf3920d1b5862e5d7eb624136494a7e4779b2f9d06855ebe3e633d95dcc5a1735d92d59d1ae28a0724c2f9d8bddd60fc9bc3603fab48 + languageName: node + linkType: hard + +"@motionone/types@npm:^10.12.0, @motionone/types@npm:^10.16.3": + version: 10.16.3 + resolution: "@motionone/types@npm:10.16.3" + checksum: ff38982f5aff2c0abbc3051c843d186d6f954c971e97dd6fced97a4ef50ee04f6e49607541ebb80e14dd143cf63553c388392110e270d04eca23f6b529f7f321 + languageName: node + linkType: hard + +"@motionone/utils@npm:^10.12.0, @motionone/utils@npm:^10.16.3": + version: 10.16.3 + resolution: "@motionone/utils@npm:10.16.3" + dependencies: + "@motionone/types": ^10.16.3 + hey-listen: ^1.0.8 + tslib: ^2.3.1 + checksum: d06025911c54c2217c98026cd38d4d681268a2b9b2830ac7342820881ba6be09721dd03626f52547749ead0543d5e2f2a69c9270ffdeaabc0949f7afb3233817 + languageName: node + linkType: hard + "@mswjs/cookies@npm:^0.2.2": version: 0.2.2 resolution: "@mswjs/cookies@npm:0.2.2" @@ -14609,6 +14743,564 @@ __metadata: languageName: node linkType: hard +"@radix-ui/primitive@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/primitive@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + checksum: 2b93e161d3fdabe9a64919def7fa3ceaecf2848341e9211520c401181c9eaebb8451c630b066fad2256e5c639c95edc41de0ba59c40eff37e799918d019822d1 + languageName: node + linkType: hard + +"@radix-ui/react-arrow@npm:1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-arrow@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-primitive": 1.0.3 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 8cca086f0dbb33360e3c0142adf72f99fc96352d7086d6c2356dbb2ea5944cfb720a87d526fc48087741c602cd8162ca02b0af5e6fdf5f56d20fddb44db8b4c3 + languageName: node + linkType: hard + +"@radix-ui/react-collection@npm:1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-collection@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-slot": 1.0.2 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: acfbc9b0b2c553d343c22f02c9f098bc5cfa99e6e48df91c0d671855013f8b877ade9c657b7420a7aa523b5aceadea32a60dd72c23b1291f415684fb45d00cff + languageName: node + linkType: hard + +"@radix-ui/react-compose-refs@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-compose-refs@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 2b9a613b6db5bff8865588b6bf4065f73021b3d16c0a90b2d4c23deceeb63612f1f15de188227ebdc5f88222cab031be617a9dd025874c0487b303be3e5cc2a8 + languageName: node + linkType: hard + +"@radix-ui/react-context@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-context@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 60e9b81d364f40c91a6213ec953f7c64fcd9d75721205a494a5815b3e5ae0719193429b62ee6c7002cd6aaf70f8c0e2f08bdbaba9ffcc233044d32b56d2127d1 + languageName: node + linkType: hard + +"@radix-ui/react-dialog@npm:^1.0.4": + version: 1.0.5 + resolution: "@radix-ui/react-dialog@npm:1.0.5" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-dismissable-layer": 1.0.5 + "@radix-ui/react-focus-guards": 1.0.1 + "@radix-ui/react-focus-scope": 1.0.4 + "@radix-ui/react-id": 1.0.1 + "@radix-ui/react-portal": 1.0.4 + "@radix-ui/react-presence": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-slot": 1.0.2 + "@radix-ui/react-use-controllable-state": 1.0.1 + aria-hidden: ^1.1.1 + react-remove-scroll: 2.5.5 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 3d11ca31afb794a6dd286005ab7894cb0ce7bc2de5481de98900470b11d495256401306763de030f5e35aa545ff90d34632ffd54a1b29bf55afba813be4bb84a + languageName: node + linkType: hard + +"@radix-ui/react-direction@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-direction@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 5336a8b0d4f1cde585d5c2b4448af7b3d948bb63a1aadb37c77771b0e5902dc6266e409cf35fd0edaca7f33e26424be19e64fb8f9d7f7be2d6f1714ea2764210 + languageName: node + linkType: hard + +"@radix-ui/react-dismissable-layer@npm:1.0.5": + version: 1.0.5 + resolution: "@radix-ui/react-dismissable-layer@npm:1.0.5" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-callback-ref": 1.0.1 + "@radix-ui/react-use-escape-keydown": 1.0.3 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: e73cf4bd3763f4d55b1bea7486a9700384d7d94dc00b1d5a75e222b2f1e4f32bc667a206ca4ed3baaaf7424dce7a239afd0ba59a6f0d89c3462c4e6e8d029a04 + languageName: node + linkType: hard + +"@radix-ui/react-dropdown-menu@npm:^2.0.5": + version: 2.0.6 + resolution: "@radix-ui/react-dropdown-menu@npm:2.0.6" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-id": 1.0.1 + "@radix-ui/react-menu": 2.0.6 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-controllable-state": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 1433e04234c29ae688b1d50b4a5ad0fd67e2627a5ea2e5f60fec6e4307e673ef35a703672eae0d61d96156c59084bbb19de9f9b9936b3fc351917dfe41dcf403 + languageName: node + linkType: hard + +"@radix-ui/react-focus-guards@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-focus-guards@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 1f8ca8f83b884b3612788d0742f3f054e327856d90a39841a47897dbed95e114ee512362ae314177de226d05310047cabbf66b686ae86ad1b65b6b295be24ef7 + languageName: node + linkType: hard + +"@radix-ui/react-focus-scope@npm:1.0.4": + version: 1.0.4 + resolution: "@radix-ui/react-focus-scope@npm:1.0.4" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-callback-ref": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 3481db1a641513a572734f0bcb0e47fefeba7bccd6ec8dde19f520719c783ef0b05a55ef0d5292078ed051cc5eda46b698d5d768da02e26e836022f46b376fd1 + languageName: node + linkType: hard + +"@radix-ui/react-id@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-id@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-use-layout-effect": 1.0.1 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 446a453d799cc790dd2a1583ff8328da88271bff64530b5a17c102fa7fb35eece3cf8985359d416f65e330cd81aa7b8fe984ea125fc4f4eaf4b3801d698e49fe + languageName: node + linkType: hard + +"@radix-ui/react-menu@npm:2.0.6": + version: 2.0.6 + resolution: "@radix-ui/react-menu@npm:2.0.6" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-collection": 1.0.3 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-direction": 1.0.1 + "@radix-ui/react-dismissable-layer": 1.0.5 + "@radix-ui/react-focus-guards": 1.0.1 + "@radix-ui/react-focus-scope": 1.0.4 + "@radix-ui/react-id": 1.0.1 + "@radix-ui/react-popper": 1.1.3 + "@radix-ui/react-portal": 1.0.4 + "@radix-ui/react-presence": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-roving-focus": 1.0.4 + "@radix-ui/react-slot": 1.0.2 + "@radix-ui/react-use-callback-ref": 1.0.1 + aria-hidden: ^1.1.1 + react-remove-scroll: 2.5.5 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: a43fb560dbb5a4ddc43ea4e2434a9f517bbbcbf8b12e1e74c1e36666ad321aef7e39f91770140c106fe6f34e237102be8a02f3bc5588e6c06a709e20580c5e82 + languageName: node + linkType: hard + +"@radix-ui/react-popper@npm:1.1.3": + version: 1.1.3 + resolution: "@radix-ui/react-popper@npm:1.1.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@floating-ui/react-dom": ^2.0.0 + "@radix-ui/react-arrow": 1.0.3 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-callback-ref": 1.0.1 + "@radix-ui/react-use-layout-effect": 1.0.1 + "@radix-ui/react-use-rect": 1.0.1 + "@radix-ui/react-use-size": 1.0.1 + "@radix-ui/rect": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: b18a15958623f9222b6ed3e24b9fbcc2ba67b8df5a5272412f261de1592b3f05002af1c8b94c065830c3c74267ce00cf6c1d70d4d507ec92ba639501f98aa348 + languageName: node + linkType: hard + +"@radix-ui/react-portal@npm:1.0.4": + version: 1.0.4 + resolution: "@radix-ui/react-portal@npm:1.0.4" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-primitive": 1.0.3 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: c4cf35e2f26a89703189d0eef3ceeeb706ae0832e98e558730a5e929ca7c72c7cb510413a24eca94c7732f8d659a1e81942bec7b90540cb73ce9e4885d040b64 + languageName: node + linkType: hard + +"@radix-ui/react-presence@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-presence@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-use-layout-effect": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: ed2ff9faf9e4257a4065034d3771459e5a91c2d840b2fcec94661761704dbcb65bcdd927d28177a2a129b3dab5664eb90a9b88309afe0257a9f8ba99338c0d95 + languageName: node + linkType: hard + +"@radix-ui/react-primitive@npm:1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-primitive@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-slot": 1.0.2 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 9402bc22923c8e5c479051974a721c301535c36521c0237b83e5fa213d013174e77f3ad7905e6d60ef07e14f88ec7f4ea69891dc7a2b39047f8d3640e8f8d713 + languageName: node + linkType: hard + +"@radix-ui/react-roving-focus@npm:1.0.4": + version: 1.0.4 + resolution: "@radix-ui/react-roving-focus@npm:1.0.4" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-collection": 1.0.3 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-direction": 1.0.1 + "@radix-ui/react-id": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-callback-ref": 1.0.1 + "@radix-ui/react-use-controllable-state": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 69b1c82c2d9db3ba71549a848f2704200dab1b2cd22d050c1e081a78b9a567dbfdc7fd0403ee010c19b79652de69924d8ca2076cd031d6552901e4213493ffc7 + languageName: node + linkType: hard + +"@radix-ui/react-slot@npm:1.0.2": + version: 1.0.2 + resolution: "@radix-ui/react-slot@npm:1.0.2" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-compose-refs": 1.0.1 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: edf5edf435ff594bea7e198bf16d46caf81b6fb559493acad4fa8c308218896136acb16f9b7238c788fd13e94a904f2fd0b6d834e530e4cae94522cdb8f77ce9 + languageName: node + linkType: hard + +"@radix-ui/react-tooltip@npm:^1.0.6": + version: 1.0.7 + resolution: "@radix-ui/react-tooltip@npm:1.0.7" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-dismissable-layer": 1.0.5 + "@radix-ui/react-id": 1.0.1 + "@radix-ui/react-popper": 1.1.3 + "@radix-ui/react-portal": 1.0.4 + "@radix-ui/react-presence": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-slot": 1.0.2 + "@radix-ui/react-use-controllable-state": 1.0.1 + "@radix-ui/react-visually-hidden": 1.0.3 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 894d448c69a3e4d7626759f9f6c7997018fe8ef9cde098393bd83e10743d493dfd284eef041e46accc45486d5a5cd5f76d97f56afbdace7aed6e0cb14007bf15 + languageName: node + linkType: hard + +"@radix-ui/react-use-callback-ref@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-use-callback-ref@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: b9fd39911c3644bbda14a84e4fca080682bef84212b8d8931fcaa2d2814465de242c4cfd8d7afb3020646bead9c5e539d478cea0a7031bee8a8a3bb164f3bc4c + languageName: node + linkType: hard + +"@radix-ui/react-use-controllable-state@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-use-controllable-state@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-use-callback-ref": 1.0.1 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: dee2be1937d293c3a492cb6d279fc11495a8f19dc595cdbfe24b434e917302f9ac91db24e8cc5af9a065f3f209c3423115b5442e65a5be9fd1e9091338972be9 + languageName: node + linkType: hard + +"@radix-ui/react-use-escape-keydown@npm:1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-use-escape-keydown@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-use-callback-ref": 1.0.1 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: c6ed0d9ce780f67f924980eb305af1f6cce2a8acbaf043a58abe0aa3cc551d9aa76ccee14531df89bbee302ead7ecc7fce330886f82d4672c5eda52f357ef9b8 + languageName: node + linkType: hard + +"@radix-ui/react-use-layout-effect@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-use-layout-effect@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: bed9c7e8de243a5ec3b93bb6a5860950b0dba359b6680c84d57c7a655e123dec9b5891c5dfe81ab970652e7779fe2ad102a23177c7896dde95f7340817d47ae5 + languageName: node + linkType: hard + +"@radix-ui/react-use-rect@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-use-rect@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/rect": 1.0.1 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 433f07e61e04eb222349825bb05f3591fca131313a1d03709565d6226d8660bd1d0423635553f95ee4fcc25c8f2050972d848808d753c388e2a9ae191ebf17f3 + languageName: node + linkType: hard + +"@radix-ui/react-use-size@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/react-use-size@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-use-layout-effect": 1.0.1 + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 6cc150ad1e9fa85019c225c5a5d50a0af6cdc4653dad0c21b4b40cd2121f36ee076db326c43e6bc91a69766ccff5a84e917d27970176b592577deea3c85a3e26 + languageName: node + linkType: hard + +"@radix-ui/react-visually-hidden@npm:1.0.3, @radix-ui/react-visually-hidden@npm:^1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-visually-hidden@npm:1.0.3" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/react-primitive": 1.0.3 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 2e9d0c8253f97e7d6ffb2e52a5cfd40ba719f813b39c3e2e42c496d54408abd09ef66b5aec4af9b8ab0553215e32452a5d0934597a49c51dd90dc39181ed0d57 + languageName: node + linkType: hard + +"@radix-ui/rect@npm:1.0.1": + version: 1.0.1 + resolution: "@radix-ui/rect@npm:1.0.1" + dependencies: + "@babel/runtime": ^7.13.10 + checksum: aeec13b234a946052512d05239067d2d63422f9ec70bf2fe7acfd6b9196693fc33fbaf43c2667c167f777d90a095c6604eb487e0bce79e230b6df0f6cacd6a55 + languageName: node + linkType: hard + "@react-hookz/deep-equal@npm:^1.0.4": version: 1.0.4 resolution: "@react-hookz/deep-equal@npm:1.0.4" @@ -17088,12 +17780,21 @@ __metadata: languageName: node linkType: hard -"@types/codemirror@npm:^5.0.0": - version: 5.60.10 - resolution: "@types/codemirror@npm:5.60.10" +"@types/codemirror@npm:^0.0.90": + version: 0.0.90 + resolution: "@types/codemirror@npm:0.0.90" dependencies: "@types/tern": "*" - checksum: c5977db03939f2a208f0ec7958be70b4fb205dd3f3122b2175ff28287a5424da95f9030b2838c61d37e6278ec53795861dec12439967c1e1da885b2b2a65b299 + checksum: f4594b9bc95306bbbe24d967e0749e28fe7b1e461c41621429b8c8bc295bda1704d99c1d7d5496efd987ee80d24f055155ddd742fa0c975cd69f279ccdaa0af9 + languageName: node + linkType: hard + +"@types/codemirror@npm:^5.0.0, @types/codemirror@npm:^5.60.8": + version: 5.60.12 + resolution: "@types/codemirror@npm:5.60.12" + dependencies: + "@types/tern": "*" + checksum: dff22f32ea42ccd3f9bfcf408631f94a11ffb4614ff4fa8cc55adf7da6e7ba96650533b8dd27d037242747bdaa85141e93520f84409db7bc394862a174a10e1e languageName: node linkType: hard @@ -19920,6 +20621,15 @@ __metadata: languageName: node linkType: hard +"aria-hidden@npm:^1.1.1": + version: 1.2.3 + resolution: "aria-hidden@npm:1.2.3" + dependencies: + tslib: ^2.0.0 + checksum: 7d7d211629eef315e94ed3b064c6823d13617e609d3f9afab1c2ed86399bb8e90405f9bdd358a85506802766f3ecb468af985c67c846045a34b973bcc0289db9 + languageName: node + linkType: hard + "aria-query@npm:5.1.3, aria-query@npm:^5.0.0, aria-query@npm:^5.1.3": version: 5.1.3 resolution: "aria-query@npm:5.1.3" @@ -21884,7 +22594,7 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.0.2, clsx@npm:^1.0.4, clsx@npm:^1.1.1": +"clsx@npm:^1.0.2, clsx@npm:^1.0.4, clsx@npm:^1.1.1, clsx@npm:^1.2.1": version: 1.2.1 resolution: "clsx@npm:1.2.1" checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 @@ -21948,6 +22658,20 @@ __metadata: languageName: node linkType: hard +"codemirror-graphql@npm:^2.0.10": + version: 2.0.10 + resolution: "codemirror-graphql@npm:2.0.10" + dependencies: + "@types/codemirror": ^0.0.90 + graphql-language-service: 5.2.0 + peerDependencies: + "@codemirror/language": 6.0.0 + codemirror: ^5.65.3 + graphql: ^15.5.0 || ^16.0.0 + checksum: d14e680168f407fd3d6ab09d88ea33640dd39f84d9ceb1ce605d9687d459fd08787109800a9847bbce72122e3318e975b255f65ddb19725d7a12781f4bf26506 + languageName: node + linkType: hard + "codemirror@npm:^5.65.3": version: 5.65.3 resolution: "codemirror@npm:5.65.3" @@ -23738,6 +24462,13 @@ __metadata: languageName: node linkType: hard +"detect-node-es@npm:^1.1.0": + version: 1.1.0 + resolution: "detect-node-es@npm:1.1.0" + checksum: e46307d7264644975b71c104b9f028ed1d3d34b83a15b8a22373640ce5ea630e5640b1078b8ea15f202b54641da71e4aa7597093bd4b91f113db520a26a37449 + languageName: node + linkType: hard + "detect-node@npm:^2.0.4": version: 2.0.4 resolution: "detect-node@npm:2.0.4" @@ -26611,6 +27342,36 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^6.5.1": + version: 6.5.1 + resolution: "framer-motion@npm:6.5.1" + dependencies: + "@emotion/is-prop-valid": ^0.8.2 + "@motionone/dom": 10.12.0 + framesync: 6.0.1 + hey-listen: ^1.0.8 + popmotion: 11.0.3 + style-value-types: 5.0.0 + tslib: ^2.1.0 + peerDependencies: + react: ">=16.8 || ^17.0.0 || ^18.0.0" + react-dom: ">=16.8 || ^17.0.0 || ^18.0.0" + dependenciesMeta: + "@emotion/is-prop-valid": + optional: true + checksum: 737959063137b4ccafe01e0ac0c9e5a9531bf3f729f62c34ca7a5d7955e6664f70affd22b044f7db51df41acb21d120a4f71a860e17a80c4db766ad66f2153a1 + languageName: node + linkType: hard + +"framesync@npm:6.0.1": + version: 6.0.1 + resolution: "framesync@npm:6.0.1" + dependencies: + tslib: ^2.1.0 + checksum: a23ebe8f7e20a32c0b99c2f8175b6f07af3ec6316aad52a2316316a6d011d717af8d2175dcc2827031c59fabb30232ed3e19a720a373caba7f070e1eae436325 + languageName: node + linkType: hard + "fresh@npm:0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -26909,6 +27670,13 @@ __metadata: languageName: node linkType: hard +"get-nonce@npm:^1.0.0": + version: 1.0.1 + resolution: "get-nonce@npm:1.0.1" + checksum: e2614e43b4694c78277bb61b0f04583d45786881289285c73770b07ded246a98be7e1f78b940c80cbe6f2b07f55f0b724e6db6fd6f1bcbd1e8bdac16521074ed + languageName: node + linkType: hard + "get-package-type@npm:^0.1.0": version: 0.1.0 resolution: "get-package-type@npm:0.1.0" @@ -27293,7 +28061,7 @@ __metadata: languageName: node linkType: hard -"graphiql@npm:^1.5.12, graphiql@npm:^1.8.8": +"graphiql@npm:^1.8.8": version: 1.11.5 resolution: "graphiql@npm:1.11.5" dependencies: @@ -27310,6 +28078,22 @@ __metadata: languageName: node linkType: hard +"graphiql@npm:^3.0.6": + version: 3.0.6 + resolution: "graphiql@npm:3.0.6" + dependencies: + "@graphiql/react": ^0.19.4 + "@graphiql/toolkit": ^0.9.1 + graphql-language-service: ^5.2.0 + markdown-it: ^12.2.0 + peerDependencies: + graphql: ^15.5.0 || ^16.0.0 + react: ^16.8.0 || ^17 || ^18 + react-dom: ^16.8.0 || ^17 || ^18 + checksum: 34d35ca4b46ce1e0cef0362f15d091481d2275e4a5b537b5715cfbaee15b1e114c31f35a63558a6f74a754687fbf4bc977411818af34e2e471a96902370c40f2 + languageName: node + linkType: hard + "graphlib@npm:^2.1.8": version: 2.1.8 resolution: "graphlib@npm:2.1.8" @@ -27344,17 +28128,17 @@ __metadata: languageName: node linkType: hard -"graphql-language-service@npm:^5.0.6": - version: 5.0.6 - resolution: "graphql-language-service@npm:5.0.6" +"graphql-language-service@npm:5.2.0, graphql-language-service@npm:^5.0.6, graphql-language-service@npm:^5.2.0": + version: 5.2.0 + resolution: "graphql-language-service@npm:5.2.0" dependencies: nullthrows: ^1.0.0 - vscode-languageserver-types: ^3.15.1 + vscode-languageserver-types: ^3.17.1 peerDependencies: graphql: ^15.5.0 || ^16.0.0 bin: graphql: dist/temp-bin.js - checksum: a7155ba934aa428278cce0f460fa3b8b12020a26a0355e60738974617a66d9b2f1bb7d41cbd72a1620a29e61f10ca438cbf72cbf2405b8f653edddfc69fec02a + checksum: b053c6b7158d0ee7a3e55391bfd8be956fc5380211ca586b3a252007845e119540fb40efcc438975eaebc5ef25f46973f7ff4d9543c66e14ebd992957e0299b7 languageName: node linkType: hard @@ -27722,6 +28506,13 @@ __metadata: languageName: node linkType: hard +"hey-listen@npm:^1.0.8": + version: 1.0.8 + resolution: "hey-listen@npm:1.0.8" + checksum: 6bad60b367688f5348e25e7ca3276a74b59ac5a09b0455e6ff8ab7d4a9e38cd2116c708a7dcd8a954d27253ce1d8717ec891d175723ea739885b828cf44e4072 + languageName: node + linkType: hard + "highlight.js@npm:^10.1.0, highlight.js@npm:^10.4.1, highlight.js@npm:^10.6.0, highlight.js@npm:^10.7.2, highlight.js@npm:~10.7.0": version: 10.7.3 resolution: "highlight.js@npm:10.7.3" @@ -35422,6 +36213,18 @@ __metadata: languageName: node linkType: hard +"popmotion@npm:11.0.3": + version: 11.0.3 + resolution: "popmotion@npm:11.0.3" + dependencies: + framesync: 6.0.1 + hey-listen: ^1.0.8 + style-value-types: 5.0.0 + tslib: ^2.1.0 + checksum: 9fe7d03b4ec0e85bfb9dadc23b745147bfe42e16f466ba06e6327197d0e38b72015afc2f918a8051dedc3680310417f346ffdc463be6518e2e92e98f48e30268 + languageName: node + linkType: hard + "popper.js@npm:1.16.1-lts": version: 1.16.1-lts resolution: "popper.js@npm:1.16.1-lts" @@ -37030,6 +37833,41 @@ __metadata: languageName: node linkType: hard +"react-remove-scroll-bar@npm:^2.3.3": + version: 2.3.4 + resolution: "react-remove-scroll-bar@npm:2.3.4" + dependencies: + react-style-singleton: ^2.2.1 + tslib: ^2.0.0 + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: b5ce5f2f98d65c97a3e975823ae4043a4ba2a3b63b5ba284b887e7853f051b5cd6afb74abde6d57b421931c52f2e1fdbb625dc858b1cb5a32c27c14ab85649d4 + languageName: node + linkType: hard + +"react-remove-scroll@npm:2.5.5": + version: 2.5.5 + resolution: "react-remove-scroll@npm:2.5.5" + dependencies: + react-remove-scroll-bar: ^2.3.3 + react-style-singleton: ^2.2.1 + tslib: ^2.1.0 + use-callback-ref: ^1.3.0 + use-sidecar: ^1.1.2 + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 2c7fe9cbd766f5e54beb4bec2e2efb2de3583037b23fef8fa511ab426ed7f1ae992382db5acd8ab5bfb030a4b93a06a2ebca41377d6eeaf0e6791bb0a59616a4 + languageName: node + linkType: hard + "react-resizable@npm:^3.0.4, react-resizable@npm:^3.0.5": version: 3.0.5 resolution: "react-resizable@npm:3.0.5" @@ -37163,6 +38001,23 @@ __metadata: languageName: node linkType: hard +"react-style-singleton@npm:^2.2.1": + version: 2.2.1 + resolution: "react-style-singleton@npm:2.2.1" + dependencies: + get-nonce: ^1.0.0 + invariant: ^2.2.4 + tslib: ^2.0.0 + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 7ee8ef3aab74c7ae1d70ff34a27643d11ba1a8d62d072c767827d9ff9a520905223e567002e0bf6c772929d8ea1c781a3ba0cc4a563e92b1e3dc2eaa817ecbe8 + languageName: node + linkType: hard + "react-syntax-highlighter@npm:^15.4.5, react-syntax-highlighter@npm:^15.5.0": version: 15.5.0 resolution: "react-syntax-highlighter@npm:15.5.0" @@ -40052,6 +40907,16 @@ __metadata: languageName: node linkType: hard +"style-value-types@npm:5.0.0": + version: 5.0.0 + resolution: "style-value-types@npm:5.0.0" + dependencies: + hey-listen: ^1.0.8 + tslib: ^2.1.0 + checksum: 16d198302cd102edf9dba94e7752a2364c93b1eaa5cc7c32b42b28eef4af4ccb5149a3f16bc2a256adc02616a2404f4612bd15f3081c1e8ca06132cae78be6c0 + languageName: node + linkType: hard + "styled-components@npm:^5.3.3": version: 5.3.11 resolution: "styled-components@npm:5.3.11" @@ -41783,6 +42648,21 @@ __metadata: languageName: node linkType: hard +"use-callback-ref@npm:^1.3.0": + version: 1.3.0 + resolution: "use-callback-ref@npm:1.3.0" + dependencies: + tslib: ^2.0.0 + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 7913df383a5a6fcb399212eedefaac2e0c6f843555202d4e3010bac3848afe38ecaa3d0d6500ad1d936fbeffd637e6c517e68edb024af5e6beca7f27f3ce7b21 + languageName: node + linkType: hard + "use-composed-ref@npm:^1.3.0": version: 1.3.0 resolution: "use-composed-ref@npm:1.3.0" @@ -41861,6 +42741,22 @@ __metadata: languageName: node linkType: hard +"use-sidecar@npm:^1.1.2": + version: 1.1.2 + resolution: "use-sidecar@npm:1.1.2" + dependencies: + detect-node-es: ^1.1.0 + tslib: ^2.0.0 + peerDependencies: + "@types/react": ^16.9.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 925d1922f9853e516eaad526b6fed1be38008073067274f0ecc3f56b17bb8ab63480140dd7c271f94150027c996cea4efe83d3e3525e8f3eda22055f6a39220b + languageName: node + linkType: hard + "use-sync-external-store@npm:^1.0.0, use-sync-external-store@npm:^1.2.0": version: 1.2.0 resolution: "use-sync-external-store@npm:1.2.0" @@ -42263,10 +43159,10 @@ __metadata: languageName: node linkType: hard -"vscode-languageserver-types@npm:^3.15.1": - version: 3.15.1 - resolution: "vscode-languageserver-types@npm:3.15.1" - checksum: 28c1cb0d5b7ad7c719015a6eb5f774c000fe68c41bbd4a3fd0cbe6d862f27eec075d6bfb14c9d86c22d0e8711c2df8194295bf1e7d6ac0c359cab348c5e14887 +"vscode-languageserver-types@npm:^3.17.1": + version: 3.17.5 + resolution: "vscode-languageserver-types@npm:3.17.5" + checksum: 79b420e7576398d396579ca3a461c9ed70e78db4403cd28bbdf4d3ed2b66a2b4114031172e51fad49f0baa60a2180132d7cb2ea35aa3157d7af3c325528210ac languageName: node linkType: hard