diff --git a/.changeset/angry-bobcats-sit.md b/.changeset/angry-bobcats-sit.md deleted file mode 100644 index ad02ff68da..0000000000 --- a/.changeset/angry-bobcats-sit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -The `subscribe` method on the `Config` returned by `loadBackendConfig` is now forwarded through `getConfig` and `getOptionalConfig`. diff --git a/.changeset/brave-shoes-push.md b/.changeset/brave-shoes-push.md deleted file mode 100644 index 3c6a8d7e0e..0000000000 --- a/.changeset/brave-shoes-push.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@backstage/create-app': patch ---- - -Added the default `ScmAuth` implementation to the app. - -To apply this change to an existing app, head to `packages/app/apis.ts`, import `ScmAuth` from `@backstage/integration-react`, and add a `ScmAuth.createDefaultApiFactory()` to your list of APIs: - -```diff - import { - ScmIntegrationsApi, - scmIntegrationsApiRef, -+ ScmAuth, - } from '@backstage/integration-react'; - - export const apis: AnyApiFactory[] = [ -... -+ ScmAuth.createDefaultApiFactory(), -... - ]; -``` - -If you have integrations towards SCM providers other than the default ones (github.com, gitlab.com, etc.), you will want to create a custom `ScmAuth` factory instead, for example like this: - -```ts -createApiFactory({ - api: scmAuthApiRef, - deps: { - gheAuthApi: gheAuthApiRef, - githubAuthApi: githubAuthApiRef, - }, - factory: ({ githubAuthApi, gheAuthApi }) => - ScmAuth.merge( - ScmAuth.forGithub(githubAuthApi), - ScmAuth.forGithub(gheAuthApi, { - host: 'ghe.example.com', - }), - ), -}); -``` diff --git a/.changeset/curly-months-count.md b/.changeset/curly-months-count.md deleted file mode 100644 index 8541c8c5c2..0000000000 --- a/.changeset/curly-months-count.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Fixed a bug where internal references within the catalog were broken when new entities where added through entity providers, such as registering a new location or adding one in configuration. These broken references then caused some entities to be incorrectly marked as orphaned and prevented refresh from working properly. diff --git a/.changeset/great-rabbits-juggle.md b/.changeset/great-rabbits-juggle.md deleted file mode 100644 index 649af14aeb..0000000000 --- a/.changeset/great-rabbits-juggle.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@backstage/plugin-catalog-import': minor ---- - -Switched to using the `ScmAuthApi` for authentication rather than GitHub auth. If you are instantiating your `CatalogImportClient` manually you now need to pass in an instance of `ScmAuthApi` instead. - -Also be sure to register the `scmAuthApiRef` from the `@backstage/integration-react` in your app: - -```ts -import { ScmAuth } from '@backstage/integration-react'; - -// in packages/app/apis.ts - -const apis = [ -// ... other APIs - -ScmAuth.createDefaultApiFactory(); - -// OR - -createApiFactory({ - api: scmAuthApiRef, - deps: { - gheAuthApi: gheAuthApiRef, - githubAuthApi: githubAuthApiRef, - }, - factory: ({ githubAuthApi, gheAuthApi }) => - ScmAuth.merge( - ScmAuth.forGithub(githubAuthApi), - ScmAuth.forGithub(gheAuthApi, { - host: 'ghe.example.com', - }), - ), -}); -] -``` diff --git a/.changeset/heavy-rabbits-melt.md b/.changeset/heavy-rabbits-melt.md deleted file mode 100644 index cb836588f6..0000000000 --- a/.changeset/heavy-rabbits-melt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -The `create-plugin` command now prefers dependency versions ranges that are already in the lockfile. diff --git a/.changeset/khaki-hotels-doubt.md b/.changeset/khaki-hotels-doubt.md deleted file mode 100644 index 8bab499f3e..0000000000 --- a/.changeset/khaki-hotels-doubt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/integration': patch ---- - -Support selective GitHub app installation for GHE diff --git a/.changeset/mighty-students-itch.md b/.changeset/mighty-students-itch.md deleted file mode 100644 index 7471c56d7e..0000000000 --- a/.changeset/mighty-students-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Fix an issue where filtering in search doesn't work correctly for Bitbucket. diff --git a/.changeset/pink-windows-suffer.md b/.changeset/pink-windows-suffer.md deleted file mode 100644 index f27af1f4a3..0000000000 --- a/.changeset/pink-windows-suffer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Added a check for the TechDocs annotation on the entity diff --git a/.changeset/plenty-feet-pay.md b/.changeset/plenty-feet-pay.md deleted file mode 100644 index 43fab61c79..0000000000 --- a/.changeset/plenty-feet-pay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-components': patch ---- - -Stop forcing `target="_blank"` in the `SupportButton` but instead use the default logic of the `Link` component, that opens external targets in a new window and relative targets in the same window. diff --git a/.changeset/proud-carrots-prove.md b/.changeset/proud-carrots-prove.md deleted file mode 100644 index 8d18b27282..0000000000 --- a/.changeset/proud-carrots-prove.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Fix duplication checks to stop looking for the old core packages, and to allow some explicitly diff --git a/.changeset/purple-scissors-allow.md b/.changeset/purple-scissors-allow.md deleted file mode 100644 index 39d70d4c2a..0000000000 --- a/.changeset/purple-scissors-allow.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -'@backstage/integration-react': patch ---- - -Added `ScmAuthApi` along with the implementation `ScmAuth`. The `ScmAuthApi` provides methods for client-side authentication towards multiple different source code management services simultaneously. - -When requesting credentials you supply a URL along with the same options as the other `OAuthApi`s, and optionally a request for additional high-level scopes. - -For example like this: - -```ts -const { token } = await scmAuthApi.getCredentials({ - url: 'https://ghe.example.com/backstage/backstage', - additionalScope: { - repoWrite: true, - }, -}); -``` - -The instantiation of the API can either be done with a default factory that adds support for the public providers (github.com, gitlab.com, etc.): - -```ts -// in packages/app/apis.ts -ScmAuth.createDefaultApiFactory(); -``` - -Or with a more custom setup that can add support for additional providers, for example like this: - -```ts -createApiFactory({ - api: scmAuthApiRef, - deps: { - gheAuthApi: gheAuthApiRef, - githubAuthApi: githubAuthApiRef, - }, - factory: ({ githubAuthApi, gheAuthApi }) => - ScmAuth.merge( - ScmAuth.forGithub(githubAuthApi), - ScmAuth.forGithub(gheAuthApi, { - host: 'ghe.example.com', - }), - ), -}); -``` - -The additional `gheAuthApiRef` utility API can be defined either inside the app itself if it's only used for this purpose, for inside an internal common package for APIs, such as `@internal/apis`: - -```ts -const gheAuthApiRef: ApiRef = - createApiRef({ - id: 'internal.auth.ghe', - }); -``` - -And then implemented using the `GithubAuth` class from `@backstage/core-app-api`: - -```ts -createApiFactory({ - api: githubAuthApiRef, - deps: { - discoveryApi: discoveryApiRef, - oauthRequestApi: oauthRequestApiRef, - configApi: configApiRef, - }, - factory: ({ discoveryApi, oauthRequestApi, configApi }) => - GithubAuth.create({ - provider: { - id: 'ghe', - icon: ..., - title: 'GHE' - }, - discoveryApi, - oauthRequestApi, - defaultScopes: ['read:user'], - environment: configApi.getOptionalString('auth.environment'), - }), -}) -``` - -Finally you also need to add and configure another GitHub provider to the `auth-backend` using the provider ID `ghe`: - -```ts -// Add the following options to `createRouter` in packages/backend/src/plugins/auth.ts -providerFactories: { - ghe: createGithubProvider(), -}, -``` - -Other providers follow the same steps, but you will want to use the appropriate auth API implementation in the frontend, such as for example `GitlabAuth`. diff --git a/.changeset/seven-bulldogs-grin.md b/.changeset/seven-bulldogs-grin.md deleted file mode 100644 index 2d6f0c14c1..0000000000 --- a/.changeset/seven-bulldogs-grin.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': minor ---- - -Introduced a new `CatalogProcessorCache` that is available to catalog processors. It allows arbitrary values to be saved that will then be visible during the next run. The cache is scoped to each individual processor and entity, but is shared across processing steps in a single processor. - -The cache is available as a new argument to each of the processing steps, except for `validateEntityKind` and `handleError`. - -This also introduces an optional `getProcessorName` to the `CatalogProcessor` interface, which is used to provide a stable identifier for the processor. While it is currently optional it will move to be required in the future. - -The breaking part of this change is the modification of the `state` field in the `EntityProcessingRequest` and `EntityProcessingResult` types. This is unlikely to have any impact as the `state` field was previously unused, but could require some minor updates. diff --git a/.changeset/smart-cooks-drop.md b/.changeset/smart-cooks-drop.md deleted file mode 100644 index 5ce59f2837..0000000000 --- a/.changeset/smart-cooks-drop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kubernetes': patch ---- - -Added a check for the Kubernetes annotation on the entity diff --git a/.changeset/thick-rabbits-double.md b/.changeset/thick-rabbits-double.md deleted file mode 100644 index 4ff38bf860..0000000000 --- a/.changeset/thick-rabbits-double.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Update OAuth refresh handler to pass updated refresh token to ensure cookie is updated with new value. diff --git a/.changeset/tricky-pens-camp.md b/.changeset/tricky-pens-camp.md deleted file mode 100644 index dc47274351..0000000000 --- a/.changeset/tricky-pens-camp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Update AboutCard to only render refresh button if the entity is managed by an url location. diff --git a/.eslintrc.js b/.eslintrc.js index 6c8b62e4aa..ec7b4f9130 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,5 +34,22 @@ module.exports = { onNonMatchingHeader: 'replace', }, ], + 'no-restricted-syntax': [ + 'error', + { + message: + "Avoid using .toLowerCase(), use .toLocaleLowerCase('en-US') instead. " + + 'This rule can sometimes be ignored when converting text to be displayed to the user.', + selector: + "CallExpression[arguments.length=0] > MemberExpression[property.name='toLowerCase']", + }, + { + message: + "Avoid using .toUpperCase(), use .toLocaleUpperCase('en-US') instead. " + + 'This rule can sometimes be ignored when converting text to be displayed to the user.', + selector: + "CallExpression[arguments.length=0] > MemberExpression[property.name='toUpperCase']", + }, + ], }, }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0854635a2b..b637524a52 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -23,3 +23,4 @@ /.changeset/cost-insights-* @backstage/reviewers @backstage/silver-lining /.changeset/search-* @backstage/techdocs-core /.changeset/techdocs-* @backstage/techdocs-core +/cypress/src/integration/plugins/techdocs.spec.ts @backstage/techdocs-core diff --git a/.github/ISSUE_TEMPLATE/bug_template.md b/.github/ISSUE_TEMPLATE/bug_template.md index 4ad3612ac8..63ae5e373a 100644 --- a/.github/ISSUE_TEMPLATE/bug_template.md +++ b/.github/ISSUE_TEMPLATE/bug_template.md @@ -38,6 +38,8 @@ labels: bug + + - NodeJS Version (v14): - Operating System and Version (e.g. Ubuntu 14.04): - Browser Information: diff --git a/.github/workflows/docs-quality-checker.yml b/.github/workflows/docs-quality-checker.yml index 2150c4ee6a..d61cf4cf01 100644 --- a/.github/workflows/docs-quality-checker.yml +++ b/.github/workflows/docs-quality-checker.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: documentation quality check - uses: errata-ai/vale-action@v1.3.0 + uses: errata-ai/vale-action@v1.4.0 # Whitelist excluding ADOPTERS, CHANGELOG and OWNERS (no exclude flag exists) with: files: '[".changeset", ".github", "contrib", "docs", "microsite", "packages", "plugins", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "GOVERNANCE.md", "README.md"]' diff --git a/.gitignore b/.gitignore index 57ad74c5cc..8c37280023 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,6 @@ site # Sensitive credentials *-credentials.yaml + +# e2e tests +cypress/cypress/* diff --git a/ADOPTERS.md b/ADOPTERS.md index 5e46f4b0ea..f05ab1152b 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -52,3 +52,5 @@ | [Wayfair](https://www.wayfair.com) | [@fransan](https://github.com/fransan), [@errskipower](https://github.com/errskipower), [@hrrs](https://github.com/hrrs) | Developer portal for service catalog, technical documentation, and APIs. | | [CircleHD](https://www.circlehd.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | | [CastDesk](https://castdesk.com) | [@circlehddev](https://github.com/circlehddev) | Developer Portal for internal dev team across the globe | +| [Santagostino](https://santagostino.it) | [@santagostino](https://github.com/santagostino) | Developer portal, gateway to our infrastructure, documentation, service catalog and internal tooling. | +| [Peak](https://peak.ai) | [Luke Beamish](https://github.com/lukebeamish-peak) | Developer portal for all internal engineers to access documentation and tooling. | diff --git a/app-config.yaml b/app-config.yaml index 2fb75f718e..c24ac1701b 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -262,7 +262,9 @@ catalog: # Backstage example groups and users - type: file target: ../catalog-model/examples/acme-corp.yaml - + # Backstage end-to-end tests of TechDocs + - type: file + target: ../../cypress/e2e-fixture.catalog.info.yaml scaffolder: # Use to customize default commit author info used when new components are created # defaultAuthor: @@ -358,6 +360,10 @@ auth: clientId: ${AUTH_ONELOGIN_CLIENT_ID} clientSecret: ${AUTH_ONELOGIN_CLIENT_SECRET} issuer: ${AUTH_ONELOGIN_ISSUER} + bitbucket: + development: + clientId: ${AUTH_BITBUCKET_CLIENT_ID} + clientSecret: ${AUTH_BITBUCKET_CLIENT_SECRET} costInsights: engineerCost: 200000 products: @@ -382,6 +388,31 @@ costInsights: default: true MSC: name: Monthly Subscribers + currencies: + engineers: + label: 'Engineers 🛠' + unit: 'engineer' + usd: + label: 'US Dollars 💵' + kind: 'USD' + unit: 'dollar' + prefix: '$' + rate: 1 + carbonOffsetTons: + label: 'Carbon Offset Tons ♻️⚖️s' + kind: 'CARBON_OFFSET_TONS' + unit: 'carbon offset ton' + rate: 3.5 + beers: + label: 'Beers 🍺' + kind: 'BEERS' + unit: 'beer' + rate: 4.5 + pintsIceCream: + label: 'Pints of Ice Cream 🍦' + kind: 'PINTS_OF_ICE_CREAM' + unit: 'ice cream pint' + rate: 5.5 homepage: clocks: - label: UTC @@ -400,3 +431,8 @@ jenkins: baseUrl: https://jenkins.example.com username: backstage-bot apiKey: 123456789abcdef0123456789abcedf012 + +azureDevOps: + host: dev.azure.com + token: my-token + organization: my-company diff --git a/cypress/.eslintrc.js b/cypress/.eslintrc.js index d7498649a0..a77de4a629 100644 --- a/cypress/.eslintrc.js +++ b/cypress/.eslintrc.js @@ -11,6 +11,7 @@ module.exports = { bundledDependencies: false, }, ], + 'jest/valid-expect': 'off', 'jest/expect-expect': 'off', 'no-restricted-syntax': 'off', }, diff --git a/cypress/cypress.json b/cypress/cypress.json index ebbbe59901..3ef3df8e65 100644 --- a/cypress/cypress.json +++ b/cypress/cypress.json @@ -2,7 +2,9 @@ "baseUrl": "http://localhost:7000", "integrationFolder": "./src/integration", "supportFile": "./src/support", - "fixturesFolder": "./src/fixures", + "fixturesFolder": "./src/fixtures", "pluginsFile": "./src/plugins", - "defaultCommandTimeout": 10000 + "defaultCommandTimeout": 10000, + "viewportHeight": 900, + "viewportWidth": 1440 } diff --git a/cypress/e2e-fixture.catalog.info.yaml b/cypress/e2e-fixture.catalog.info.yaml new file mode 100644 index 0000000000..31502e415d --- /dev/null +++ b/cypress/e2e-fixture.catalog.info.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: techdocs-e2e-fixture + description: Used for end-to-end tests of TechDocs in Backstage. + annotations: + backstage.io/techdocs-ref: dir:./fixtures +spec: + type: service + lifecycle: experimental + owner: user:guest diff --git a/cypress/fixtures/docs/index.md b/cypress/fixtures/docs/index.md new file mode 100644 index 0000000000..d7ff14b46d --- /dev/null +++ b/cypress/fixtures/docs/index.md @@ -0,0 +1,3 @@ +# Home page + +This is a basic documentation used for end-to-end tests. diff --git a/cypress/fixtures/docs/sub-page-one.md b/cypress/fixtures/docs/sub-page-one.md new file mode 100644 index 0000000000..7c451efeb5 --- /dev/null +++ b/cypress/fixtures/docs/sub-page-one.md @@ -0,0 +1,109 @@ +# Sub-page 1 + +## Section 1.1 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +## Section 1.2 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +## Section 1.3 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! diff --git a/cypress/fixtures/docs/sub-page-three.md b/cypress/fixtures/docs/sub-page-three.md new file mode 100644 index 0000000000..e9a439ed98 --- /dev/null +++ b/cypress/fixtures/docs/sub-page-three.md @@ -0,0 +1,121 @@ +# Sub-page 3 + +## Section 3.1 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +## Section 3.2 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +### Sub-Section 3.2.1 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +### Sub-Section 3.2.2 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +## Section 3.3 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! diff --git a/cypress/fixtures/docs/sub-page-two.md b/cypress/fixtures/docs/sub-page-two.md new file mode 100644 index 0000000000..eb2344e9c3 --- /dev/null +++ b/cypress/fixtures/docs/sub-page-two.md @@ -0,0 +1,146 @@ +# Sub-page 2 + +## Section 2.1 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +## Section 2.2 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +### Sub-Section 2.2.1 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +### Sub-Section 2.2.2 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +## Section 2.3 + +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! +To next page! + +[Link to Section 1.1](sub-page-one.md#section-11) diff --git a/cypress/fixtures/mkdocs.yml b/cypress/fixtures/mkdocs.yml new file mode 100644 index 0000000000..2362fab898 --- /dev/null +++ b/cypress/fixtures/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: e2e Fixture Documentation +site_description: Documentation used for end-to-end tests of TechDocs in Backstage. +nav: + - Home: index.md + - Sub-page 1: sub-page-one.md + - Sub-page 2: sub-page-two.md + - Nested Sub-pages: + - Sub-page 3: sub-page-three.md +plugins: + - techdocs-core diff --git a/cypress/src/integration/integrations.ts b/cypress/src/integration/integrations.spec.ts similarity index 100% rename from cypress/src/integration/integrations.ts rename to cypress/src/integration/integrations.spec.ts diff --git a/cypress/src/integration/catalog.ts b/cypress/src/integration/plugins/catalog.spec.ts similarity index 100% rename from cypress/src/integration/catalog.ts rename to cypress/src/integration/plugins/catalog.spec.ts diff --git a/cypress/src/integration/plugins/techdocs.spec.ts b/cypress/src/integration/plugins/techdocs.spec.ts new file mode 100644 index 0000000000..0edd7b15ff --- /dev/null +++ b/cypress/src/integration/plugins/techdocs.spec.ts @@ -0,0 +1,188 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// +import 'os'; + +describe('TechDocs', () => { + beforeEach(() => { + cy.loginAsGuest(); + cy.mockSockJSNode(); + cy.interceptTechDocsAPICalls(); + }); + + describe('Navigating to TechDocs', () => { + it('should navigate to the TechDocs home page via the primary navigation bar', () => { + cy.visit('/'); + cy.wait(500); + + cy.get('[data-testid="sidebar-root"]') + .get('div') + .get('a[href="/docs"]') + .click(); + + cy.contains('Documentation'); + }); + + it('should navigate to the TechDocs home page from the URL', () => { + cy.visit('/docs'); + cy.wait(500); + + cy.contains('Documentation'); + }); + + it('should navigate to a specific TechDocs entity from the "Overview" tab', () => { + cy.visit('/docs'); + cy.contains('techdocs-e2e-fixture') + .parents() + .eq(2) + .contains('Read Docs') + .click(); + + cy.location().should(loc => { + expect(loc.pathname).to.eq( + '/docs/default/Component/techdocs-e2e-fixture', + ); + }); + }); + + it('should navigate to a specific TechDocs entity page from a URL', () => { + cy.visit('/docs/default/Component/techdocs-e2e-fixture'); + cy.waitHomePage(); + + cy.contains('e2e Fixture Documentation'); + cy.contains( + 'Documentation used for end-to-end tests of TechDocs in Backstage.', + ); + cy.getTechDocsShadowRoot().contains('Home page'); + }); + + it('should navigate to a specific TechDocs section from a URL', () => { + cy.visit('/docs/default/Component/techdocs-e2e-fixture/sub-page-two'); + cy.waitSectionTwoPage(); + + cy.window().its('scrollY').should('equal', 0); + + cy.getTechDocsShadowRoot().within(() => { + cy.contains('Sub-page 2'); + }); + }); + + it('should navigate to a specific TechDocs fragment from a URL', () => { + cy.visit( + '/docs/default/Component/techdocs-e2e-fixture/sub-page-two#section-23', + ); + cy.waitSectionTwoPage(); + + // This is used to test the post-render behavior of the techdocs Reader + cy.wait(500); + + cy.getTechDocsShadowRoot().within(() => { + cy.isInViewport('#section-23'); + }); + }); + + it('should navigate to a wrong TechDocs entity page from a URL', () => { + cy.visit('/docs/default/Component/wrong-component'); + + cy.get('[data-testid=error]').should('be.visible'); + }); + }); + + describe('Navigating within TechDocs', () => { + it('should navigate to a specific TechDocs page via the navigation bar', () => { + cy.visit('/docs/default/Component/techdocs-e2e-fixture'); + cy.waitHomePage(); + + cy.getTechDocsShadowRoot().within(() => { + cy.getTechDocsNavigation() + .find('> div > div > [data-md-level="0"] > ul > li:nth-child(2) > a') + .click(); + cy.contains('Sub-page 1'); + cy.window().its('scrollY').should('eq', 0); + }); + }); + + describe('Navigating within a TechDocs page', () => { + beforeEach(() => { + cy.visit('/docs/default/Component/techdocs-e2e-fixture/sub-page-two'); + cy.waitSectionTwoPage(); + }); + it('should navigate to a specific fragment within the page via the table of contents - Level 1', () => { + return cy.getTechDocsShadowRoot().within(() => { + // Section 3 + cy.getTechDocsTableOfContents().within(() => { + cy.get('> div > div > nav > ul > li:nth-child(3) > a').click(); + }); + + cy.isInViewport('#section-23'); + }); + }); + + it('should navigate to a specific fragment within the page via the table of contents - Level 2', () => { + return cy.getTechDocsShadowRoot().within(() => { + cy.isNotInViewport('#sub-section-222'); + // Section 2.2 + cy.getTechDocsTableOfContents() + .find( + '> div > div > nav > ul > li:nth-child(2) > nav > ul > li:nth-child(2) > a', + ) + .click(); + + cy.isInViewport('#sub-section-222'); + }); + }); + + it('should navigate to a specific TechDocs page fragment from a link', () => { + return cy.getTechDocsShadowRoot().within(() => { + cy.get('.md-content > article') + .contains('Link to Section 1.1') + .click(); + + cy.location().should(loc => { + expect(loc.pathname).to.eq( + '/docs/default/Component/techdocs-e2e-fixture/sub-page-one/', + ); + expect(loc.hash).to.eq('#section-11'); + }); + }); + }); + + it('should navigate to the next page within a TechDocs entity', () => { + return cy.getTechDocsShadowRoot().within(() => { + cy.get('.md-footer-nav__link--next').click(); + + cy.location().should(loc => { + expect(loc.pathname).to.eq( + '/docs/default/Component/techdocs-e2e-fixture/sub-page-three/', + ); + }); + }); + }); + + it('should navigate to the previous page within a TechDocs entity', () => { + return cy.getTechDocsShadowRoot().within(() => { + cy.get('.md-footer-nav__link--prev').click(); + + cy.location().should(loc => { + expect(loc.pathname).to.eq( + '/docs/default/Component/techdocs-e2e-fixture/sub-page-one/', + ); + }); + }); + }); + }); + }); +}); diff --git a/cypress/src/integration/user/login.spec.ts b/cypress/src/integration/user/login.spec.ts new file mode 100644 index 0000000000..25be6e8d4b --- /dev/null +++ b/cypress/src/integration/user/login.spec.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// +import 'os'; + +describe('Login', () => { + it('should render the login page', () => { + cy.visit('/'); + cy.contains('Select a sign-in method'); + }); + + it('should be able to login', () => { + cy.get('button').contains('Enter').click(); + cy.url().should('include', '/catalog'); + + cy.contains('artist-lookup'); + }); +}); diff --git a/cypress/src/integration/user/logout.spec.ts b/cypress/src/integration/user/logout.spec.ts new file mode 100644 index 0000000000..2d37cb63e5 --- /dev/null +++ b/cypress/src/integration/user/logout.spec.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/// +import 'os'; + +describe('Logout', () => { + before(() => { + cy.loginAsGuest(); + }); + it('should be able to logout', () => { + cy.visit('/settings'); + cy.get('[data-testid="user-settings-menu"]').click(); + return cy + .get('[data-testid="sign-out"]') + .click() + .then(() => { + return expect( + localStorage.getItem('@backstage/core:SignInPage:provider'), + ).to.be.null; + }); + }); +}); diff --git a/cypress/src/support/commands.ts b/cypress/src/support/commands.ts new file mode 100644 index 0000000000..3c8456fcbd --- /dev/null +++ b/cypress/src/support/commands.ts @@ -0,0 +1,116 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable jest/no-standalone-expect */ +/// +import 'os'; + +Cypress.Commands.add('loginAsGuest', () => { + cy.visit('/', { + onLoad: (win: Window) => + win.localStorage.setItem('@backstage/core:SignInPage:provider', 'guest'), + }); +}); + +Cypress.Commands.add('getTechDocsShadowRoot', () => { + cy.get('[data-testid="techdocs-content-shadowroot"]').shadow(); +}); + +Cypress.Commands.add('isNotInViewport', element => { + cy.get(element).then($el => { + const bottom = Cypress.config(`viewportHeight`); + const rect = $el[0].getBoundingClientRect(); + + if (bottom) { + expect(rect.top).to.be.greaterThan(bottom); + expect(rect.bottom).to.be.greaterThan(bottom); + expect(rect.top).to.be.greaterThan(bottom); + expect(rect.bottom).to.be.greaterThan(bottom); + } + }); +}); + +Cypress.Commands.add('isInViewport', element => { + cy.get(element).then($el => { + const bottom = Cypress.config(`viewportHeight`); + const rect = $el[0].getBoundingClientRect(); + + if (bottom) { + expect(rect.top).not.to.be.greaterThan(bottom); + expect(rect.bottom).not.to.be.greaterThan(bottom); + expect(rect.top).not.to.be.greaterThan(bottom); + expect(rect.bottom).not.to.be.greaterThan(bottom); + } + }); +}); + +Cypress.Commands.add('getTechDocsTableOfContents', () => { + cy.get('[data-md-component="toc"]'); +}); + +Cypress.Commands.add('getTechDocsNavigation', () => { + cy.get('[data-md-component="navigation"]'); +}); + +Cypress.Commands.add('mockSockJSNode', () => { + cy.intercept('GET', '**/sockjs-node/info**', { + body: { + websocket: true, + origins: ['*:*'], + cookie_needed: false, + entropy: 2882389500, + }, + }); +}); + +Cypress.Commands.add('interceptTechDocsAPICalls', () => { + cy.intercept( + 'GET', + '**/techdocs/metadata/entity/default/Component/techdocs-e2e-fixture', + ).as('entityMetadata'); + + cy.intercept( + 'GET', + '**/techdocs/metadata/techdocs/default/Component/techdocs-e2e-fixture', + ).as('techdocsMetadata'); + + cy.intercept( + 'GET', + '**/techdocs/sync/default/Component/techdocs-e2e-fixture', + ).as('syncEntity'); + + cy.intercept( + 'GET', + '**/techdocs/static/docs/default/Component/techdocs-e2e-fixture/sub-page-two/index.html', + ).as('sectionTwoHTML'); + + cy.intercept( + 'GET', + '**/techdocs/static/docs/default/Component/techdocs-e2e-fixture/index.html', + ).as('homeHTML'); +}); + +Cypress.Commands.add('waitSectionTwoPage', () => { + cy.wait([ + '@entityMetadata', + '@syncEntity', + '@techdocsMetadata', + '@sectionTwoHTML', + ]); +}); + +Cypress.Commands.add('waitHomePage', () => { + cy.wait(['@entityMetadata', '@syncEntity', '@techdocsMetadata', '@homeHTML']); +}); diff --git a/cypress/src/support/index.ts b/cypress/src/support/index.ts index ebfcb04d03..6d1051a793 100644 --- a/cypress/src/support/index.ts +++ b/cypress/src/support/index.ts @@ -14,12 +14,4 @@ * limitations under the License. */ /// - -Cypress.Commands.add('loginAsGuest', () => { - cy.visit('/', { - onLoad: (win: Window) => - win.localStorage.setItem('@backstage/core:SignInPage:provider', 'guest'), - }); -}); - -export {}; +import './commands'; diff --git a/cypress/src/types.d.ts b/cypress/src/types.d.ts index fff4fe29e4..838f5571ed 100644 --- a/cypress/src/types.d.ts +++ b/cypress/src/types.d.ts @@ -22,5 +22,55 @@ declare namespace Cypress { * @example cy.loginAsGuests */ loginAsGuest(): Chainable; + /** + * Get the TechDocs shadow root element + * @example cy.getTechDocsShadowRoot + */ + getTechDocsShadowRoot(): Chainable; + /** + * Mock TechDocs backend API + * @example cy.mockTechDocs + */ + mockTechDocs(): void; + /** + * Get the TechDocs table of contents element + * @example cy.getTechDocsShadowRoot + */ + getTechDocsTableOfContents(): Chainable; + /** + * Get the TechDocs navigation element + * @example cy.getTechDocsNavigation + */ + getTechDocsNavigation(): Chainable; + /** + * Intercept the TechDocs API calls + * @example cy.interceptTechDocsAPICalls + */ + interceptTechDocsAPICalls(): Chainable; + /** + * Mock SockJS-Node call + * @example cy.mockSockJSNode + */ + mockSockJSNode(): Chainable; + /** + * Wait TechDocs API response for home page + * @example cy.waitHomePage + */ + waitHomePage(): Chainable; + /** + * Wait TechDocs API response for Section 2 page + * @example cy.waitSectionTwoPage + */ + waitSectionTwoPage(): Chainable; + /** + * Check if the element is in viewport + * @example cy.isInViewport + */ + isInViewport(element: string): Chainable; + /** + * Check if the element is not in viewport + * @example cy.isNotInViewport + */ + isNotInViewport(element: string): Chainable; } } diff --git a/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md new file mode 100644 index 0000000000..ae09d5dbee --- /dev/null +++ b/docs/auth/bitbucket/provider.md @@ -0,0 +1,52 @@ +--- +id: provider +title: Bitbucket Authentication Provider +sidebar_label: Bitbucket +description: Adding Bitbucket OAuth as an authentication provider in Backstage +--- + +The Backstage `core-plugin-api` package comes with a Bitbucket authentication +provider that can authenticate users using Bitbucket Cloud. This does **NOT** +work with Bitbucket Server. + +## Create an OAuth Consumer in Bitbucket + +To add Bitbucket Cloud authentication, you must create an OAuth Consumer. + +Go to `https://bitbucket.org//workspace/settings/api` . + +Click Add Consumer. + +Settings for local development: + +- Application name: Backstage (or your custom app name) +- Callback URL: `http://localhost:7000/api/auth/bitbucket` +- Other are optional +- (IMPORTANT) **Permissions: Account - Read, Workspace membership - Read** + +## Configuration + +The provider configuration can then be added to your `app-config.yaml` under the +root `auth` configuration: + +```yaml +auth: + environment: development + providers: + bitbucket: + development: + clientId: ${AUTH_BITBUCKET_CLIENT_ID} + clientSecret: ${AUTH_BITBUCKET_CLIENT_SECRET} +``` + +The Bitbucket provider is a structure with two configuration keys: + +- `clientId`: The Key that you generated in Bitbucket, e.g. + `b59241722e3c3b4816e2` +- `clientSecret`: The Secret tied to the generated Key. + +## Adding the provider to the Backstage frontend + +To add the provider to the frontend, add the `bitbucketAuthApi` reference and +`SignInPage` component as shown in +[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/auth/github/provider.md b/docs/auth/github/provider.md index 05726e8a4e..d8803e392d 100644 --- a/docs/auth/github/provider.md +++ b/docs/auth/github/provider.md @@ -50,6 +50,10 @@ The GitHub provider is a structure with three configuration keys: - `clientSecret`: The client secret tied to the generated client ID. - `enterpriseInstanceUrl` (optional): The base URL for a GitHub Enterprise instance, e.g. `https://ghe..com`. Only needed for GitHub Enterprise. +- `callbackUrl` (optional): The callback url that GitHub will use when + initiating an OAuth flow, e.g. + `https://your-intermediate-service.com/handler`. Only needed if Backstage is + not the immediate receiver (e.g. one OAuth app for many backstage instances). ## Adding the provider to the Backstage frontend diff --git a/docs/cli/commands.md b/docs/cli/commands.md index ff61e51c75..5727833562 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -57,6 +57,7 @@ postpack Restores the changes made by the prepack command create-github-app Create new GitHub App in your organization (experimental) +info Show helpful information for debugging and reporting bugs help [command] display help for command ``` @@ -423,7 +424,10 @@ This command uses a default Jest configuration that is included in the CLI, which is set up with similar goals for speed, scale, and working within a monorepo. The configuration sets the `src` as the root directory, enforces the `.test.` infix for tests, and uses `src/setupTests.ts` as the test setup -location. +location. The included configuration also supports test execution at the root of +a yarn workspaces monorepo by automatically creating one grouped configuration +that includes all packages that have `backstage-cli test` in their package +`test` script. If needed, the configuration can be extended using a `"jest"` field in `package.json`, both within the target package and the monorepo root, with @@ -647,3 +651,15 @@ YAML file that can be referenced in the GitHub integration configuration. ```text Usage: backstage-cli create-github-app <github-org> ``` + +## info + +Scope: `root` + +Outputs debug information which is useful when opening an issue. Outputs system +information, node.js and npm versions, CLI version and type (inside backstage +repo or a created app), all `@backstage/*` package dependency versions. + +```text +Usage: backstage-cli info +``` diff --git a/docs/conf/defining.md b/docs/conf/defining.md index f13beb9f77..6462f4f60a 100644 --- a/docs/conf/defining.md +++ b/docs/conf/defining.md @@ -13,9 +13,10 @@ which during validation is stitched together into a single schema. ## Schema Collection and Definition Schemas are collected from all packages and dependencies in each repo that are a -part of the Backstage ecosystem, including transitive dependencies. The current -definition of "part of the ecosystem" is that a package has at least one -dependency in the `@backstage` namespace, but this is subject to change. +part of the Backstage ecosystem, including the root package and transitive +dependencies. The current definition of "part of the ecosystem" is that a +package has at least one dependency in the `@backstage` namespace or a +`"configSchema"` field in `package.json`, but this is subject to change. Each package is searched for a schema at a single point of entry, a top-level `"configSchema"` field in `package.json`. The field can either contain an diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md index 247324a46c..3fec28f176 100644 --- a/docs/features/software-catalog/external-integrations.md +++ b/docs/features/software-catalog/external-integrations.md @@ -90,8 +90,7 @@ does so! ## Creating a Catalog Data Reader Processor The recommended way of instantiating the catalog backend classes is to use the -[`CatalogBuilder`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/service/CatalogBuilder.ts), -as illustrated in the +`CatalogBuilder`, as illustrated in the [example backend here](https://github.com/backstage/backstage/blob/master/packages/backend/src/plugins/catalog.ts). We will create a new [`CatalogProcessor`](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend/src/ingestion/processors/types.ts) @@ -171,3 +170,105 @@ export default async function createPlugin( Start up the backend - it should now start reading from the previously registered location and you'll see your entities start to appear in Backstage. + +## Caching processing results + +The catalog periodically refreshes entities in the catalog, and in doing so it +calls out to external systems to fetch changes. This can be taxing for upstream +services and large deployments may get rate limited if too many requests are +sent. Luckily many external systems provide ETag support to check for changes +which usually doesn't count towards the quota and saves resources both +internally and externally. + +The catalog has built in support for leveraging ETags when refreshing external +locations in GitHub. This example aims to demonstrate how to add the same +behavior for `system-x` that we implemented earlier. + +```ts +import { UrlReader } from '@backstage/backend-common'; +import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { + results, + CatalogProcessor, + CatalogProcessorEmit, + CatalogProcessorCache, + CatalogProcessorParser, +} from '@backstage/plugin-catalog-backend'; + +// It's recommended to always bump the CACHE_KEY version if you make +// changes to the processor implementation or CacheItem. +const CACHE_KEY = 'v1'; + +// Our cache item contains the ETag used in the upstream request +// as well as the processing result used when the Etag matches. +// Bump the CACHE_KEY version if you make any changes to this type. +type CacheItem = { + etag: string; + entity: Entity; +}; + +export class SystemXReaderProcessor implements CatalogProcessor { + constructor(private readonly reader: UrlReader) {} + + // It's recommended to give the processor a unique name. + getProcessorName() { + return 'system-x-processor'; + } + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: CatalogProcessorEmit, + _parser: CatalogProcessorParser, + cache: CatalogProcessorCache, + ): Promise { + // Pick a custom location type string. A location will be + // registered later with this type. + if (location.type !== 'system-x') { + return false; + } + const cacheItem = await cache.get(CACHE_KEY); + try { + // This assumes an URL reader that returns the response together with the ETag. + // We send the ETag from the previous run if it exists. + // The previous ETag will be set in the headers for the outgoing request and system-x + // is going to throw NOT_MODIFIED (HTTP 304) if the ETag matches. + const response = await this.reader.readUrl?.(location.target, { + etag: cacheItem?.etag, + }); + if (!response) { + // readUrl is currently optional to implement so we have to check if we get a response back. + throw new Error( + 'No URL reader that can parse system-x targets installed', + ); + } + + // ETag is optional in the response but we need it to cache the result. + if (!response.etag) { + throw new Error( + 'No ETag returned from system-x, cannot use response for caching', + ); + } + + // For this example the JSON payload is a single entity. + const entity: Entity = JSON.parse(response.buffer.toString()); + emit(results.entity(location, entity)); + + // Update the cache with the new ETag and entity used for the next run. + await cache.set(CACHE_KEY, { + etag: response.etag, + entity, + }); + } catch (error) { + if (error.name === 'NotModifiedError' && cacheItem) { + // The ETag matches and we have a cached value from the previous run. + emit(results.entity(location, cacheItem.entity)); + } + const message = `Unable to read ${location.type}, ${error}`; + emit(results.generalError(location, message)); + } + + return true; + } +} +``` diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index a5773a6aff..581a974455 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -6,7 +6,7 @@ description: How to write your own actions If you're wanting to extend the functionality of the Scaffolder, you can do so by writing custom actions which can be used along side our -[built-in actions](./builtin-actions.md) +[built-in actions](./builtin-actions.md). ### Writing your Custom Action @@ -60,7 +60,7 @@ close over the `TemplateAction`. Take a look at our [built-in actions](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/actions/builtin) for reference. -We set the type generic to `{ contents: string, filename: string}` which is +We set the type generic to `{ contents: string, filename: string }` which is there to set the type on the handler `ctx` `inputs` property so we get good type checking. This could be generated from the next part of this guide, the `input` schema, but it's not supported right now. Feel free to contribute 🚀 👍. @@ -68,12 +68,12 @@ schema, but it's not supported right now. Feel free to contribute 🚀 👍. The `createTemplateAction` takes an object which specifies the following: - `id` - a unique ID for your custom action. We encourage you to namespace these - in someway so they wont collide with future built-in actions that we may ship - with the `scaffolder-backend` plugin. + in some way so that they won't collide with future built-in actions that we + may ship with the `scaffolder-backend` plugin. - `schema.input` - A JSON schema for input values to your function - `schema.output` - A JSON schema for values which are outputted from the function using `ctx.output` -- `handler` the actual code which is run part of the action, with a context. +- `handler` - the actual code which is run part of the action, with a context #### The context object @@ -81,7 +81,7 @@ When the action `handler` is called, we provide you a `context` as the only argument. It looks like the following: - `ctx.baseUrl` - a string where the template is located -- `ctx.logger` - a winston logger for additional logging inside your action +- `ctx.logger` - a Winston logger for additional logging inside your action - `ctx.logStream` - a stream version of the logger if needed - `ctx.workspacePath` - a string of the working directory of the template run - `ctx.input` - an object which should match the JSON schema provided in the diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 02bb698f64..f95177e029 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -97,7 +97,7 @@ spec: entityRef: '{{ steps.register.output.entityRef }}' ``` -Let's dive in an pick apart what each of these sections do and what they are. +Let's dive in and pick apart what each of these sections do and what they are. ### `spec.parameters` - `FormStep | FormStep[]` @@ -256,11 +256,11 @@ use `ui:widget: password` or set some properties of `ui:backstage`: #### The Repository Picker -So in order to make working with repository providers easier, we've built a -custom picker that can be used by overriding the `ui:field` option in the -`uiSchema` for a `string` field. Instead of displaying a text input block it -will render our custom component that we've built which makes it easy to select -a repository provider, and insert a project or owner, and repository name. +In order to make working with repository providers easier, we've built a custom +picker that can be used by overriding the `ui:field` option in the `uiSchema` +for a `string` field. Instead of displaying a text input block it will render +our custom component that we've built which makes it easy to select a repository +provider, and insert a project or owner, and repository name. You can see it in the above full example which is a separate step and it looks a little like this: @@ -323,9 +323,9 @@ template. These follow the same standard format: name: '{{ parameters.name }}' ``` -By default we ship some built in actions that you can take a look at -[here](./builtin-actions.md), or you can create your own custom actions by -looking at the docs [here](./writing-custom-actions.md) +By default we ship some [built in actions](./builtin-actions.md) that you can +take a look at, or you can +[create your own custom actions](./writing-custom-actions.md). ### Outputs @@ -359,5 +359,5 @@ As you can see above in the `Outputs` section, `actions` and `steps` can also output things. You can grab that output using `steps.$stepId.output.$property`. You can read more about all the `inputs` and `outputs` defined in the actions in -code part of the `JSONSchema`, or you can read more about our built in ones -[here](./builtin-actions.md). +code part of the `JSONSchema`, or you can read more about our +[built in actions](./builtin-actions.md). diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index db093caa39..9c41c6dde2 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -142,6 +142,84 @@ const AppRoutes = () => { }; ``` +## How to customize the TechDocs reader page? + +Similar to how it is possible to customize the TechDocs Home, it is also +possible to customize the TechDocs Reader Page. It is done in your `app` +package. By default, you might see something like this in your `App.tsx`: + +```tsx +const AppRoutes = () => { + }> + {techDocsPage} + ; +}; +``` + +The `techDocsPage` is a default techdocs reader page which lives in +`packages/app/src/components/techdocs`. It includes the following without you +having to set anything up. + +```tsx + + {({ techdocsMetadataValue, entityMetadataValue, entityRef, onReady }) => ( + <> + + + + + + )} + +``` + +If you would like to compose your own `techDocsPage`, you can do so by replacing +the children of TechDocsPage with something else. Maybe you are _just_ +interested in replacing the Header: + +```tsx + + {({ entityRef, onReady }) => ( + <> +
+ + + + + )} + +``` + +Or maybe you want to disable the in-context search + +```tsx + + {({ entityRef, onReady }) => ( + <> +
+ + + + + )} + +``` + +Or maybe you want to replace the entire TechDocs Page. + +```tsx + +
+ +

my own content

+
+ +``` + ## How to migrate from TechDocs Alpha to Beta > This guide only applies to the "recommended" TechDocs deployment method (where diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index ecd9e4ac21..241ae7ef03 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -37,9 +37,7 @@ exported by [@backstage/theme](https://www.npmjs.com/package/@backstage/theme) in combination with [createTheme](https://material-ui.com/customization/theming/#createmuitheme-options-args-theme) from [@material-ui/core](https://www.npmjs.com/package/@material-ui/core). See -the -[@backstage/theme source](https://github.com/backstage/backstage/tree/master/packages/theme/src) -and the implementation of the `createTheme` function for how this is done. +the "Overriding Backstage and Material UI css rules" section below. You can also create a theme from scratch that matches the `BackstageTheme` type exported by [@backstage/theme](https://www.npmjs.com/package/@backstage/theme). @@ -136,6 +134,79 @@ const themeOptions = createThemeOptions({ }); ``` +## Overriding Backstage and Material UI components styles + +When creating a custom theme you would be applying different values to +component's css rules that use the theme object. For example, a Backstage +component's styles might look like this: + +```ts +const useStyles = makeStyles( + theme => ({ + header: { + padding: theme.spacing(3), + boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', + backgroundImage: theme.page.backgroundImage, + }, + }), + { name: 'BackstageHeader' }, +); +``` + +Notice how the `padding` is getting its value from `theme.spacing`, that means +that setting a value for spacing in your custom theme would affect this +component padding property and the same goes for `backgroundImage` which uses +`theme.page.backgroundImage`. However, the `boxShadow` property doesn't +reference any value from the theme, that means that creating a custom theme +wouldn't be enough to alter the `box-shadow` property or to add css rules that +aren't already defined like a margin. For these cases you should also create an +override. + +```ts +import { createApp } from '@backstage/core-app-api'; +import { BackstageTheme, lightTheme } from '@backstage/theme'; +/** + * The `@backstage/core-components` package exposes this type that + * contains all Backstage and `material-ui` components that can be + * overridden along with the classes key those components use. + */ +import { BackstageOverrides } from '@backstage/core-components'; + +export const createCustomThemeOverrides = ( + theme: BackstageTheme, +): BackstageOverrides => { + return { + BackstageHeader: { + header: { + width: 'auto', + margin: '20px', + boxShadow: 'none', + borderBottom: `4px solid ${theme.palette.primary.main}`, + }, + }, + }; +}; + +const app = createApp({ + apis: ..., + plugins: ..., + themes: [{ + id: 'my-theme', + title: 'My Custom Theme', + variant: 'light', + theme: { + ...lightTheme, + overrides: { + // These are the overrides that Backstage applies to `material-ui` components + ...lightTheme.overrides, + // These are your custom overrides, either to `material-ui` or Backstage components. + ...createCustomThemeOverrides(lightTheme), + }, + }, + }] +}); +``` + ## Custom Logo In addition to a custom theme, you can also customize the logo displayed at the diff --git a/docs/overview/stability-index.md b/docs/overview/stability-index.md index 8e710d4e7c..a79bcd6e11 100644 --- a/docs/overview/stability-index.md +++ b/docs/overview/stability-index.md @@ -27,7 +27,7 @@ point building on top of the previous one: and the new APIs can be used in parallel. This deprecation must have been released for at least two weeks before the deprecated API is removed in a minor version bump. -- **3** - The time limit for the deprecation is 3 months instead of two weeks. +- **3** - The time limit for the deprecation is 3 months instead of two days. TL;DR: @@ -279,7 +279,7 @@ Integrates GraphiQL as a tool to browse GraphQL API endpoints inside Backstage. Stability: `1` -### `graphql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/graphql/) +### `graphql` [GitHub](https://github.com/backstage/backstage/tree/master/plugins/graphql-backend/) A backend plugin that provides diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md index 0fe2db9098..8d22c05a79 100644 --- a/docs/plugins/composability.md +++ b/docs/plugins/composability.md @@ -1,42 +1,39 @@ --- id: composability -title: Composability System Migration +title: Composability System # prettier-ignore -description: Documentation and migration instructions for new composability APIs. +description: Documentation for the Backstage plugin composability APIs. --- ## Summary -This page describes the new composability system that was recently introduced in -Backstage, and it does so from the perspective of the existing patterns and -APIs. As the new system is solidified and existing code is ported, this page -will be removed and replaced with a more direct description of the composability -system. For now, the primary purpose of this documentation is to aid in the -migration of existing plugins, but it does cover the migration of apps as well. +This page describes the composability system that helps bring together content +from a multitude of plugins into one Backstage application. -The core principle of the new composability system is that plugins should have -clear boundaries and connections. It should isolate crashes within a plugin, but -allow navigation between them. It should allow for plugins to be loaded only -when needed, and enable plugins to provide extension points for other plugins to +The core principle of the composability system is that plugins should have clear +boundaries and connections. It should isolate crashes within a plugin, but allow +navigation between them. It should allow for plugins to be loaded only when +needed, and enable plugins to provide extension points for other plugins to build upon. The composability system is also built with an app-first mindset, prioritizing simplicity and clarity in the app over that in the plugins and core APIs. -The new composability system isn't a single new API surface. It is a collection -of patterns, primitives, new APIs, and old APIs used in new ways. At the core is -the new concept of extensions, which are exported by plugins for use in the app. -There is also a new primitive called component data, which assists in the -conversion to a more declarative app. The `RouteRef`s now have a clear purpose -as well, and can be used route to pages in a flexible way. +The composability system isn't a single API surface. It is a collection of +patterns, primitives, and APIs. At the core is the concept of **extensions**, +which are exported by plugins for use in the app. There is also a primitive +called component data, which helps keep the structure of the app more +declarative. There are also `RouteRef`s that help route between pages in a +flexible way, which is especially important when bringing together different +open source plugins. -## New Concepts +## Concepts -This section is a brief look into all the new and updated concepts that were put -in place to support the new composability system. +This section is a brief look into all the concepts that help support the +composability system. ### Component Data -Component data is a new composability primitive that is introduced as a way to +Component data is a composability primitive that is introduced as a way to provide a new data dimension for React components. Data is attached to React components using a key, and is then readable from any JSX elements created with those components, using the same key, as illustrated by the following example: @@ -59,7 +56,7 @@ inspected, while our component data adds more structured access and simplifies evolution by allowing for multiple different versions of a piece of data to be used and interpreted at once. -The initial use-case for component data is to support route and plugin discovery +One of the use-cases for component data is to support route and plugin discovery through elements in the app. Through this we allow for the React element tree in the app to be the source of truth, both for which plugins are used, as well as all top-level plugin routes in the app. The use of component data is not limited @@ -101,9 +98,10 @@ supply a `RouteRef` as `mountPoint`. The mount point will be the handle of the component for the outside world, and is used by other components and plugins that wish to link to the routable component. -As of now there are only two extension creation functions, but it is possible to -add more of them in the future, both in the core library and in plugins that -wish to provide an extension point for other plugins to build upon. Extensions +As of now there are only two extension creation functions in the core library, +but more may be added in the future. There are also some plugins that provide +ways to extend functionality through their own extensions, like +`createScaffolderFieldExtension` from `@backstage/plugin-scaffolder`. Extensions are also not tied to React, and can both be used to model generic JavaScript concepts, as well as potentially bridge to rendering libraries and web frameworks other than React. @@ -192,14 +190,30 @@ const App = () => ( ); ``` -### New Routing System +### Naming Patterns -A big piece of what is enabled by moving over to this new composability system -is to make `RouteRef`s useful. The `RouteRef`s no longer have their own path, in -fact the only required parameter is currently a `title`. Instead of assigning a -path to each `RouteRef` and possibly overriding these paths in the app, the -concrete `path` for each `RouteRef` is discovered based on the element tree in -the app. Let's consider the following example: +There are a couple of naming patters to adhere to as you build plugins, which +helps clarify the intent and usage of the exports. + +| Description | Pattern | Examples | +| --------------------- | --------------- | ---------------------------------------------- | +| Top-level Pages | \*Page | CatalogIndexPage, SettingsPage, LighthousePage | +| Entity Tab Content | Entity\*Content | EntityJenkinsContent, EntityKubernetesContent | +| Entity Overview Card | Entity\*Card | EntitySentryCard, EntityPagerDutyCard | +| Entity Conditional | is\*Available | isPagerDutyAvailable, isJenkinsAvailable | +| Plugin Instance | \*Plugin | jenkinsPlugin, catalogPlugin | +| Utility API Reference | \*ApiRef | configApiRef, catalogApiRef | + +### Routing System + +The routing system of Backstage relies heavily on the composability system. It +uses `RouteRef`s to represent routing targets in the app, which at runtime will +be bound to a concrete `path`, but provides a level of indirection to help mix +together different plugins that otherwise wouldn't know how to route to each +other. + +The concrete `path` for each `RouteRef` is discovered based on the element tree +in the app. Let's consider the following example: ```tsx const appRoutes = ( @@ -216,12 +230,10 @@ extension it has a `RouteRef` assigned as its mount point, which we'll refer to as `fooPageRouteRef`. Given the above example, the `fooPageRouteRef` will be associated with the -`'/foo'` route. The path is no longer accessible via the `path` property of the -`RouteRef` though, as the routing structure is tied to the app's react tree. We -instead use the new `useRouteRef` hook if we want to create a concrete link to -the page. The `useRouteRef` hook takes a single `RouteRef` as its only -parameter, and returns a function that is called to create the URL. For example -like this: +`'/foo'` route. If we want to route to the `FooPage`, we can use the +`useRouteRef` hook to create a concrete link to the page. The `useRouteRef` hook +takes a single `RouteRef` as its only parameter, and returns a function that is +called to create the URL. For example like this: ```tsx const MyComponent = () => { @@ -230,16 +242,15 @@ const MyComponent = () => { }; ``` -Now let's assume that we want to link from the `BarPage` to the `FooPage`. -Before the introduction of the new composability system, we would do this by -importing the `fooPageRouteRef` exported by the `fooPlugin`. This created an -unnecessary dependency on the plugin, and also provided little flexibility in -allowing the app to tie plugins together, with the links instead being dictated -by the plugins themselves. To solve this, we introduce `ExternalRouteRef`s. Much -like regular route references, they can be passed to `useRouteRef` to create -concrete URLs, but they can not be used as mount points in routable component -and instead have to be associated with a target route using route bindings in -the app. +Now let's assume that we want to link from the `BarPage` to the `FooPage`. We +don't want to reference the `fooPageRouteRef` directly from our `barPlugin`, +since that would create an unnecessary dependency on the `fooPlugin`. It would +also provided little flexibility in allowing the app to tie plugins together, +with the links instead being dictated by the plugins themselves. To solve this, +we use `ExternalRouteRef`s. Much like regular route references, they can be +passed to `useRouteRef` to create concrete URLs, but they can not be used as +mount points in routable component and instead have to be associated with a +target route using route bindings in the app. We create a new `ExternalRouteRef` inside the `barPlugin`, using a neutral name that describes its role in the plugin rather than a specific plugin page that it @@ -305,6 +316,14 @@ in a different file than the one that creates the plugin instance, for example a top-level `routes.ts`. This is to avoid circular imports when you use the route references from other parts of the same plugin. +Another thing to note is that this indirection in the routing is particularly +useful for open source plugins that need to leave flexibility in how they are +integrated. For plugins that you build internally for your own Backstage +application, you can choose to go the route of direct imports or even use +concrete routes directly. Although there can be some benefits to using the full +routing system even in internal plugins. It can help you structure your routes, +and as you will see further down it also helps you manage route parameters. + ### Optional External Routes When creating an `ExternalRouteRef` it is possible to mark it as optional: @@ -338,7 +357,7 @@ const MyComponent = () => { ### Parameterized Routes -A new addition to `RouteRef`s is the possibility of adding named and typed +A feature of `RouteRef`s is the possibility of adding named and typed parameters. Parameters are declared at creation, and will enforce presence of the parameters in the path in the app, and require them as a parameter when using `useRouteRef`. @@ -415,21 +434,12 @@ const MyPage = () => ( ); ``` -### New Catalog Components +### Catalog Components -The established pattern for selecting what plugins should be available on each -catalog page is to use custom components in the app, with logic embedded in the -render function. Typically this takes form as a component that either receives -the entity via props or uses the `useEntity` hook to retrieve the selected -entity. A `switch` or `if` / `else if` chain is then used to select what -children should be rendered based on information in the entity. - -This pattern will no longer work with the new composability system, and in -general is very difficult to build any form of declarative model around, as it -depends on runtime execution. To help replace existing code, a new -`EntitySwitch` component has been added to the `@backstage/catalog` plugin, -which grabs the selected entity from a context, and selects at most one element -to render using a list of `EntitySwitch.Case` children. +To help structure the catalog entity pages in your app and choose what content +to render in different scenarios, the `@backstage/catalog` plugin provides an +`EntitySwitch` component. It works by selecting at most one element to render +using a list of `EntitySwitch.Case` children. For example, if you want all entities of kind `"Template"` to be rendered with a `MyTemplate` component, and all other entities to be rendered with a `MyOther` @@ -475,6 +485,9 @@ new `EntityLayout` component. It is a tweaked version and replacement for the `EntityPageLayout` component, and is introduced more in depth in the app migration section below. +**NOTE**: The rest of this documentation covers how to migrate older +applications to the new composability system described above. + ## Porting Existing Plugins There are a couple of high-level steps to porting an existing plugin to the new diff --git a/docs/tutorials/using-backstage-proxy-within-plugin.md b/docs/tutorials/using-backstage-proxy-within-plugin.md new file mode 100644 index 0000000000..b0c6bef8ef --- /dev/null +++ b/docs/tutorials/using-backstage-proxy-within-plugin.md @@ -0,0 +1,208 @@ +--- +id: using-backstage-proxy-within-plugin +title: Using the Backstage Proxy from Within a Plugin +# prettier-ignore +description: Guide on how to create a set of API bindings that interface with a backend via the backstage proxy +--- + +This guide walks you through setting up a simple proxy to an existing API that +is deployed externally to backstage and sending requests to that API from within +a backstage frontend plugin. + +If your plugin requires access to an API, backstage offers +[3 options](../plugins/call-existing-api.md): + +1. you can + [access the API directly](../plugins/call-existing-api.md#issuing-requests-directly), +1. you can create a [backend plugin](../plugins/backend-plugin.md) if you are + implementing the API alongside your frontend plugin +1. you can configure backstage to proxy to an already existing API. + +**Table of Contents** + +- [Setting up the backstage proxy](#setting-up-the-backstage-proxy) +- [Calling an API using the backstage proxy](#calling-an-api-using-the-backstage-proxy) + - [Defining the API client interface](#defining-the-api-client-interface) + - [Creating the API client](#creating-the-api-client) + - [Bundling your ApiRef with your plugin](#bundling-your-apiref-with-your-plugin) + - [Using the API in your components](#using-your-plugin-in-your-components) + +# Setting up the backstage proxy + +Let's say your plugin's API is hosted at _https://api.myawesomeservice.com/v1_, +and you want to be able to access it within backstage at +`/api/proxy/`, and add a default header called +`X-Custom-Source`. You will need to add the following to `app-config.yaml`: + +```yaml +proxy: + '/': + target: https://api.myawesomeservice.com/v1 + headers: + X-Custom-Source: backstage +``` + +You can find more details about the proxy config options in the +[proxying section](../plugins/proxying.md). + +# Calling an API using the backstage proxy + +If you followed the previous steps, you should now be able to access your API by +calling `${backend-url}/api/proxy/`. The reason why +`backend-url` is referenced is because the backstage backend creates and runs +the proxy. Backstage is structured in such a way that you could run the +backstage frontend independently of the backend. So when calling your API you +need to prepend the backend url to your http call. + +The recommended pattern for calling out to services is to wrap your calls in a +[Utility API](../api/utility-apis.md). This section describes the steps to wrap +your API client in a Utility API, which are: + +- use [`createApiRef`](../reference/core-plugin-api.createapiref.md) to create a + new [`ApiRef`](../reference/core-plugin-api.apiref.md) +- register an [`ApiFactory`](../reference/core-plugin-api.apifactory.md) with + your plugin using + [`createApiFactory`](../reference/core-plugin-api.createapifactory.md). This + will wrap your API implementation, associate your `ApiRef` with your + implementation and tell backstage how to instantiate it +- finally, you can use your API in your components by calling + [`useApi`](../reference/core-plugin-api.useapi.md) + +## Defining the API client interface + +Continuing from the previous example, let's assume that +_https://api.myawesomeservice.com/v1_ has the following endpoints: + +| Method | Description | +| :----------------------- | :---------------------- | +| `GET /users` | Returns a list of users | +| `GET /users/{userId}` | Returns a single user | +| `DELETE /users/{userId}` | Deletes a user | + +Here is an example definition for this API following backstage's `apiRef` style: + +```ts +/* src/api.ts */ +import { createApiRef } from '@backstage/core-plugin-api'; + +export interface User { + name: string; + email: string; +} + +export interface MyAwesomeApi { + url: string; + listUsers: () => Promise>; + getUser: (userId: string) => Promise; + deleteUser: (userId: string) => Promise; +} + +export const myAwesomeApiRef = createApiRef({ + id: 'plugin.my-awesome-api.service', + description: 'Example API definition', +}); +``` + +## Creating the API client + +The `myAwesomeApiRef` is what you will use within backstage to reference the API +client in your plugin. The API ref itself is a global singleton object that +allows you to reference your instantiated API. The actual implementation would +look something like this: + +```ts +/* src/api.ts */ + +/* ... */ + +import { DiscoveryApi } from '@backstage/core-plugin-api'; + +export class MyAwesomeApiClient implements MyAwesomeApi { + discoveryApi: DiscoveryApi; + + constructor({discoveryApi}: {discoveryApi: DiscoveryApi}) { + this.discoveryApi = discoveryApi; + } + + private async fetch(input: string, init?: RequestInit): Promise { + // As configured previously for the backend proxy + const proxyUri = '${await this.discoveryApi.getBaseUrl('proxy')}/'; + + const resp = await fetch(`${proxyUri}${input}`, init); + if (!resp.ok) throw new Error(resp); + return await resp.json(); + } + + async listUsers(): Promise> { + return await this.fetch>('/users'); + } + + async getUser(userId: string): Promise { + return await this.fetch(`/users/${userId}`); + } + + async deleteUser(userId: string): Promise { + return await this.fetch( + `/users/${userId}`, + { method: 'DELETE' } + ); + } +``` + +> For more information on the DiscoveryApi check out the +> [docs](../reference/core-plugin-api.discoveryapi.md) + +## Bundling your ApiRef with your plugin + +The final piece in the puzzle is bundling the `myAwesomeApiRef` with a factory +for `MyAwesomeApiClient` objects. This is usually done in the `plugin.ts` file +inside the plugin's `src` directory. This is an example of what it'd look like, +assuming you added the previous code in a file called `api.ts`: + +```ts +/* src/plugin.ts */ +import { myAwesomeApiRef, MyAwesomeApiClient } from './api'; +import { + createPlugin, + createRouteRef, + createApiFactory, + createRoutableExtension, + createComponentExtension, + discoveryApiRef, +} from '@backstage/core-plugin-api'; + +//... + +export const myCustomPlugin = createPlugin({ + id: '', + + // Configure a factory for myAwesomeApiRef + apis: [ + createApiFactory({ + api: myAwesomeApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new MyAwesomeApiClient({ discoveryApi }), + }), + ], +}); +``` + +## Using the API in your components + +Now you should be able to access your API using the backstage hook +[`useApi`](../reference/core-plugin-api.useapi.md) from within your plugin code. + +```ts +/* plugins/my-awesome-plugin/src/components/AwesomeUsersTable.tsx */ +import { useApi } from '@backstage/core-plugin-api'; +import { myAwesomeApiRef } from '../../api'; + +export const AwesomeUsersTable = () => { + const apiClient = useApi(myAwesomeApiRef); + + apiClient.listUsers() + .then( + ... + ) +} +``` diff --git a/microsite/blog/2021-09-30-50-public-adopters.md b/microsite/blog/2021-09-30-50-public-adopters.md new file mode 100644 index 0000000000..3e96cade69 --- /dev/null +++ b/microsite/blog/2021-09-30-50-public-adopters.md @@ -0,0 +1,45 @@ +--- +title: The Big 5-0 +author: Francesco Corti, Spotify +authorURL: https://github.com/fcorti/ +authorImageURL: https://avatars.githubusercontent.com/u/6010860?v=4 +--- + +We’re excited to celebrate an important milestone for the Backstage community: 50+ [public adopters](https://github.com/backstage/backstage/blob/master/ADOPTERS.md)! + +![Celebrating all of Backstage’s 50+ adopters.](assets/21-09-30/50-public-adopters.png) + +Before digging into why we believe this is so important, we want to send a huge “thank you” to all the Backstage adopters and contributors who have helped grow the Backstage community. First, to the publicly listed adopters as your visible support for the project excites others to learn more about Backstage. Second, to those non-listed adopters, many of whom still engage in the community via issues, comments, or code contributions. Thank you! + + + +## Why we care about public adopters + +![The pace of public adoption has accelerated over the last year](assets/21-09-30/public-backstage-adopters.png) + +If you roll the clock back to early 2020 when Backstage was open sourced, we never could’ve imagined surpassing this milestone. It feels particularly big given that Backstage tends to be adopted by complex organizations with hundreds (if not thousands of) developers and thousands (if not tens of thousands) of software components. + +We’ve also seen such diverse examples of Backstage in the wild. Adopters as varied as [American Airlines][am] and [Splunk][sp] have demoed their Backstage-built developer portals. We’ve seen digital-first companies like [Zalando][za] and [DAZN][da] share their journey from proof-of-concept to in-production. And the developer experience team at [Expedia Group][ex] has been shared at multiple Community Sessions, detailing their adoption journey as well as contributing back with ideas for new features. + +For the Backstage project, public adopters are important because they provide a wide variety of use cases, industries, and degrees of complexity (teams, number of services, etc.) that Backstage is able to support. In addition, a growing adopters list shows the project’s maturity and helps other organizations understand the benefits and make the decision to join the project and the community. + +In other words, the more the public adopters list grows and becomes more diverse, the more the community will grow and provide contributions that benefit all adopters. + +## Fifty is just the beginning + +![Backstage is growing: 52+ pull requests per week, excluding maintainers. Over 13,000 stars on GitHub. 518 total contributors, with about 7+ new contributors per week.](assets/21-09-30/backstage-stats.png) + +While we’re thrilled to celebrate this milestone, the public adopters list is just one metric we monitor in the overall health of the Backstage project. It definitely doesn’t tell the whole story! We’re seeing continued growth within the contributor community, PRs, and GitHub overall ratings as well. As fate would have it, we also recently reached 50+ plugins in the [Backstage Plugin Marketplace][plugins]! + +## Join us! + +If you are a Backstage enthusiast, please [join me][news] and the entire Backstage Team in celebrating this milestone. And if you are a Backstage adopter not already listed in the [GitHub page][gh], consider adding your name to better inform the community and participate in the project. + +[am]: https://backstage.spotify.com/blog/adopter-spotlight/american-airlines-runway/ +[sp]: https://backstage.spotify.com/blog/community-session/splunk-pink-phonebook/ +[za]: https://youtu.be/6sg5uMCLxTA +[da]: https://medium.com/dazn-tech/developer-experience-dx-at-dazn-e6de9a0208d2 +[ex]: https://backstage.spotify.com/blog/community-session/firehydrant-expedia-loblaw/ +[plugins]: https://backstage.io/plugins +[news]: https://mailchi.mp/spotify/backstage-community +[gh]: https://github.com/backstage/backstage/blob/master/ADOPTERS.md diff --git a/microsite/blog/assets/21-09-30/50-public-adopters.png b/microsite/blog/assets/21-09-30/50-public-adopters.png new file mode 100644 index 0000000000..d4557c2980 Binary files /dev/null and b/microsite/blog/assets/21-09-30/50-public-adopters.png differ diff --git a/microsite/blog/assets/21-09-30/backstage-stats.png b/microsite/blog/assets/21-09-30/backstage-stats.png new file mode 100644 index 0000000000..8f965289c9 Binary files /dev/null and b/microsite/blog/assets/21-09-30/backstage-stats.png differ diff --git a/microsite/blog/assets/21-09-30/public-backstage-adopters.png b/microsite/blog/assets/21-09-30/public-backstage-adopters.png new file mode 100644 index 0000000000..c19efc0497 Binary files /dev/null and b/microsite/blog/assets/21-09-30/public-backstage-adopters.png differ diff --git a/microsite/data/plugins/badges.yaml b/microsite/data/plugins/badges.yaml new file mode 100644 index 0000000000..646a519b25 --- /dev/null +++ b/microsite/data/plugins/badges.yaml @@ -0,0 +1,9 @@ +--- +title: Badges +author: Andreas Stenius +authorUrl: https://github.com/backstage/community +category: Discovery +description: The badges plugin offers a set of badges that can be used outside of Backstage, showing information related to data from the catalog. +documentation: https://github.com/backstage/backstage/blob/master/plugins/badges/README.md +iconUrl: img/badges.svg +npmPackageName: '@backstage/plugin-badges' diff --git a/microsite/data/plugins/git-release-manager.yaml b/microsite/data/plugins/git-release-manager.yaml index 7eec6040ac..9c2372a6cb 100644 --- a/microsite/data/plugins/git-release-manager.yaml +++ b/microsite/data/plugins/git-release-manager.yaml @@ -3,7 +3,7 @@ title: GitHub Release Manager author: '@Spotify' authorUrl: https://github.com/spotify category: Release management -description: Manage releases without having to juggle git commands +description: Manage releases without having to juggle git commands. documentation: https://github.com/backstage/backstage/tree/master/plugins/git-release-manager iconUrl: img/git-release-manager-logo.svg npmPackageName: '@backstage/plugin-git-release-manager' diff --git a/microsite/data/plugins/gke-usage.yaml b/microsite/data/plugins/gke-usage.yaml index 42d3e3a287..2ca9f5784a 100644 --- a/microsite/data/plugins/gke-usage.yaml +++ b/microsite/data/plugins/gke-usage.yaml @@ -3,7 +3,7 @@ title: GKE Usage author: BESTSELLER authorUrl: bestsellerit.com category: Discovery -description: This plugin will show you the cost and resource usage of your application within GKE. +description: This plugin will show you the cost and resource usage of your application within Google Kubernetes Engine (GKE). documentation: https://github.com/BESTSELLER/backstage-plugin-gkeusage/blob/master/README.md iconUrl: https://bestsellerit.com/img/google-container-engine_avatar.svg npmPackageName: '@bestsellerit/backstage-plugin-gkeusage' diff --git a/microsite/data/plugins/harbor.yaml b/microsite/data/plugins/harbor.yaml index a851f99545..4e31113c83 100644 --- a/microsite/data/plugins/harbor.yaml +++ b/microsite/data/plugins/harbor.yaml @@ -3,7 +3,7 @@ title: Harbor author: BESTSELLER authorUrl: bestsellerit.com category: Discovery -description: This plugin will show you information about docker images within harbor. +description: This plugin will show you information about Docker images within the Harbor cloud native registry. documentation: https://github.com/BESTSELLER/backstage-plugin-harbor/blob/master/README.md iconUrl: https://bestsellerit.com/img/terraform-harbor/goharbor.jpeg npmPackageName: '@bestsellerit/backstage-plugin-harbor' diff --git a/microsite/data/plugins/prometheus.yaml b/microsite/data/plugins/prometheus.yaml new file mode 100644 index 0000000000..0d1e02dd74 --- /dev/null +++ b/microsite/data/plugins/prometheus.yaml @@ -0,0 +1,13 @@ +--- +title: Prometheus +author: Roadie +authorUrl: https://roadie.io +category: Monitoring +description: Prometheus plugin provides visualization of Prometheus metrics and alerts +documentation: https://roadie.io/backstage/plugins/prometheus/?utm_source=backstage.io&utm_medium=marketplace&utm_campaign=prometheus +iconUrl: https://avatars.githubusercontent.com/u/3380462?s=200&v=4 +npmPackageName: '@roadiehq/backstage-plugin-prometheus' +tags: + - monitoring + - graphs + - alerting diff --git a/microsite/data/plugins/shortcuts.yaml b/microsite/data/plugins/shortcuts.yaml new file mode 100644 index 0000000000..437a0e44b1 --- /dev/null +++ b/microsite/data/plugins/shortcuts.yaml @@ -0,0 +1,9 @@ +--- +title: Shortcuts +author: Spotify +authorUrl: https://github.com/spotify +category: Utility +description: The shortcuts plugin allows a user to have easy access to pages within a Backstage app by storing them as "shortcuts" in the Sidebar. +documentation: https://github.com/backstage/backstage/blob/master/plugins/shortcuts/README.md +iconUrl: img/shortcuts.svg +npmPackageName: '@backstage/plugin-shortcuts' diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index 56b6c122ae..3804c45478 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -20,6 +20,20 @@ class Index extends React.Component { return (
+ + + 🚀 Feature updates!{' '} + + Software Templates + {' '} + and{' '} + + TechDocs + {' '} + are now in beta. + + + @@ -54,15 +68,6 @@ class Index extends React.Component { - - - 🎉 New feature: Kubernetes for service owners.{' '} - - Learn more. - - - - diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 2d14a288d8..9cf3b204c2 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -255,7 +255,8 @@ "tutorials/quickstart-app-plugin", "tutorials/migrating-away-from-core", "tutorials/configuring-plugin-databases", - "tutorials/switching-sqlite-postgres" + "tutorials/switching-sqlite-postgres", + "tutorials/using-backstage-proxy-within-plugin" ], "Architecture Decision Records (ADRs)": [ "architecture-decisions/adrs-overview", diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 393a5af118..ac432372b1 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -1094,10 +1094,27 @@ code { position: relative; overflow: visible; z-index: 100; - + margin: 25px auto 0 auto; max-width: 1430px; height: 0; - margin: -14px auto 14px auto; +} + +@media only screen and (max-width: 1024px) { + .Banner__Container { + margin-top: 20px; + } +} + +@media only screen and (max-width: 645px) { + .Banner__Container { + margin-top: 60px; + } +} + +@media only screen and (max-width: 335px) { + .Banner__Container { + margin-top: 90px; + } } .Banner__DismissButton { diff --git a/microsite/static/img/badges.svg b/microsite/static/img/badges.svg new file mode 100644 index 0000000000..b0272986e6 --- /dev/null +++ b/microsite/static/img/badges.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/microsite/static/img/shortcuts.svg b/microsite/static/img/shortcuts.svg new file mode 100644 index 0000000000..c53a45c0ab --- /dev/null +++ b/microsite/static/img/shortcuts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 48d1d49dcf..366411c7c4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -111,7 +111,7 @@ nav: - Structure of a plugin: 'plugins/structure-of-a-plugin.md' - Plugin Development: 'plugins/plugin-development.md' - Integrate into the Software Catalog: 'plugins/integrating-plugin-into-software-catalog.md' - - Composability System Migration: 'plugins/composability.md' + - Composability System: 'plugins/composability.md' - Backends and APIs: - Proxying: 'plugins/proxying.md' - Backend plugin: 'plugins/backend-plugin.md' @@ -140,6 +140,7 @@ nav: - Google: 'auth/google/provider.md' - Okta: 'auth/okta/provider.md' - OneLogin: 'auth/onelogin/provider.md' + - Bitbucket: 'auth/bitbucket/provider.md' - Adding authentication providers: 'auth/add-auth-provider.md' - Using authentication and identity: 'auth/using-auth.md' - Sign in resolvers: 'auth/identity-resolver.md' @@ -165,6 +166,7 @@ nav: - Migrating away from @backstage/core: 'tutorials/migrating-away-from-core.md' - Adding Custom Plugin to Existing Monorepo App: 'tutorials/quickstart-app-plugin.md' - Switching Backstage from SQLite to PostgreSQL: 'tutorials/switching-sqlite-postgres.md' + - Using the Backstage Proxy from Within a Plugin: 'tutorials/using-backstage-proxy-within-plugin.md' - Architecture Decision Records (ADRs): - Overview: 'architecture-decisions/index.md' - ADR001 - Architecture Decision Record (ADR) log: 'architecture-decisions/adr001-add-adr-log.md' diff --git a/package.json b/package.json index 4f360b8f71..03a502fee2 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,10 @@ "build:api-reports:only": "ts-node -T -P scripts/tsconfig.json scripts/api-extractor.ts", "build:api-docs": "yarn build:api-reports --docs", "tsc": "tsc", - "tsc:full": "tsc --skipLibCheck false --incremental false", + "tsc:full": "backstage-cli clean && tsc --skipLibCheck false --incremental false", "clean": "backstage-cli clean && lerna run clean", "diff": "lerna run diff --", - "test": "lerna run test --since origin/master -- --coverage", + "test": "backstage-cli test", "test:all": "lerna run test -- --coverage", "lint": "lerna run lint --since origin/master --", "lint:docs": "node ./scripts/check-docs-quality", diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 4b6a39a792..3cc1f0aea7 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,89 @@ # example-app +## 0.2.49 + +### Patch Changes + +- Updated dependencies + - @backstage/cli@0.7.15 + - @backstage/core-components@0.6.1 + - @backstage/core-plugin-api@0.1.10 + - @backstage/core-app-api@0.1.16 + - @backstage/plugin-org@0.3.26 + - @backstage/plugin-catalog@0.7.0 + - @backstage/plugin-catalog-react@0.5.2 + - @backstage/catalog-model@0.9.4 + - @backstage/plugin-cost-insights@0.11.9 + - @backstage/plugin-user-settings@0.3.8 + - @backstage/plugin-kubernetes@0.4.16 + - @backstage/plugin-catalog-import@0.7.1 + - @backstage/plugin-badges@0.2.12 + - @backstage/plugin-home@0.4.3 + - @backstage/plugin-search@0.4.14 + - @backstage/plugin-shortcuts@0.1.11 + - @backstage/plugin-api-docs@0.6.11 + - @backstage/plugin-catalog-graph@0.1.3 + - @backstage/plugin-circleci@0.2.26 + - @backstage/plugin-cloudbuild@0.2.26 + - @backstage/plugin-code-coverage@0.1.14 + - @backstage/plugin-explore@0.3.19 + - @backstage/plugin-gcp-projects@0.3.7 + - @backstage/plugin-github-actions@0.4.21 + - @backstage/plugin-graphiql@0.2.19 + - @backstage/plugin-jenkins@0.5.9 + - @backstage/plugin-kafka@0.2.18 + - @backstage/plugin-lighthouse@0.2.28 + - @backstage/plugin-newrelic@0.3.7 + - @backstage/plugin-pagerduty@0.3.16 + - @backstage/plugin-rollbar@0.3.17 + - @backstage/plugin-scaffolder@0.11.7 + - @backstage/plugin-sentry@0.3.24 + - @backstage/plugin-tech-radar@0.4.10 + - @backstage/plugin-techdocs@0.12.1 + - @backstage/plugin-todo@0.1.13 + +## 0.2.48 + +### Patch Changes + +- Updated dependencies + - @backstage/cli@0.7.14 + - @backstage/plugin-techdocs@0.12.0 + - @backstage/plugin-user-settings@0.3.7 + - @backstage/core-app-api@0.1.15 + - @backstage/plugin-catalog-import@0.7.0 + - @backstage/plugin-badges@0.2.11 + - @backstage/plugin-cost-insights@0.11.8 + - @backstage/plugin-tech-radar@0.4.9 + - @backstage/core-plugin-api@0.1.9 + - @backstage/plugin-kubernetes@0.4.15 + - @backstage/core-components@0.6.0 + - @backstage/integration-react@0.1.11 + - @backstage/plugin-catalog@0.6.17 + - @backstage/plugin-api-docs@0.6.10 + - @backstage/plugin-catalog-graph@0.1.2 + - @backstage/plugin-catalog-react@0.5.1 + - @backstage/plugin-circleci@0.2.25 + - @backstage/plugin-cloudbuild@0.2.25 + - @backstage/plugin-code-coverage@0.1.13 + - @backstage/plugin-explore@0.3.18 + - @backstage/plugin-gcp-projects@0.3.6 + - @backstage/plugin-github-actions@0.4.20 + - @backstage/plugin-graphiql@0.2.18 + - @backstage/plugin-home@0.4.2 + - @backstage/plugin-jenkins@0.5.8 + - @backstage/plugin-kafka@0.2.17 + - @backstage/plugin-lighthouse@0.2.27 + - @backstage/plugin-newrelic@0.3.6 + - @backstage/plugin-org@0.3.25 + - @backstage/plugin-pagerduty@0.3.15 + - @backstage/plugin-rollbar@0.3.16 + - @backstage/plugin-scaffolder@0.11.6 + - @backstage/plugin-search@0.4.13 + - @backstage/plugin-sentry@0.3.23 + - @backstage/plugin-shortcuts@0.1.10 + - @backstage/plugin-todo@0.1.12 + ## 0.2.47 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index dedce0bf85..d1892438d8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,46 +1,46 @@ { "name": "example-app", - "version": "0.2.47", + "version": "0.2.49", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.9.3", - "@backstage/cli": "^0.7.13", - "@backstage/core-app-api": "^0.1.14", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.8", - "@backstage/integration-react": "^0.1.10", - "@backstage/plugin-api-docs": "^0.6.9", - "@backstage/plugin-badges": "^0.2.10", - "@backstage/plugin-catalog": "^0.6.16", - "@backstage/plugin-catalog-graph": "^0.1.1", - "@backstage/plugin-catalog-import": "^0.6.0", - "@backstage/plugin-catalog-react": "^0.5.0", - "@backstage/plugin-circleci": "^0.2.24", - "@backstage/plugin-cloudbuild": "^0.2.24", - "@backstage/plugin-code-coverage": "^0.1.12", - "@backstage/plugin-cost-insights": "^0.11.7", - "@backstage/plugin-explore": "^0.3.17", - "@backstage/plugin-gcp-projects": "^0.3.5", - "@backstage/plugin-github-actions": "^0.4.19", - "@backstage/plugin-graphiql": "^0.2.17", - "@backstage/plugin-home": "^0.4.1", - "@backstage/plugin-jenkins": "^0.5.7", - "@backstage/plugin-kafka": "^0.2.16", - "@backstage/plugin-kubernetes": "^0.4.14", - "@backstage/plugin-lighthouse": "^0.2.26", - "@backstage/plugin-newrelic": "^0.3.5", - "@backstage/plugin-org": "^0.3.24", - "@backstage/plugin-pagerduty": "0.3.14", - "@backstage/plugin-rollbar": "^0.3.15", - "@backstage/plugin-scaffolder": "^0.11.5", - "@backstage/plugin-search": "^0.4.12", - "@backstage/plugin-sentry": "^0.3.22", - "@backstage/plugin-shortcuts": "^0.1.9", - "@backstage/plugin-tech-radar": "^0.4.8", - "@backstage/plugin-techdocs": "^0.11.3", - "@backstage/plugin-todo": "^0.1.11", - "@backstage/plugin-user-settings": "^0.3.6", + "@backstage/catalog-model": "^0.9.4", + "@backstage/cli": "^0.7.15", + "@backstage/core-app-api": "^0.1.16", + "@backstage/core-components": "^0.6.1", + "@backstage/core-plugin-api": "^0.1.10", + "@backstage/integration-react": "^0.1.11", + "@backstage/plugin-api-docs": "^0.6.11", + "@backstage/plugin-badges": "^0.2.12", + "@backstage/plugin-catalog": "^0.7.0", + "@backstage/plugin-catalog-graph": "^0.1.3", + "@backstage/plugin-catalog-import": "^0.7.1", + "@backstage/plugin-catalog-react": "^0.5.2", + "@backstage/plugin-circleci": "^0.2.26", + "@backstage/plugin-cloudbuild": "^0.2.26", + "@backstage/plugin-code-coverage": "^0.1.14", + "@backstage/plugin-cost-insights": "^0.11.9", + "@backstage/plugin-explore": "^0.3.19", + "@backstage/plugin-gcp-projects": "^0.3.7", + "@backstage/plugin-github-actions": "^0.4.21", + "@backstage/plugin-graphiql": "^0.2.19", + "@backstage/plugin-home": "^0.4.3", + "@backstage/plugin-jenkins": "^0.5.9", + "@backstage/plugin-kafka": "^0.2.18", + "@backstage/plugin-kubernetes": "^0.4.16", + "@backstage/plugin-lighthouse": "^0.2.28", + "@backstage/plugin-newrelic": "^0.3.7", + "@backstage/plugin-org": "^0.3.26", + "@backstage/plugin-pagerduty": "0.3.16", + "@backstage/plugin-rollbar": "^0.3.17", + "@backstage/plugin-scaffolder": "^0.11.7", + "@backstage/plugin-search": "^0.4.14", + "@backstage/plugin-sentry": "^0.3.24", + "@backstage/plugin-shortcuts": "^0.1.11", + "@backstage/plugin-tech-radar": "^0.4.10", + "@backstage/plugin-techdocs": "^0.12.1", + "@backstage/plugin-todo": "^0.1.13", + "@backstage/plugin-user-settings": "^0.3.8", "@backstage/search-common": "^0.2.0", "@backstage/theme": "^0.2.10", "@material-ui/core": "^4.12.2", @@ -62,7 +62,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.17", + "@backstage/test-utils": "^0.1.18", "@rjsf/core": "^3.0.0", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 87984bece0..109ae7922e 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -84,6 +84,8 @@ import { searchPage } from './components/search/SearchPage'; import { providers } from './identityProviders'; import * as plugins from './plugins'; +import { techDocsPage } from './components/techdocs/TechDocsPage'; + const app = createApp({ apis, plugins: Object.values(plugins), @@ -170,7 +172,9 @@ const routes = ( } - /> + > + {techDocsPage} + }> diff --git a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx index 5ef4f617a7..1c96e9325c 100644 --- a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx +++ b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx @@ -26,7 +26,7 @@ export const LowerCaseValuePickerFieldExtension = scaffolderPlugin.provide( name: 'LowerCaseValuePicker', component: TextValuePicker, validation: (value: string, validation: FieldValidation) => { - if (value.toLowerCase() !== value) { + if (value.toLocaleLowerCase('en-US') !== value) { validation.addError('Only lowercase values are allowed.'); } }, diff --git a/packages/app/src/components/techdocs/TechDocsPage.tsx b/packages/app/src/components/techdocs/TechDocsPage.tsx new file mode 100644 index 0000000000..be11e30e61 --- /dev/null +++ b/packages/app/src/components/techdocs/TechDocsPage.tsx @@ -0,0 +1,44 @@ +/* + * 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 { Content } from '@backstage/core-components'; +import { + TechDocsPageHeader, + TechDocsPage, + Reader, +} from '@backstage/plugin-techdocs'; +import React from 'react'; + +const DefaultTechDocsPage = () => { + return ( + + {({ techdocsMetadataValue, entityMetadataValue, entityRef, onReady }) => ( + <> + + + + + + )} + + ); +}; + +export const techDocsPage = ; diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 4cc7bab91a..49204d2b5e 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -24,6 +24,7 @@ import { oneloginAuthApiRef, oauth2ApiRef, oidcAuthApiRef, + bitbucketAuthApiRef, } from '@backstage/core-plugin-api'; export const providers = [ @@ -81,4 +82,10 @@ export const providers = [ message: 'Sign In using OneLogin', apiRef: oneloginAuthApiRef, }, + { + id: 'bitbucket-auth-provider', + title: 'Bitbucket', + message: 'Sign In using Bitbucket', + apiRef: bitbucketAuthApiRef, + }, ]; diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index 28f092886e..e7b897692e 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,37 @@ # @backstage/backend-common +## 0.9.6 + +### Patch Changes + +- 8f969d5a56: Correct error message typo +- a31afc5b62: Replace slash stripping regexp with trimEnd to remove CodeQL warning +- d7055285de: Add glob patterns support to config CORS options. It's possible to send patterns like: + + ```yaml + backend: + cors: + origin: + - https://*.my-domain.com + - http://localhost:700[0-9] + - https://sub-domain-+([0-9]).my-domain.com + ``` + +- Updated dependencies + - @backstage/config-loader@0.6.10 + - @backstage/integration@0.6.7 + - @backstage/cli-common@0.1.4 + +## 0.9.5 + +### Patch Changes + +- 8bb3c0a578: The `subscribe` method on the `Config` returned by `loadBackendConfig` is now forwarded through `getConfig` and `getOptionalConfig`. +- 0c8a59e293: Fix an issue where filtering in search doesn't work correctly for Bitbucket. +- Updated dependencies + - @backstage/integration@0.6.6 + - @backstage/config-loader@0.6.9 + ## 0.9.4 ### Patch Changes diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 67a2c1a853..16167f7803 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.9.4", + "version": "0.9.6", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -29,11 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.3", + "@backstage/cli-common": "^0.1.4", "@backstage/config": "^0.1.10", - "@backstage/config-loader": "^0.6.8", + "@backstage/config-loader": "^0.6.10", "@backstage/errors": "^0.1.2", - "@backstage/integration": "^0.6.5", + "@backstage/integration": "^0.6.7", "@google-cloud/storage": "^5.8.0", "@octokit/rest": "^18.5.3", "@types/cors": "^2.8.6", @@ -77,8 +77,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/test-utils": "^0.1.17", + "@backstage/cli": "^0.7.15", + "@backstage/test-utils": "^0.1.18", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 611e94f0db..4ae0ea37a8 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -25,6 +25,7 @@ import { } from '@backstage/integration'; import fetch from 'cross-fetch'; import parseGitUrl from 'git-url-parse'; +import { trimEnd } from 'lodash'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { @@ -149,7 +150,7 @@ export class BitbucketUrlReader implements UrlReader { // a future improvement, we could be smart and try to deduce that non-glob // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used // to get just that part of the repo. - const treeUrl = url.replace(filepath, '').replace(/\/+$/, ''); + const treeUrl = trimEnd(url.replace(filepath, ''), '/'); const tree = await this.readTree(treeUrl, { etag: options?.etag, diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index b78add6916..3fc9673e8b 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -37,6 +37,7 @@ import { ReadUrlResponse, ReadUrlOptions, } from './types'; +import { trimEnd } from 'lodash'; /** @public */ export class GitlabUrlReader implements UrlReader { @@ -186,7 +187,7 @@ export class GitlabUrlReader implements UrlReader { // a future improvement, we could be smart and try to deduce that non-glob // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used // to get just that part of the repo. - const treeUrl = url.replace(filepath, '').replace(/\/+$/, ''); + const treeUrl = trimEnd(url.replace(filepath, ''), '/'); const tree = await this.readTree(treeUrl, { etag: options?.etag, diff --git a/packages/backend-common/src/service/lib/config.test.ts b/packages/backend-common/src/service/lib/config.test.ts index f2f0c88cfb..b80d475517 100644 --- a/packages/backend-common/src/service/lib/config.test.ts +++ b/packages/backend-common/src/service/lib/config.test.ts @@ -15,7 +15,7 @@ */ import { ConfigReader } from '@backstage/config'; -import { readCspOptions } from './config'; +import { readCorsOptions, readCspOptions } from './config'; describe('config', () => { describe('readCspOptions', () => { @@ -42,4 +42,67 @@ describe('config', () => { expect(() => readCspOptions(config)).toThrow(/wanted string-array/); }); }); + + describe('readCorsOptions', () => { + it('reads single string', () => { + const mockCallback = jest.fn(); + const config = new ConfigReader({ cors: { origin: 'https://*.value*' } }); + const cors = readCorsOptions(config); + expect(cors).toEqual( + expect.objectContaining({ + origin: expect.any(Function), + }), + ); + const origin = cors?.origin as Function; + origin('https://a.value', mockCallback); // valid origin + origin('http://a.value', mockCallback); // invalid origin + origin(undefined, mockCallback); // when not origin needs to reject the call + + expect(mockCallback.mock.calls[0][0]).toBe(null); + expect(mockCallback.mock.calls[1][0]).toBe(null); + + expect(mockCallback.mock.calls[0][1]).toBe(true); + expect(mockCallback.mock.calls[1][1]).toBe(false); + expect(mockCallback.mock.calls[2][1]).toBe(false); + }); + + it('reads string array', () => { + const mockCallback = jest.fn(); + const config = new ConfigReader({ + cors: { + origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'], + }, + }); + const cors = readCorsOptions(config); + expect(cors).toEqual( + expect.objectContaining({ + origin: expect.any(Function), + }), + ); + const origin = cors?.origin as Function; + origin('https://a.b.c.value-9.com', mockCallback); + origin('http://a.value-999.com', mockCallback); + origin('http://a.value', mockCallback); + origin('http://a.valuex', mockCallback); + + expect(mockCallback.mock.calls[0][0]).toBe(null); + expect(mockCallback.mock.calls[1][0]).toBe(null); + expect(mockCallback.mock.calls[2][0]).toBe(null); + expect(mockCallback.mock.calls[3][0]).toBe(null); + + expect(mockCallback.mock.calls[0][1]).toBe(true); + expect(mockCallback.mock.calls[1][1]).toBe(true); + expect(mockCallback.mock.calls[2][1]).toBe(true); + expect(mockCallback.mock.calls[3][1]).toBe(false); + }); + + it('reads undefined origin', () => { + const config = new ConfigReader({ + cors: {}, + }); + const cors = readCorsOptions(config); + expect(cors).toEqual(expect.objectContaining({})); + expect(cors?.origin).toBeUndefined(); + }); + }); }); diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts index 77ef925403..57b814da63 100644 --- a/packages/backend-common/src/service/lib/config.ts +++ b/packages/backend-common/src/service/lib/config.ts @@ -16,6 +16,7 @@ import { Config } from '@backstage/config'; import { CorsOptions } from 'cors'; +import { Minimatch } from 'minimatch'; export type BaseOptions = { listenPort?: string | number; @@ -46,6 +47,13 @@ export type CertificateAttributes = { */ export type CspOptions = Record; +type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[]; + +type CustomOrigin = ( + requestOrigin: string | undefined, + callback: (err: Error | null, origin?: StaticOrigin) => void, +) => void; + /** * Reads some base options out of a config object. * @@ -78,7 +86,7 @@ export function readBaseOptions(config: Config): BaseOptions { typeof port !== 'string' ) { throw new Error( - `Invalid type in config for key 'backend.listen.post', got ${typeof port}, wanted string or number`, + `Invalid type in config for key 'backend.listen.port', got ${typeof port}, wanted string or number`, ); } @@ -112,7 +120,7 @@ export function readCorsOptions(config: Config): CorsOptions | undefined { } return removeUnknown({ - origin: getOptionalStringOrStrings(cc, 'origin'), + origin: createCorsOriginMatcher(getOptionalStringOrStrings(cc, 'origin')), methods: getOptionalStringOrStrings(cc, 'methods'), allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'), exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'), @@ -207,16 +215,45 @@ function getOptionalStringOrStrings( key: string, ): string | string[] | undefined { const value = config.getOptional(key); - if ( - value === undefined || - typeof value === 'string' || - isStringArray(value) - ) { + if (value === undefined || isStringOrStrings(value)) { return value; } throw new Error(`Expected string or array of strings, got ${typeof value}`); } +function createCorsOriginMatcher( + originValue: string | string[] | undefined, +): CustomOrigin | undefined { + if (originValue === undefined) { + return originValue; + } + + if (!isStringOrStrings(originValue)) { + throw new Error( + `Expected string or array of strings, got ${typeof originValue}`, + ); + } + + const allowedOrigin = + typeof originValue === 'string' ? [originValue] : originValue; + + const allowedOriginPatterns = + allowedOrigin?.map( + pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }), + ) ?? []; + + return (origin, callback) => { + return callback( + null, + allowedOriginPatterns.some(pattern => pattern.match(origin ?? '')), + ); + }; +} + +function isStringOrStrings(value: any): value is string | string[] { + return typeof value === 'string' || isStringArray(value); +} + function isStringArray(value: any): value is string[] { if (!Array.isArray(value)) { return false; diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 6c4fe32dca..b527a875e7 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,37 @@ # example-backend +## 0.2.49 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@0.16.0 + - @backstage/catalog-model@0.9.4 + - @backstage/plugin-proxy-backend@0.2.13 + - @backstage/plugin-auth-backend@0.4.3 + - @backstage/backend-common@0.9.6 + - @backstage/catalog-client@0.5.0 + - @backstage/integration@0.6.7 + - @backstage/plugin-scaffolder-backend@0.15.7 + - example-app@0.2.49 + - @backstage/plugin-badges-backend@0.1.11 + - @backstage/plugin-code-coverage-backend@0.1.12 + - @backstage/plugin-jenkins-backend@0.1.6 + - @backstage/plugin-techdocs-backend@0.10.4 + - @backstage/plugin-todo-backend@0.1.13 + +## 0.2.48 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-common@0.9.5 + - @backstage/plugin-catalog-backend@0.15.0 + - @backstage/plugin-azure-devops-backend@0.1.1 + - @backstage/integration@0.6.6 + - @backstage/plugin-auth-backend@0.4.2 + - example-app@0.2.48 + ## 0.2.47 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 5346466ba1..0752f0da2e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.47", + "version": "0.2.49", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -24,35 +24,36 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.9.4", - "@backstage/catalog-client": "^0.4.0", - "@backstage/catalog-model": "^0.9.3", + "@backstage/backend-common": "^0.9.6", + "@backstage/catalog-client": "^0.5.0", + "@backstage/catalog-model": "^0.9.4", "@backstage/config": "^0.1.10", - "@backstage/integration": "^0.6.5", + "@backstage/integration": "^0.6.7", "@backstage/plugin-app-backend": "^0.3.16", - "@backstage/plugin-auth-backend": "^0.4.1", - "@backstage/plugin-badges-backend": "^0.1.10", - "@backstage/plugin-catalog-backend": "^0.14.0", - "@backstage/plugin-code-coverage-backend": "^0.1.11", + "@backstage/plugin-auth-backend": "^0.4.3", + "@backstage/plugin-azure-devops-backend": "^0.1.1", + "@backstage/plugin-badges-backend": "^0.1.11", + "@backstage/plugin-catalog-backend": "^0.16.0", + "@backstage/plugin-code-coverage-backend": "^0.1.12", "@backstage/plugin-graphql-backend": "^0.1.9", - "@backstage/plugin-jenkins-backend": "^0.1.5", + "@backstage/plugin-jenkins-backend": "^0.1.6", "@backstage/plugin-kubernetes-backend": "^0.3.16", "@backstage/plugin-kafka-backend": "^0.2.10", - "@backstage/plugin-proxy-backend": "^0.2.12", + "@backstage/plugin-proxy-backend": "^0.2.13", "@backstage/plugin-rollbar-backend": "^0.1.15", - "@backstage/plugin-scaffolder-backend": "^0.15.6", + "@backstage/plugin-scaffolder-backend": "^0.15.7", "@backstage/plugin-scaffolder-backend-module-rails": "^0.1.5", "@backstage/plugin-search-backend": "^0.2.6", "@backstage/plugin-search-backend-node": "^0.4.2", "@backstage/plugin-search-backend-module-elasticsearch": "^0.0.4", "@backstage/plugin-search-backend-module-pg": "^0.2.1", - "@backstage/plugin-techdocs-backend": "^0.10.3", - "@backstage/plugin-todo-backend": "^0.1.12", + "@backstage/plugin-techdocs-backend": "^0.10.4", + "@backstage/plugin-todo-backend": "^0.1.13", "@gitbeaker/node": "^30.2.0", "@octokit/rest": "^18.5.3", "azure-devops-node-api": "^11.0.1", "dockerode": "^3.2.1", - "example-app": "^0.2.47", + "example-app": "^0.2.49", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-prom-bundle": "^6.3.6", @@ -64,7 +65,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.7.13", + "@backstage/cli": "^0.7.15", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 78c6c4c20b..f25ec5a14e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -38,6 +38,7 @@ import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import { metricsInit, metricsHandler } from './metrics'; import auth from './plugins/auth'; +import azureDevOps from './plugins/azuredevops'; import catalog from './plugins/catalog'; import codeCoverage from './plugins/codecoverage'; import kubernetes from './plugins/kubernetes'; @@ -94,6 +95,7 @@ async function main() { ); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); const authEnv = useHotMemoize(module, () => createEnv('auth')); + const azureDevOpsEnv = useHotMemoize(module, () => createEnv('azure-devops')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); const searchEnv = useHotMemoize(module, () => createEnv('search')); @@ -112,6 +114,7 @@ async function main() { apiRouter.use('/rollbar', await rollbar(rollbarEnv)); apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); apiRouter.use('/auth', await auth(authEnv)); + apiRouter.use('/azure-devops', await azureDevOps(azureDevOpsEnv)); apiRouter.use('/search', await search(searchEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/todo', await todo(todoEnv)); diff --git a/packages/backend/src/plugins/azuredevops.ts b/packages/backend/src/plugins/azuredevops.ts new file mode 100644 index 0000000000..e11a21ba2e --- /dev/null +++ b/packages/backend/src/plugins/azuredevops.ts @@ -0,0 +1,26 @@ +/* + * 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 { createRouter } from '@backstage/plugin-azure-devops-backend'; +import { Router } from 'express'; +import type { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + config, +}: PluginEnvironment): Promise { + return await createRouter({ logger, config }); +} diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index ffb629b5bc..8243d09486 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/catalog-client +## 0.5.0 + +### Minor Changes + +- bb0f6b8a0f: Updates the `` to accept asynchronous `if` functions. + + Adds the new `getEntityAncestors` method to `CatalogClient`. + + Updates the `` to make use of the ancestry endpoint to display errors for entities further up the ancestry tree. This makes it easier to discover issues where for example the origin location has been removed or malformed. + + `hasCatalogProcessingErrors()` is now changed to be asynchronous so any calls outside the already established entitySwitch need to be awaited. + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.4 + ## 0.4.0 ### Minor Changes diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 8a81c0eb10..b7bb713957 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -38,6 +38,11 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise>; // (undocumented) + getEntityAncestors( + request: CatalogEntityAncestorsRequest, + options?: CatalogRequestOptions, + ): Promise; + // (undocumented) getEntityByName( name: EntityName, options?: CatalogRequestOptions, @@ -88,6 +93,11 @@ export class CatalogClient implements CatalogApi { options?: CatalogRequestOptions, ): Promise>; // (undocumented) + getEntityAncestors( + request: CatalogEntityAncestorsRequest, + options?: CatalogRequestOptions, + ): Promise; + // (undocumented) getEntityByName( compoundName: EntityName, options?: CatalogRequestOptions, @@ -133,6 +143,20 @@ export type CatalogEntitiesRequest = { fields?: string[] | undefined; }; +// @public (undocumented) +export type CatalogEntityAncestorsRequest = { + entityRef: string; +}; + +// @public (undocumented) +export type CatalogEntityAncestorsResponse = { + root: EntityName; + items: { + entity: Entity; + parents: EntityName[]; + }[]; +}; + // @public (undocumented) export type CatalogListResponse = { items: T[]; diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 8520164f91..8da62f6115 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/catalog-client", "description": "An isomorphic client for the catalog backend", - "version": "0.4.0", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,13 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.3", - "@backstage/config": "^0.1.10", + "@backstage/catalog-model": "^0.9.4", "@backstage/errors": "^0.1.2", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.7.13", + "@backstage/cli": "^0.7.15", "@types/jest": "^26.0.7", "msw": "^0.29.0" }, diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 1a6bc5c70d..75944467d8 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -20,6 +20,7 @@ import { Location, LOCATION_ANNOTATION, ORIGIN_LOCATION_ANNOTATION, + parseEntityRef, stringifyEntityRef, stringifyLocationReference, } from '@backstage/catalog-model'; @@ -33,6 +34,8 @@ import { CatalogEntitiesRequest, CatalogListResponse, CatalogRequestOptions, + CatalogEntityAncestorsRequest, + CatalogEntityAncestorsResponse, } from './types/api'; import { DiscoveryApi } from './types/discovery'; @@ -44,6 +47,20 @@ export class CatalogClient implements CatalogApi { this.discoveryApi = options.discoveryApi; } + async getEntityAncestors( + request: CatalogEntityAncestorsRequest, + options?: CatalogRequestOptions, + ): Promise { + const { kind, namespace, name } = parseEntityRef(request.entityRef); + return await this.requestRequired( + 'GET', + `/entities/by-name/${encodeURIComponent(kind)}/${encodeURIComponent( + namespace, + )}/${encodeURIComponent(name)}/ancestry`, + options, + ); + } + async getLocationById( id: string, options?: CatalogRequestOptions, diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 679588b10a..c7025e9452 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -28,6 +28,17 @@ export type CatalogEntitiesRequest = { fields?: string[] | undefined; }; +/** @public */ +export type CatalogEntityAncestorsRequest = { + entityRef: string; +}; + +/** @public */ +export type CatalogEntityAncestorsResponse = { + root: EntityName; + items: { entity: Entity; parents: EntityName[] }[]; +}; + /** @public */ export type CatalogListResponse = { items: T[]; @@ -45,6 +56,10 @@ export interface CatalogApi { request?: CatalogEntitiesRequest, options?: CatalogRequestOptions, ): Promise>; + getEntityAncestors( + request: CatalogEntityAncestorsRequest, + options?: CatalogRequestOptions, + ): Promise; getEntityByName( name: EntityName, options?: CatalogRequestOptions, diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 7963891a4c..3552a72e3c 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -21,6 +21,8 @@ export type { CatalogEntitiesRequest, CatalogListResponse, CatalogRequestOptions, + CatalogEntityAncestorsRequest, + CatalogEntityAncestorsResponse, } from './api'; export type { DiscoveryApi } from './discovery'; export { CATALOG_FILTER_EXISTS } from './api'; diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index cce24390b1..e7c36953db 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/catalog-model +## 0.9.4 + +### Patch Changes + +- 957e4b3351: Updated dependencies +- ca0559444c: Avoid usage of `.to*Case()`, preferring `.toLocale*Case('en-US')` instead. + ## 0.9.3 ### Patch Changes diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 3f92a671fa..1e7eb8fd52 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -10,12 +10,9 @@ import { SerializedError } from '@backstage/errors'; import * as yup from 'yup'; // @public @deprecated (undocumented) -export const analyzeLocationSchema: yup.ObjectSchema< - { - location: LocationSpec; - }, - object ->; +export const analyzeLocationSchema: yup.SchemaOf<{ + location: LocationSpec; +}>; // @public (undocumented) interface ApiEntityV1alpha1 extends Entity { @@ -337,7 +334,7 @@ export { LocationEntityV1alpha1 }; export const locationEntityV1alpha1Validator: KindValidator; // @public @deprecated (undocumented) -export const locationSchema: yup.ObjectSchema; +export const locationSchema: yup.SchemaOf; // @public (undocumented) export type LocationSpec = { @@ -347,7 +344,7 @@ export type LocationSpec = { }; // @public @deprecated (undocumented) -export const locationSpecSchema: yup.ObjectSchema; +export const locationSpecSchema: yup.SchemaOf; // @public (undocumented) export function makeValidator(overrides?: Partial): Validators; diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 0bea531816..45b947c890 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/catalog-model", "description": "Types and validators that help describe the model of a Backstage Catalog", - "version": "0.9.3", + "version": "0.9.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,16 +33,15 @@ "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.2", "@types/json-schema": "^7.0.5", - "@types/yup": "^0.29.8", + "@types/yup": "^0.29.13", "ajv": "^7.0.3", "json-schema": "^0.3.0", "lodash": "^4.17.21", "uuid": "^8.0.0", - "yup": "^0.29.3" + "yup": "^0.32.9" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@types/express": "^4.17.6", + "@backstage/cli": "^0.7.15", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", "yaml": "^1.9.2" diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts index c1b9383eeb..6a38e80dfe 100644 --- a/packages/catalog-model/src/entity/ref.ts +++ b/packages/catalog-model/src/entity/ref.ts @@ -248,7 +248,9 @@ export function stringifyEntityRef( name = ref.name; } - return `${kind.toLowerCase()}:${namespace.toLowerCase()}/${name.toLowerCase()}`; + return `${kind.toLocaleLowerCase('en-US')}:${namespace.toLocaleLowerCase( + 'en-US', + )}/${name.toLocaleLowerCase('en-US')}`; } /** @@ -296,8 +298,10 @@ export function compareEntityToRef( } return ( - entityKind.toLowerCase() === refKind.toLowerCase() && - entityNamespace.toLowerCase() === refNamespace.toLowerCase() && - entityName.toLowerCase() === refName.toLowerCase() + entityKind.toLocaleLowerCase('en-US') === + refKind.toLocaleLowerCase('en-US') && + entityNamespace.toLocaleLowerCase('en-US') === + refNamespace.toLocaleLowerCase('en-US') && + entityName.toLocaleLowerCase('en-US') === refName.toLocaleLowerCase('en-US') ); } diff --git a/packages/catalog-model/src/location/validation.ts b/packages/catalog-model/src/location/validation.ts index f6680d6eee..452c69af53 100644 --- a/packages/catalog-model/src/location/validation.ts +++ b/packages/catalog-model/src/location/validation.ts @@ -21,11 +21,11 @@ import { LocationSpec, Location } from './types'; * @public * @deprecated Use JSONSchema or validators instead. */ -export const locationSpecSchema = yup - .object({ +export const locationSpecSchema: yup.SchemaOf = yup + .object({ type: yup.string().required(), target: yup.string().required(), - presence: yup.string(), + presence: yup.mixed().oneOf(['required', 'optional']), }) .noUnknown() .required(); @@ -34,11 +34,12 @@ export const locationSpecSchema = yup * @public * @deprecated Use JSONSchema or validators instead. */ -export const locationSchema = yup - .object({ +export const locationSchema: yup.SchemaOf = yup + .object({ id: yup.string().required(), type: yup.string().required(), target: yup.string().required(), + presence: yup.mixed().oneOf(['required', 'optional']), }) .noUnknown() .required(); @@ -47,9 +48,10 @@ export const locationSchema = yup * @public * @deprecated Use JSONSchema or validators instead. */ -export const analyzeLocationSchema = yup - .object<{ location: LocationSpec }>({ - location: locationSpecSchema, - }) - .noUnknown() - .required(); +export const analyzeLocationSchema: yup.SchemaOf<{ location: LocationSpec }> = + yup + .object({ + location: locationSpecSchema, + }) + .noUnknown() + .required(); diff --git a/packages/cli-common/CHANGELOG.md b/packages/cli-common/CHANGELOG.md index 8a2a903efd..b4b87fa103 100644 --- a/packages/cli-common/CHANGELOG.md +++ b/packages/cli-common/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/cli-common +## 0.1.4 + +### Patch Changes + +- ca0559444c: Avoid usage of `.to*Case()`, preferring `.toLocale*Case('en-US')` instead. + ## 0.1.3 ### Patch Changes diff --git a/packages/cli-common/package.json b/packages/cli-common/package.json index c643192df3..0693b555a6 100644 --- a/packages/cli-common/package.json +++ b/packages/cli-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli-common", "description": "Common functionality used by cli, backend, and create-app", - "version": "0.1.3", + "version": "0.1.4", "private": false, "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/cli-common/src/paths.ts b/packages/cli-common/src/paths.ts index 90239dfa4a..4aa9d4b76e 100644 --- a/packages/cli-common/src/paths.ts +++ b/packages/cli-common/src/paths.ts @@ -115,7 +115,7 @@ export function findPaths(searchDir: string): Paths { // Drive letter can end up being lowercased here on Windows, bring back to uppercase for consistency const targetDir = fs .realpathSync(process.cwd()) - .replace(/^[a-z]:/, str => str.toUpperCase()); + .replace(/^[a-z]:/, str => str.toLocaleUpperCase('en-US')); // Lazy load this as it will throw an error if we're not inside the Backstage repo. let ownRoot = ''; diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 96170d6899..b88175ee24 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/cli +## 0.7.15 + +### Patch Changes + +- ae4680b88d: The `create-plugin` command now passes the extension name via the `name` key + in `createRoutableExtension()` calls in newly created plugins. +- df1242ffe4: Adding `--inspect-brk` as an option when debugging backend for development +- c7f2a2307d: When creating a backend plugin with `--backend` flag, don't add `-backend` if it's already suffixed +- 185fec5c0c: The default jest configuration used by the `test` command now supports yarn workspaces. By running `backstage-cli test` in the root of a monorepo, all packages will now automatically be included in the test suite and it will run just like it does within a package. Each package in the monorepo will still use its own local jest configuration, and only packages that have `backstage-cli test` in the `test` script within `package.json` will be included. +- Updated dependencies + - @backstage/config-loader@0.6.10 + - @backstage/cli-common@0.1.4 + +## 0.7.14 + +### Patch Changes + +- 3a8704f16b: Only serve static assets if there is a public folder during `app:serve` and `plugin:serve`. This fixes a common bug that would break `plugin:serve` with an `EBUSY` error. +- 40199b61d6: Configuration schema is now also collected from the root `package.json` if it exists. +- 2a6c393c06: The `create-plugin` command now prefers dependency versions ranges that are already in the lockfile. +- 58f91943ab: Improved ´plugin:diff´ check for the `package.json` `"files"` field. +- 12e074a6e4: Fix duplication checks to stop looking for the old core packages, and to allow some explicitly +- Updated dependencies + - @backstage/config-loader@0.6.9 + ## 0.7.13 ### Patch Changes diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 1df33d2395..80b3e84cda 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -16,20 +16,23 @@ const fs = require('fs-extra'); const path = require('path'); +const glob = require('util').promisify(require('glob')); -async function getConfig() { +async function getProjectConfig(targetPath) { + const configJsPath = path.resolve(targetPath, 'jest.config.js'); + const configTsPath = path.resolve(targetPath, 'jest.config.ts'); // If the package has it's own jest config, we use that instead. - if (await fs.pathExists('jest.config.js')) { - return require(path.resolve('jest.config.js')); - } else if (await fs.pathExists('jest.config.ts')) { - return require(path.resolve('jest.config.ts')); + if (await fs.pathExists(configJsPath)) { + return require(configJsPath); + } else if (await fs.pathExists(configTsPath)) { + return require(configTsPath); } // We read all "jest" config fields in package.json files all the way to the filesystem root. // All configs are merged together to create the final config, with longer paths taking precedence. // The merging of the configs is shallow, meaning e.g. all transforms are replaced if new ones are defined. const pkgJsonConfigs = []; - let currentPath = process.cwd(); + let currentPath = targetPath; // Some sanity check to avoid infinite loop for (let i = 0; i < 100; i++) { @@ -70,8 +73,8 @@ async function getConfig() { const transformModulePattern = transformModules && `(?!${transformModules})`; const options = { - rootDir: path.resolve('src'), - coverageDirectory: path.resolve('coverage'), + rootDir: path.resolve(targetPath, 'src'), + coverageDirectory: path.resolve(targetPath, 'coverage'), collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'], moduleNameMapper: { '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'), @@ -96,11 +99,61 @@ async function getConfig() { }; // Use src/setupTests.ts as the default location for configuring test env - if (fs.existsSync('src/setupTests.ts')) { + if (fs.existsSync(path.resolve(targetPath, 'src/setupTests.ts'))) { options.setupFilesAfterEnv = ['/setupTests.ts']; } return Object.assign(options, ...pkgJsonConfigs); } -module.exports = getConfig(); +// This loads the root jest config, which in turn will either refer to a single +// configuration for the current package, or a collection of configurations for +// the target workspace packages +async function getRootConfig() { + const targetPath = process.cwd(); + const targetPackagePath = path.resolve(targetPath, 'package.json'); + const exists = await fs.pathExists(targetPackagePath); + + if (!exists) { + return getProjectConfig(targetPath); + } + + // Check whether the current package is a workspace root or not + const data = await fs.readJson(targetPackagePath); + const workspacePatterns = data.workspaces && data.workspaces.packages; + if (!workspacePatterns) { + return getProjectConfig(targetPath); + } + + // If the target package is a workspace root, we find all packages in the + // workspace and load those in as separate jest projects instead. + const projectPaths = await Promise.all( + workspacePatterns.map(pattern => glob(path.join(targetPath, pattern))), + ).then(_ => _.flat()); + + const configs = await Promise.all( + projectPaths.flat().map(async projectPath => { + const packagePath = path.resolve(projectPath, 'package.json'); + if (!(await fs.pathExists(packagePath))) { + return undefined; + } + + // We check for the presence of "backstage-cli test" in the package test + // script to determine whether a given package should be tested + const packageData = await fs.readJson(packagePath); + const testScript = packageData.scripts && packageData.scripts.test; + if (testScript && testScript.includes('backstage-cli test')) { + return await getProjectConfig(projectPath); + } + + return undefined; + }), + ).then(cs => cs.filter(Boolean)); + + return { + rootDir: targetPath, + projects: configs, + }; +} + +module.exports = getRootConfig(); diff --git a/packages/cli/package.json b/packages/cli/package.json index 0dc4dc3cf7..5b0a4b34d2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.7.13", + "version": "0.7.15", "private": false, "publishConfig": { "access": "public" @@ -30,9 +30,9 @@ "dependencies": { "@babel/core": "^7.4.4", "@babel/plugin-transform-modules-commonjs": "^7.4.4", - "@backstage/cli-common": "^0.1.3", + "@backstage/cli-common": "^0.1.4", "@backstage/config": "^0.1.10", - "@backstage/config-loader": "^0.6.8", + "@backstage/config-loader": "^0.6.10", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^4.0.0", "@lerna/project": "^4.0.0", @@ -76,6 +76,7 @@ "express": "^4.17.1", "fork-ts-checker-webpack-plugin": "^4.0.5", "fs-extra": "9.1.0", + "glob": "^7.1.7", "handlebars": "^4.7.3", "html-webpack-plugin": "^5.3.1", "inquirer": "^7.0.4", @@ -117,13 +118,13 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.9.4", + "@backstage/backend-common": "^0.9.6", "@backstage/config": "^0.1.10", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.8", - "@backstage/core-app-api": "^0.1.14", - "@backstage/dev-utils": "^0.2.10", - "@backstage/test-utils": "^0.1.17", + "@backstage/core-components": "^0.6.1", + "@backstage/core-plugin-api": "^0.1.10", + "@backstage/core-app-api": "^0.1.16", + "@backstage/dev-utils": "^0.2.11", + "@backstage/test-utils": "^0.1.18", "@backstage/theme": "^0.2.10", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", diff --git a/packages/cli/src/commands/backend/dev.ts b/packages/cli/src/commands/backend/dev.ts index a09cea0e8a..b14a1f3e20 100644 --- a/packages/cli/src/commands/backend/dev.ts +++ b/packages/cli/src/commands/backend/dev.ts @@ -29,6 +29,7 @@ export default async (cmd: Command) => { entry: 'src/index', checksEnabled: cmd.check, inspectEnabled: cmd.inspect, + inspectBrkEnabled: cmd.inspectBrk, }); await waitForExit(); diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 0d47df8c22..6ddd1f7160 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -239,7 +239,10 @@ export default async (cmd: Command) => { } const answers: Answers = await inquirer.prompt(questions); - const pluginId = cmd.backend ? `${answers.id}-backend` : answers.id; + const pluginId = + cmd.backend && !answers.id.endsWith('-backend') + ? `${answers.id}-backend` + : answers.id; const name = cmd.scope ? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}` diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index d21349a4e1..117778b35e 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -69,6 +69,7 @@ export function registerCommands(program: CommanderStatic) { .description('Start local development server with HMR for the backend') .option('--check', 'Enable type checking and linting') .option('--inspect', 'Enable debugger') + .option('--inspect-brk', 'Enable debugger with await to attach debugger') // We don't actually use the config in the CLI, just pass them on to the NodeJS process .option(...configOption) .action(lazy(() => import('./backend/dev').then(m => m.default))); @@ -224,6 +225,11 @@ export function registerCommands(program: CommanderStatic) { .command('create-github-app ') .description('Create new GitHub App in your organization.') .action(lazy(() => import('./create-github-app').then(m => m.default))); + + program + .command('info') + .description('Show helpful information for debugging and reporting bugs') + .action(lazy(() => import('./info').then(m => m.default))); } // Wraps an action function so that it always exits and handles errors diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts new file mode 100644 index 0000000000..4042d2cb8e --- /dev/null +++ b/packages/cli/src/commands/info.ts @@ -0,0 +1,46 @@ +/* + * 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 { version as cliVersion } from '../../package.json'; +import os from 'os'; +import { runPlain } from '../lib/run'; +import { paths } from '../lib/paths'; +import { Lockfile } from '../lib/versioning'; + +export default async () => { + await new Promise(async () => { + const yarnVersion = await runPlain('yarn --version'); + // eslint-disable-next-line no-restricted-syntax + const isLocal = require('fs').existsSync(paths.resolveOwn('./src')); + + console.log(`OS: ${os.type} ${os.release} - ${os.platform}/${os.arch}`); + console.log(`node: ${process.version}`); + console.log(`yarn: ${yarnVersion}`); + console.log(`cli: ${cliVersion} (${isLocal ? 'local' : 'installed'})`); + console.log(); + console.log('Dependencies:'); + const lockfilePath = paths.resolveTargetRoot('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); + + const deps = [...lockfile.keys()].filter(n => n.startsWith('@backstage/')); + const maxLength = Math.max(...deps.map(d => d.length)); + + for (const dep of deps) { + const versions = new Set(lockfile.get(dep)!.map(i => i.version)); + console.log(` ${dep.padEnd(maxLength)} ${[...versions].join(', ')}`); + } + }); +}; diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 6e3f445f4a..58ff71b7f6 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -247,6 +247,13 @@ export async function createBackendConfig( const { loaders } = transforms(options); + const runScriptNodeArgs = new Array(); + if (options.inspectEnabled) { + runScriptNodeArgs.push('--inspect'); + } else if (options.inspectBrkEnabled) { + runScriptNodeArgs.push('--inspect-brk'); + } + return { mode: isDev ? 'development' : 'production', profile: false, @@ -319,7 +326,7 @@ export async function createBackendConfig( plugins: [ new RunScriptWebpackPlugin({ name: 'main.js', - nodeArgs: options.inspectEnabled ? ['--inspect'] : undefined, + nodeArgs: runScriptNodeArgs.length > 0 ? runScriptNodeArgs : undefined, args: process.argv.slice(3), // drop `node backstage-cli backend:dev` }), new webpack.HotModuleReplacementPlugin(), diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index 2f8e3125c1..e580cf4985 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -50,10 +50,12 @@ export async function serveBundle(options: ServeOptions) { publicPath: config.output?.publicPath as string, stats: 'errors-warnings', }, - static: { - publicPath: config.output?.publicPath as string, - directory: paths.targetPublic ?? '/', - }, + static: paths.targetPublic + ? { + publicPath: config.output?.publicPath as string, + directory: paths.targetPublic, + } + : undefined, historyApiFallback: { // Paths with dots should still use the history fallback. // See https://github.com/facebookincubator/create-react-app/issues/387. diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 283074a295..93865bcf7e 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -47,11 +47,13 @@ export type BackendBundlingOptions = { isDev: boolean; parallel?: ParallelOption; inspectEnabled: boolean; + inspectBrkEnabled: boolean; }; export type BackendServeOptions = BundlingPathsOptions & { checksEnabled: boolean; inspectEnabled: boolean; + inspectBrkEnabled: boolean; }; export type LernaPackage = { diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index a93f040122..3688d60089 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -39,6 +39,8 @@ export async function loadCliConfig(options: Options) { const schema = await loadConfigSchema({ dependencies: localPackageNames, + // Include the package.json in the project root if it exists + packagePaths: [paths.resolveTargetRoot('package.json')], }); const appConfigs = await loadConfig({ diff --git a/packages/cli/src/lib/diff/handlers.ts b/packages/cli/src/lib/diff/handlers.ts index 4a17125e5a..f8bbfa09ce 100644 --- a/packages/cli/src/lib/diff/handlers.ts +++ b/packages/cli/src/lib/diff/handlers.ts @@ -85,6 +85,7 @@ class PackageJsonHandler { targetObj: any = this.targetPkg, prefix?: string, sort?: boolean, + optional?: boolean, ) { const fullFieldName = chalk.cyan( prefix ? `${prefix}[${fieldName}]` : fieldName, @@ -107,7 +108,7 @@ class PackageJsonHandler { } await this.write(); } - } else if (fieldName in obj) { + } else if (fieldName in obj && optional !== true) { if ( await this.prompt( `package.json is missing field ${fullFieldName}, set to ${coloredNewValue}?`, @@ -123,11 +124,41 @@ class PackageJsonHandler { } private async syncFiles() { - if (typeof this.targetPkg.configSchema === 'string') { - const files = [...this.pkg.files, this.targetPkg.configSchema]; - await this.syncField('files', { files }); + const { configSchema } = this.targetPkg; + const hasSchemaFile = typeof configSchema === 'string'; + + if (!this.targetPkg.files) { + const expected = hasSchemaFile ? ['dist', configSchema] : ['dist']; + if ( + await this.prompt( + `package.json is missing field "files", set to ${JSON.stringify( + expected, + )}?`, + ) + ) { + this.targetPkg.files = expected; + await this.write(); + } } else { - await this.syncField('files'); + const missing = []; + if (!this.targetPkg.files.includes('dist')) { + missing.push('dist'); + } + if (hasSchemaFile && !this.targetPkg.files.includes(configSchema)) { + missing.push(configSchema); + } + if (missing.length) { + if ( + await this.prompt( + `package.json is missing ${JSON.stringify( + missing, + )} in the "files" field, add?`, + ) + ) { + this.targetPkg.files.push(...missing); + await this.write(); + } + } } } @@ -200,7 +231,7 @@ class PackageJsonHandler { continue; } - await this.syncField(key, pkgDeps, targetDeps, fieldName, true); + await this.syncField(key, pkgDeps, targetDeps, fieldName, true, true); } } diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index dcf35fc5d2..86a8ed9507 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -11,6 +11,7 @@ export const {{ pluginVar }} = createPlugin({ export const {{ extensionName }} = {{ pluginVar }}.provide( createRoutableExtension({ + name: '{{ extensionName }}', component: () => import('./components/ExampleComponent').then(m => m.ExampleComponent), mountPoint: rootRouteRef, diff --git a/packages/codemods/CHANGELOG.md b/packages/codemods/CHANGELOG.md index fa5c9d663b..d038e16ccc 100644 --- a/packages/codemods/CHANGELOG.md +++ b/packages/codemods/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/codemods +## 0.1.17 + +### Patch Changes + +- 7dee4db0b0: build(deps): bump `jscodeshift` from 0.12.0 to 0.13.0 +- 903dbdeb7d: Added an `extension-names` codemod, which adds a `name` key to all extensions + provided by plugins. Extension names are used to provide richer context to + events captured by the new Analytics API, and may also appear in debug output + and other situations. + + To apply this codemod, run `npx @backstage/codemods apply extension-names` in + the root of your Backstage monorepo. + +- Updated dependencies + - @backstage/core-components@0.6.1 + - @backstage/core-plugin-api@0.1.10 + - @backstage/core-app-api@0.1.16 + - @backstage/cli-common@0.1.4 + +## 0.1.16 + +### Patch Changes + +- Updated dependencies + - @backstage/core-app-api@0.1.15 + - @backstage/core-plugin-api@0.1.9 + - @backstage/core-components@0.6.0 + ## 0.1.15 ### Patch Changes diff --git a/packages/codemods/package.json b/packages/codemods/package.json index 6f07940f3b..a745498613 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/codemods", "description": "A collection of codemods for Backstage projects", - "version": "0.1.15", + "version": "0.1.17", "private": false, "publishConfig": { "access": "public", @@ -31,12 +31,12 @@ "backstage-codemods": "bin/backstage-codemods" }, "dependencies": { - "@backstage/cli-common": "0.1.3", + "@backstage/cli-common": "0.1.4", "@backstage/core-app-api": "*", "@backstage/core-components": "*", "@backstage/core-plugin-api": "*", "chalk": "^4.0.0", - "jscodeshift": "^0.12.0", + "jscodeshift": "^0.13.0", "jscodeshift-add-imports": "^1.0.10" }, "devDependencies": { diff --git a/packages/codemods/src/codemods.ts b/packages/codemods/src/codemods.ts index 6cdd9d8977..3d366e3707 100644 --- a/packages/codemods/src/codemods.ts +++ b/packages/codemods/src/codemods.ts @@ -25,4 +25,9 @@ export const codemods: Codemod[] = [ description: 'Updates @backstage/core imports to use @backstage/core-* imports instead.', }, + { + name: 'extension-names', + description: + 'Adds a "name" property to all core extensions provided by plugins.', + }, ]; diff --git a/packages/codemods/transforms/extension-names.js b/packages/codemods/transforms/extension-names.js new file mode 100644 index 0000000000..75cbabdc81 --- /dev/null +++ b/packages/codemods/transforms/extension-names.js @@ -0,0 +1,83 @@ +/* + * 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. + */ + +module.exports = (file, /** @type {import('jscodeshift').API} */ api) => { + const j = api.jscodeshift; + const root = j(file.source); + + // Find all variables set to *.provide() with an invocation of an extension + // creator as its first argument. + // For example: somePlugin.provide(createReactExtension({})) + const extensionCreators = [ + 'createReactExtension', + 'createComponentExtension', + 'createRoutableExtension', + ]; + const extensions = root + .findVariableDeclarators() + .filter( + n => + n.node.init?.type === 'CallExpression' && + n.node.init?.callee.type === 'MemberExpression' && + n.node.init?.callee?.property.name === 'provide' && + n.node.init?.arguments.length === 1 && + extensionCreators.includes(n.node.init?.arguments[0].callee?.name), + ); + + // Abort if no such variable declarations exist in this file. + if (extensions.size === 0) { + return undefined; + } + + // For each variable, parse out its name and apply the name as the value of + // the "name" key on the argument passed to the extension creator's argument. + // For example: createReactExtension({ name: 'Some Extension' }) + let numberAffected = 0; + extensions.nodes().flatMap(node => { + const creatorArgObject = node.init?.arguments[0].arguments[0]; + const hasNameKeyAlready = + creatorArgObject.properties.filter(p => p.key.name === 'name').length === + 1; + const nameProp = j.objectProperty( + j.identifier('name'), + j.literal(node.id.name), + ); + + // Apply this change only once. + if (!hasNameKeyAlready) { + numberAffected++; + creatorArgObject.properties.unshift(nameProp); + } + }); + + // Abort if no changes were made. + if (numberAffected === 0) { + return undefined; + } + + // Check for code styles to be applied to this file. + const useSingleQuote = + root.find(j.ImportDeclaration).nodes()[0]?.source.extra.raw[0] === "'"; + const useTrailingComma = root + .find(j.ObjectExpression) + .nodes()[0] + ?.extra?.hasOwnProperty('trailingComma'); + + return root.toSource({ + quote: useSingleQuote ? 'single' : 'double', + trailingComma: useTrailingComma, + }); +}; diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index 88479bbb18..34fc34b441 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/config-loader +## 0.6.10 + +### Patch Changes + +- 957e4b3351: Updated dependencies +- Updated dependencies + - @backstage/cli-common@0.1.4 + +## 0.6.9 + +### Patch Changes + +- ee7a1a4b64: Add option to collect configuration schemas from explicit package paths in addition to by package name. +- e68bd978e2: Allow collection of configuration schemas from multiple versions of the same package. + ## 0.6.8 ### Patch Changes diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index bb3ae50d23..355839c5af 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -53,6 +53,7 @@ export function loadConfigSchema( export type LoadConfigSchemaOptions = | { dependencies: string[]; + packagePaths?: string[]; } | { serialized: JsonObject; diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 2f2878799e..d423c915d0 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.6.8", + "version": "0.6.10", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/cli-common": "^0.1.3", + "@backstage/cli-common": "^0.1.4", "@backstage/config": "^0.1.9", "@types/json-schema": "^7.0.6", "ajv": "^7.0.3", @@ -40,14 +40,14 @@ "json-schema-merge-allof": "^0.8.1", "typescript-json-schema": "^0.50.1", "yaml": "^1.9.2", - "yup": "^0.29.3" + "yup": "^0.32.9" }, "devDependencies": { "@types/jest": "^26.0.7", "@types/json-schema-merge-allof": "^0.6.0", "@types/mock-fs": "^4.10.0", "@types/node": "^14.14.32", - "@types/yup": "^0.29.8", + "@types/yup": "^0.29.13", "mock-fs": "^5.1.0" }, "files": [ diff --git a/packages/config-loader/src/lib/schema/collect.test.ts b/packages/config-loader/src/lib/schema/collect.test.ts index ef9d38faa9..877cbb54e3 100644 --- a/packages/config-loader/src/lib/schema/collect.test.ts +++ b/packages/config-loader/src/lib/schema/collect.test.ts @@ -49,7 +49,7 @@ describe('collectConfigSchemas', () => { }), }); - await expect(collectConfigSchemas([])).resolves.toEqual([]); + await expect(collectConfigSchemas([], [])).resolves.toEqual([]); }); it('should find schema in a local package', async () => { @@ -64,7 +64,7 @@ describe('collectConfigSchemas', () => { }, }); - await expect(collectConfigSchemas(['a'])).resolves.toEqual([ + await expect(collectConfigSchemas(['a'], [])).resolves.toEqual([ { path: path.join('node_modules', 'a', 'package.json'), value: mockSchema, @@ -72,8 +72,34 @@ describe('collectConfigSchemas', () => { ]); }); - it('should find schema in transitive dependencies', async () => { + it('should find schema at explicit package path', async () => { mockFs({ + root: { + 'package.json': JSON.stringify({ + name: 'root', + configSchema: mockSchema, + }), + }, + }); + + await expect( + collectConfigSchemas([], [path.join('root', 'package.json')]), + ).resolves.toEqual([ + { + path: path.join('root', 'package.json'), + value: mockSchema, + }, + ]); + }); + + it('should find schema in transitive dependencies and explicit path', async () => { + mockFs({ + root: { + 'package.json': JSON.stringify({ + name: 'root', + configSchema: { ...mockSchema, title: 'root' }, + }), + }, node_modules: { a: { 'package.json': JSON.stringify({ @@ -124,20 +150,28 @@ describe('collectConfigSchemas', () => { }, }); - await expect(collectConfigSchemas(['a'])).resolves.toEqual([ - { - path: path.join('node_modules', 'b', 'package.json'), - value: { ...mockSchema, title: 'b' }, - }, - { - path: path.join('node_modules', 'c1', 'package.json'), - value: { ...mockSchema, title: 'c1' }, - }, - { - path: path.join('node_modules', 'd1', 'package.json'), - value: { ...mockSchema, title: 'd1' }, - }, - ]); + await expect( + collectConfigSchemas(['a'], [path.join('root', 'package.json')]), + ).resolves.toEqual( + expect.arrayContaining([ + { + path: path.join('node_modules', 'b', 'package.json'), + value: { ...mockSchema, title: 'b' }, + }, + { + path: path.join('node_modules', 'c1', 'package.json'), + value: { ...mockSchema, title: 'c1' }, + }, + { + path: path.join('node_modules', 'd1', 'package.json'), + value: { ...mockSchema, title: 'd1' }, + }, + { + path: path.join('root', 'package.json'), + value: { ...mockSchema, title: 'root' }, + }, + ]), + ); }); it('should schema of different types', async () => { @@ -171,7 +205,7 @@ describe('collectConfigSchemas', () => { [typescriptModuleDir]: (mockFs as any).load(typescriptModuleDir), }); - await expect(collectConfigSchemas(['a', 'b', 'c'])).resolves.toEqual([ + await expect(collectConfigSchemas(['a', 'b', 'c'], [])).resolves.toEqual([ { path: path.join('node_modules', 'a', 'package.json'), value: { ...mockSchema, title: 'inline' }, @@ -197,6 +231,74 @@ describe('collectConfigSchemas', () => { ]); }); + it('should load schema from different package versions', async () => { + mockFs({ + node_modules: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + b: '1', + c: '1', + }, + configSchema: mockSchema, + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '1', + dependencies: { + c: '2', + }, + configSchema: { ...mockSchema, title: 'b' }, + }), + node_modules: { + c: { + 'package.json': JSON.stringify({ + name: 'c', + version: '2', + configSchema: { ...mockSchema, title: 'c2' }, + }), + }, + }, + }, + c: { + 'package.json': JSON.stringify({ + name: 'c', + version: '1', + configSchema: { ...mockSchema, title: 'c1' }, + }), + }, + }, + }); + + await expect(collectConfigSchemas(['a'], [])).resolves.toEqual([ + { + path: path.join('node_modules', 'a', 'package.json'), + value: mockSchema, + }, + { + path: path.join('node_modules', 'b', 'package.json'), + value: { ...mockSchema, title: 'b' }, + }, + { + path: path.join('node_modules', 'c', 'package.json'), + value: { ...mockSchema, title: 'c1' }, + }, + { + path: path.join( + 'node_modules', + 'b', + 'node_modules', + 'c', + 'package.json', + ), + value: { ...mockSchema, title: 'c2' }, + }, + ]); + }); + it('should not allow unknown schema file types', async () => { mockFs({ node_modules: { @@ -210,7 +312,7 @@ describe('collectConfigSchemas', () => { }, }); - await expect(collectConfigSchemas(['a'])).rejects.toThrow( + await expect(collectConfigSchemas(['a'], [])).rejects.toThrow( 'Config schema files must be .json or .d.ts, got schema.yaml', ); }); @@ -230,7 +332,7 @@ describe('collectConfigSchemas', () => { [typescriptModuleDir]: (mockFs as any).load(typescriptModuleDir), }); - await expect(collectConfigSchemas(['a'])).rejects.toThrow( + await expect(collectConfigSchemas(['a'], [])).rejects.toThrow( `Invalid schema in ${path.join( 'node_modules', 'a', diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts index b363e8e5f3..f8b134119b 100644 --- a/packages/config-loader/src/lib/schema/collect.ts +++ b/packages/config-loader/src/lib/schema/collect.ts @@ -26,8 +26,9 @@ import { getProgramFromFiles, generateSchema } from 'typescript-json-schema'; import { JsonObject } from '@backstage/config'; type Item = { - name: string; + name?: string; parentPath?: string; + packagePath?: string; }; const req = @@ -40,38 +41,54 @@ const req = */ export async function collectConfigSchemas( packageNames: string[], + packagePaths: string[], ): Promise { - const visitedPackages = new Set(); - const schemas = Array(); - const tsSchemaPaths = Array(); + const schemas = new Array(); + const tsSchemaPaths = new Array(); + const visitedPackageVersions = new Map>(); // pkgName: [versions...] + const currentDir = await fs.realpath(process.cwd()); - async function processItem({ name, parentPath }: Item) { - // Ensures that we only process each package once. We don't bother with - // loading in schemas from duplicates of different versions, as that's not - // supported by Backstage right now anyway. We may want to change that in - // the future though, if it for example becomes possible to load in two - // different versions of e.g. @backstage/core at once. - if (visitedPackages.has(name)) { - return; - } - visitedPackages.add(name); + async function processItem(item: Item) { + let pkgPath = item.packagePath; - let pkgPath: string; - try { - pkgPath = req.resolve( - `${name}/package.json`, - parentPath && { - paths: [parentPath], - }, - ); - } catch { - // We can somewhat safely ignore packages that don't export package.json, - // as they are likely not part of the Backstage ecosystem anyway. + if (pkgPath) { + const pkgExists = await fs.pathExists(pkgPath); + if (!pkgExists) { + return; + } + } else if (item.name) { + const { name, parentPath } = item; + + try { + pkgPath = req.resolve( + `${name}/package.json`, + parentPath && { + paths: [parentPath], + }, + ); + } catch { + // We can somewhat safely ignore packages that don't export package.json, + // as they are likely not part of the Backstage ecosystem anyway. + } + } + if (!pkgPath) { return; } const pkg = await fs.readJson(pkgPath); + + // Ensures that we only process the same version of each package once. + let versions = visitedPackageVersions.get(pkg.name); + if (versions?.has(pkg.version)) { + return; + } + if (!versions) { + versions = new Set(); + visitedPackageVersions.set(pkg.name, versions); + } + versions.add(pkg.version); + const depNames = [ ...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {}), @@ -126,9 +143,10 @@ export async function collectConfigSchemas( ); } - await Promise.all( - packageNames.map(name => processItem({ name, parentPath: currentDir })), - ); + await Promise.all([ + ...packageNames.map(name => processItem({ name, parentPath: currentDir })), + ...packagePaths.map(path => processItem({ name: path, packagePath: path })), + ]); const tsSchemas = compileTsSchemas(tsSchemaPaths); diff --git a/packages/config-loader/src/lib/schema/load.ts b/packages/config-loader/src/lib/schema/load.ts index 4620961310..2d4af3454c 100644 --- a/packages/config-loader/src/lib/schema/load.ts +++ b/packages/config-loader/src/lib/schema/load.ts @@ -28,6 +28,7 @@ import { export type LoadConfigSchemaOptions = | { dependencies: string[]; + packagePaths?: string[]; } | { serialized: JsonObject; @@ -44,7 +45,10 @@ export async function loadConfigSchema( let schemas: ConfigSchemaPackageEntry[]; if ('dependencies' in options) { - schemas = await collectConfigSchemas(options.dependencies); + schemas = await collectConfigSchemas( + options.dependencies, + options.packagePaths ?? [], + ); } else { const { serialized } = options; if (serialized?.backstageConfigSchemaVersion !== 1) { diff --git a/packages/core-app-api/CHANGELOG.md b/packages/core-app-api/CHANGELOG.md index 3b3ca169cd..75490a0548 100644 --- a/packages/core-app-api/CHANGELOG.md +++ b/packages/core-app-api/CHANGELOG.md @@ -1,5 +1,51 @@ # @backstage/core-app-api +## 0.1.16 + +### Patch Changes + +- d9fd798cc8: The Core App API now automatically instruments all route location changes using + the new Analytics API. Each location change triggers a `navigate` event, which + is an analogue of a "pageview" event in traditional web analytics systems. In + addition to the path, these events provide plugin-level metadata via the + analytics context, which can be useful for analyzing plugin usage: + + ```json + { + "action": "navigate", + "subject": "/the-path/navigated/to?with=params#and-hashes", + "context": { + "extension": "App", + "pluginId": "id-of-plugin-that-exported-the-route", + "routeRef": "associated-route-ref-id" + } + } + ``` + + These events can be identified and handled by checking for the action + `navigate` and the extension `App`. + +- 4c3eea7788: Bitbucket Cloud authentication - based on the existing GitHub authentication + changes around BB apis and updated scope. + + - BitbucketAuth added to core-app-api. + - Bitbucket provider added to plugin-auth-backend. + - Cosmetic entry for Bitbucket connection in user-settings Authentication Providers tab. + +- d6ad46eb22: Stop calling connector.removeSession in StaticAuthSessionManager, instead just discarding the + session locally. +- Updated dependencies + - @backstage/core-components@0.6.1 + - @backstage/core-plugin-api@0.1.10 + +## 0.1.15 + +### Patch Changes + +- 0c4ee1876f: Enables late registration of plugins into the application by updating ApiHolder when additional plugins have been added in. +- Updated dependencies + - @backstage/core-plugin-api@0.1.9 + - @backstage/core-components@0.6.0 + ## 0.1.14 ### Patch Changes diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 4b804c2cc2..22d5092aa6 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -5,6 +5,8 @@ ```ts import { AlertApi } from '@backstage/core-plugin-api'; import { AlertMessage } from '@backstage/core-plugin-api'; +import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsEvent } from '@backstage/core-plugin-api'; import { AnyApiFactory } from '@backstage/core-plugin-api'; import { AnyApiRef } from '@backstage/core-plugin-api'; import { ApiFactory } from '@backstage/core-plugin-api'; @@ -21,6 +23,7 @@ import { AuthRequestOptions } from '@backstage/core-plugin-api'; import { BackstageIdentity } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ConfigReader } from '@backstage/config'; import { DiscoveryApi } from '@backstage/core-plugin-api'; @@ -269,6 +272,33 @@ export type BackstagePluginWithAnyOutput = Omit< output(): (PluginOutput | UnknownPluginOutput)[]; }; +// Warning: (ae-missing-release-tag) "BitbucketAuth" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class BitbucketAuth { + // (undocumented) + static create({ + discoveryApi, + environment, + provider, + oauthRequestApi, + defaultScopes, + }: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T; +} + +// Warning: (ae-missing-release-tag) "BitbucketSession" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BitbucketSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt?: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + // Warning: (ae-missing-release-tag) "BootErrorPageProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -456,6 +486,14 @@ export class MicrosoftAuth { }: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T; } +// Warning: (ae-missing-release-tag) "NoOpAnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class NoOpAnalyticsApi implements AnalyticsApi { + // (undocumented) + captureEvent(_event: AnalyticsEvent): void; +} + // Warning: (ae-missing-release-tag) "OAuth2" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 18d20f4afc..1381125b23 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-app-api", "description": "Core app API used by Backstage apps", - "version": "0.1.14", + "version": "0.1.16", "private": false, "publishConfig": { "access": "public", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-components": "^0.5.0", + "@backstage/core-components": "^0.6.1", "@backstage/config": "^0.1.10", - "@backstage/core-plugin-api": "^0.1.8", + "@backstage/core-plugin-api": "^0.1.10", "@backstage/theme": "^0.2.10", "@backstage/version-bridge": "^0.1.0", "@material-ui/core": "^4.12.2", @@ -45,12 +45,12 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/test-utils": "^0.1.17", - "@backstage/test-utils-core": "^0.1.2", + "@backstage/cli": "^0.7.15", + "@backstage/test-utils": "^0.1.18", + "@backstage/test-utils-core": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/react-hooks": "^3.4.2", + "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", diff --git a/packages/core-app-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts b/packages/core-app-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts new file mode 100644 index 0000000000..a3e45006ba --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts @@ -0,0 +1,23 @@ +/* + * 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 { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api'; + +/** + * Base implementation for the AnalyticsApi that does nothing. + */ +export class NoOpAnalyticsApi implements AnalyticsApi { + captureEvent(_event: AnalyticsEvent): void {} +} diff --git a/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts b/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts new file mode 100644 index 0000000000..6bb27dd6d0 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { NoOpAnalyticsApi } from './NoOpAnalyticsApi'; diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.test.ts new file mode 100644 index 0000000000..f4bd7cbe85 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.test.ts @@ -0,0 +1,47 @@ +/* + * 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 MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; +import BitbucketAuth from './BitbucketAuth'; + +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('BitbucketAuth', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + ['team api write_repository', ['team', 'api', 'write_repository']], + ['read_repository sudo', ['read_repository', 'sudo']], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const gitlabAuth = BitbucketAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + gitlabAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts new file mode 100644 index 0000000000..c88db2a2b0 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/BitbucketAuth.ts @@ -0,0 +1,61 @@ +/* + * 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 BitbucketIcon from '@material-ui/icons/FormatBold'; +import { + BackstageIdentity, + bitbucketAuthApiRef, + ProfileInfo, +} from '@backstage/core-plugin-api'; + +import { OAuthApiCreateOptions } from '../types'; +import { OAuth2 } from '../oauth2'; + +export type BitbucketAuthResponse = { + providerInfo: { + accessToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'bitbucket', + title: 'Bitbucket', + icon: BitbucketIcon, +}; + +class BitbucketAuth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['team'], + }: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} + +export default BitbucketAuth; diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/index.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/index.ts new file mode 100644 index 0000000000..33c6e75b3b --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export * from './types'; +export { default as BitbucketAuth } from './BitbucketAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucket/types.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucket/types.ts new file mode 100644 index 0000000000..7babfdf686 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucket/types.ts @@ -0,0 +1,27 @@ +/* + * 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 { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; + +export type BitbucketSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt?: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index 9889a33878..b622b90b8c 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -23,3 +23,4 @@ export * from './saml'; export * from './auth0'; export * from './microsoft'; export * from './onelogin'; +export * from './bitbucket'; diff --git a/packages/core-app-api/src/apis/implementations/index.ts b/packages/core-app-api/src/apis/implementations/index.ts index 31f01f2bff..c494f966f2 100644 --- a/packages/core-app-api/src/apis/implementations/index.ts +++ b/packages/core-app-api/src/apis/implementations/index.ts @@ -21,6 +21,7 @@ export * from './auth'; export * from './AlertApi'; +export * from './AnalyticsApi'; export * from './AppThemeApi'; export * from './ConfigApi'; export * from './DiscoveryApi'; diff --git a/packages/core-app-api/src/app/App.test.tsx b/packages/core-app-api/src/app/App.test.tsx index 6fc1d96fd8..ab287a3ece 100644 --- a/packages/core-app-api/src/app/App.test.tsx +++ b/packages/core-app-api/src/app/App.test.tsx @@ -14,12 +14,16 @@ * limitations under the License. */ -import { LocalStorageFeatureFlags } from '../apis'; -import { renderWithEffects, withLogCollector } from '@backstage/test-utils'; +import { LocalStorageFeatureFlags, NoOpAnalyticsApi } from '../apis'; +import { + MockAnalyticsApi, + renderWithEffects, + withLogCollector, +} from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { render, screen } from '@testing-library/react'; import React, { PropsWithChildren } from 'react'; -import { BrowserRouter, Routes } from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { defaultAppIcons } from './icons'; import { configApiRef, @@ -31,6 +35,7 @@ import { createRouteRef, createSubRouteRef, createRoutableExtension, + analyticsApiRef, } from '@backstage/core-plugin-api'; import { generateBoundRoutes, PrivateAppImpl } from './App'; import { AppThemeProvider } from './AppThemeProvider'; @@ -58,7 +63,12 @@ describe('generateBoundRoutes', () => { }); describe('Integration Test', () => { + const noOpAnalyticsApi = createApiFactory( + analyticsApiRef, + new NoOpAnalyticsApi(), + ); const plugin1RouteRef = createRouteRef({ id: 'ref-1' }); + const plugin1RouteRef2 = createRouteRef({ id: 'ref-1-2' }); const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] }); const subRouteRef1 = createSubRouteRef({ id: 'sub1', @@ -117,6 +127,7 @@ describe('Integration Test', () => { const HiddenComponent = plugin2.provide( createRoutableExtension({ + name: 'HiddenComponent', component: () => Promise.resolve((_: { path?: string }) =>
), mountPoint: plugin2RouteRef, }), @@ -124,6 +135,7 @@ describe('Integration Test', () => { const ExposedComponent = plugin1.provide( createRoutableExtension({ + name: 'ExposedComponent', component: () => Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { const link1 = useRouteRef(plugin1RouteRef); @@ -155,6 +167,16 @@ describe('Integration Test', () => { }), ); + const NavigateComponent = plugin1.provide( + createRoutableExtension({ + component: () => + Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { + return ; + }), + mountPoint: plugin1RouteRef2, + }), + ); + const components = { NotFoundErrorPage: () => null, BootErrorPage: () => null, @@ -166,7 +188,7 @@ describe('Integration Test', () => { it('runs happy paths', async () => { const app = new PrivateAppImpl({ - apis: [], + apis: [noOpAnalyticsApi], defaultApis: [], themes: [ { @@ -220,7 +242,7 @@ describe('Integration Test', () => { it('runs happy paths without optional routes', async () => { const app = new PrivateAppImpl({ - apis: [], + apis: [noOpAnalyticsApi], defaultApis: [], themes: [ { @@ -266,6 +288,7 @@ describe('Integration Test', () => { jest.spyOn(storageFlags, 'registerFlag'); const apis = [ + noOpAnalyticsApi, createApiFactory({ api: featureFlagsApiRef, deps: { configApi: configApiRef }, @@ -322,6 +345,68 @@ describe('Integration Test', () => { }); }); + it('should track route changes via analytics api', async () => { + const mockAnalyticsApi = new MockAnalyticsApi(); + const apis = [createApiFactory(analyticsApiRef, mockAnalyticsApi)]; + const app = new PrivateAppImpl({ + apis, + defaultApis: [], + themes: [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + ], + icons: defaultAppIcons, + plugins: [], + components, + bindRoutes: ({ bind }) => { + bind(plugin1.externalRoutes, { + extRouteRef1: plugin1RouteRef, + extRouteRef2: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + } /> + } /> + + + , + ); + + // Capture initial and subsequent navigation events with expected context. + const capturedEvents = mockAnalyticsApi.getEvents(); + expect(capturedEvents[0]).toMatchObject({ + action: 'navigate', + subject: '/', + context: { + extension: 'App', + pluginId: 'blob', + routeRef: 'ref-1-2', + }, + }); + expect(capturedEvents[1]).toMatchObject({ + action: 'navigate', + subject: '/foo', + context: { + extension: 'App', + pluginId: 'plugin2', + routeRef: 'ref-2', + }, + }); + expect(capturedEvents).toHaveLength(2); + }); + it('should throw some error when the route has duplicate params', () => { const app = new PrivateAppImpl({ apis: [], diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx index 7857c8b35f..8cb3f167fb 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/App.tsx @@ -63,6 +63,7 @@ import { routePathCollector, } from '../routing/collectors'; import { RoutingProvider } from '../routing/RoutingProvider'; +import { RouteTracker } from '../routing/RouteTracker'; import { validateRoutes } from '../routing/validation'; import { AppContextProvider } from './AppContext'; import { AppIdentity } from './AppIdentity'; @@ -198,6 +199,7 @@ export class PrivateAppImpl implements BackstageApp { private readonly bindRoutes: AppOptions['bindRoutes']; private readonly identityApi = new AppIdentity(); + private readonly apiFactoryRegistry: ApiFactoryRegistry; constructor(options: FullAppOptions) { this.apis = options.apis; @@ -208,6 +210,7 @@ export class PrivateAppImpl implements BackstageApp { this.configLoader = options.configLoader; this.defaultApis = options.defaultApis; this.bindRoutes = options.bindRoutes; + this.apiFactoryRegistry = new ApiFactoryRegistry(); } getPlugins(): BackstagePlugin[] { @@ -365,6 +368,7 @@ export class PrivateAppImpl implements BackstageApp { return ( + {children}} /> @@ -374,6 +378,7 @@ export class PrivateAppImpl implements BackstageApp { return ( + {children}} /> @@ -388,17 +393,27 @@ export class PrivateAppImpl implements BackstageApp { private getApiHolder(): ApiHolder { if (this.apiHolder) { + // Register additional plugins if they have been added. + // Routes paths, objects and others are already updated in the provider when children of it change + for (const plugin of this.plugins) { + for (const factory of plugin.getApis()) { + if (!this.apiFactoryRegistry.get(factory.api)) { + this.apiFactoryRegistry.register('default', factory); + } + } + } + ApiResolver.validateFactories( + this.apiFactoryRegistry, + this.apiFactoryRegistry.getAllApis(), + ); return this.apiHolder; } - - const registry = new ApiFactoryRegistry(); - - registry.register('static', { + this.apiFactoryRegistry.register('static', { api: appThemeApiRef, deps: {}, factory: () => AppThemeSelector.createWithStorage(this.themes), }); - registry.register('static', { + this.apiFactoryRegistry.register('static', { api: configApiRef, deps: {}, factory: () => { @@ -410,7 +425,7 @@ export class PrivateAppImpl implements BackstageApp { return this.configApi; }, }); - registry.register('static', { + this.apiFactoryRegistry.register('static', { api: identityApiRef, deps: {}, factory: () => this.identityApi, @@ -418,18 +433,18 @@ export class PrivateAppImpl implements BackstageApp { // It's possible to replace the feature flag API, but since we must have at least // one implementation we add it here directly instead of through the defaultApis. - registry.register('default', { + this.apiFactoryRegistry.register('default', { api: featureFlagsApiRef, deps: {}, factory: () => new LocalStorageFeatureFlags(), }); for (const factory of this.defaultApis) { - registry.register('default', factory); + this.apiFactoryRegistry.register('default', factory); } for (const plugin of this.plugins) { for (const factory of plugin.getApis()) { - if (!registry.register('default', factory)) { + if (!this.apiFactoryRegistry.register('default', factory)) { throw new Error( `Plugin ${plugin.getId()} tried to register duplicate or forbidden API factory for ${ factory.api @@ -440,17 +455,19 @@ export class PrivateAppImpl implements BackstageApp { } for (const factory of this.apis) { - if (!registry.register('app', factory)) { + if (!this.apiFactoryRegistry.register('app', factory)) { throw new Error( `Duplicate or forbidden API factory for ${factory.api} in app`, ); } } - ApiResolver.validateFactories(registry, registry.getAllApis()); - - this.apiHolder = new ApiResolver(registry); + ApiResolver.validateFactories( + this.apiFactoryRegistry, + this.apiFactoryRegistry.getAllApis(), + ); + this.apiHolder = new ApiResolver(this.apiFactoryRegistry); return this.apiHolder; } diff --git a/packages/core-app-api/src/app/defaultApis.ts b/packages/core-app-api/src/app/defaultApis.ts index 2322f72e8d..5e850f4cce 100644 --- a/packages/core-app-api/src/app/defaultApis.ts +++ b/packages/core-app-api/src/app/defaultApis.ts @@ -16,6 +16,7 @@ import { AlertApiForwarder, + NoOpAnalyticsApi, ErrorApiForwarder, ErrorAlerter, GoogleAuth, @@ -25,6 +26,7 @@ import { GitlabAuth, Auth0Auth, MicrosoftAuth, + BitbucketAuth, OAuthRequestManager, WebStorage, UrlPatternDiscovery, @@ -36,6 +38,7 @@ import { import { createApiFactory, alertApiRef, + analyticsApiRef, errorApiRef, discoveryApiRef, oauthRequestApiRef, @@ -51,6 +54,7 @@ import { samlAuthApiRef, oneloginAuthApiRef, oidcAuthApiRef, + bitbucketAuthApiRef, } from '@backstage/core-plugin-api'; import OAuth2Icon from '@material-ui/icons/AcUnit'; @@ -65,6 +69,7 @@ export const defaultApis = [ ), }), createApiFactory(alertApiRef, new AlertApiForwarder()), + createApiFactory(analyticsApiRef, new NoOpAnalyticsApi()), createApiFactory({ api: errorApiRef, deps: { alertApi: alertApiRef }, @@ -224,4 +229,19 @@ export const defaultApis = [ environment: configApi.getOptionalString('auth.environment'), }), }), + createApiFactory({ + api: bitbucketAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + BitbucketAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['team'], + environment: configApi.getOptionalString('auth.environment'), + }), + }), ]; diff --git a/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts index 26f489a1b4..49e9bf501f 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts @@ -83,7 +83,7 @@ describe('StaticAuthSessionManager', () => { expect(createSession).toHaveBeenCalledTimes(2); }); - it('should remove session and reload', async () => { + it('should clear the local session', async () => { const removeSession = jest.fn(); const manager = new StaticAuthSessionManager({ connector: { removeSession }, @@ -91,7 +91,17 @@ describe('StaticAuthSessionManager', () => { } as any); await manager.removeSession(); - expect(removeSession).toHaveBeenCalled(); expect(await manager.getSession({ optional: true })).toBe(undefined); }); + + it('should not remove the session via the connector', async () => { + const removeSession = jest.fn(); + const manager = new StaticAuthSessionManager({ + connector: { removeSession }, + ...defaultOptions, + } as any); + + await manager.removeSession(); + expect(removeSession).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts index b7940d88c4..fd02e4b2df 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -71,9 +71,14 @@ export class StaticAuthSessionManager implements MutableSessionManager { return this.currentSession; } + /** + * We don't call this.connector.removeSession here, since this session manager + * is intended to be static. As such there's no need to hit the remote logout + * endpoint - simply discarding the local session state when signing out is + * enough. + */ async removeSession() { this.currentSession = undefined; - await this.connector.removeSession(); this.stateTracker.setIsSignedIn(false); } diff --git a/packages/core-app-api/src/plugins/collectors.test.tsx b/packages/core-app-api/src/plugins/collectors.test.tsx index acbe337e44..056652423e 100644 --- a/packages/core-app-api/src/plugins/collectors.test.tsx +++ b/packages/core-app-api/src/plugins/collectors.test.tsx @@ -43,24 +43,35 @@ const ref2 = createRouteRef(mockConfig()); const Extension1 = pluginA.provide( createRoutableExtension({ + name: 'Extension1', component: () => Promise.resolve(MockComponent), mountPoint: ref1, }), ); const Extension2 = pluginB.provide( createRoutableExtension({ + name: 'Extension2', component: () => Promise.resolve(MockComponent), mountPoint: ref2, }), ); const Extension3 = pluginA.provide( - createComponentExtension({ component: { sync: MockComponent } }), + createComponentExtension({ + name: 'Extension3', + component: { sync: MockComponent }, + }), ); const Extension4 = pluginB.provide( - createComponentExtension({ component: { sync: MockComponent } }), + createComponentExtension({ + name: 'Extension4', + component: { sync: MockComponent }, + }), ); const Extension5 = pluginC.provide( - createComponentExtension({ component: { sync: MockComponent } }), + createComponentExtension({ + name: 'Extension5', + component: { sync: MockComponent }, + }), ); describe('collection', () => { diff --git a/packages/core-app-api/src/routing/RouteTracker.tsx b/packages/core-app-api/src/routing/RouteTracker.tsx new file mode 100644 index 0000000000..b745dd9235 --- /dev/null +++ b/packages/core-app-api/src/routing/RouteTracker.tsx @@ -0,0 +1,119 @@ +/* + * 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, { useEffect, useMemo } from 'react'; +import { matchRoutes, useLocation } from 'react-router-dom'; +import { + useAnalytics, + AnalyticsContext, + CommonAnalyticsContext, + RouteRef, +} from '@backstage/core-plugin-api'; +import { routeObjectCollector } from './collectors'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { BackstageRouteObject } from './types'; + +/** + * Returns an extension context given the current pathname and a list of + * Backstage route objects. + */ +const getExtensionContext = ( + pathname: string, + routes: BackstageRouteObject[], +): CommonAnalyticsContext | {} => { + try { + // Find matching routes for the given path name. + const matches = matchRoutes(routes, { pathname }) as + | { route: BackstageRouteObject }[] + | null; + + // Of the matching routes, get the last (e.g. most specific) instance of + // the BackstageRouteObject. + const routeObject = matches + ?.filter(match => match?.route.routeRefs?.size > 0) + .pop()?.route; + + // If there is no route object, then allow inheritance of default context. + if (!routeObject) { + return {}; + } + + // If there is a single route ref, return it. + // todo: get routeRef of rendered gathered mount point(?) + let routeRef: RouteRef | undefined; + if (routeObject.routeRefs.size === 1) { + routeRef = routeObject.routeRefs.values().next().value; + } + + return { + extension: 'App', + pluginId: routeObject.plugin?.getId() || 'root', + ...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}), + }; + } catch { + return {}; + } +}; + +/** + * Performs the actual event capture on render. + */ +const TrackNavigation = ({ + pathname, + search, + hash, +}: { + pathname: string; + search: string; + hash: string; +}) => { + const analytics = useAnalytics(); + + useEffect(() => { + analytics.captureEvent('navigate', `${pathname}${search}${hash}`); + }, [analytics, pathname, search, hash]); + + return null; +}; + +/** + * Logs a "navigate" event with appropriate plugin-level analytics context + * attributes each time the user navigates to a page. + */ +export const RouteTracker = ({ tree }: { tree: React.ReactNode }) => { + const { pathname, search, hash } = useLocation(); + // todo(iamEAP): Work this into the existing traversal and make the data + // available on the provider. Then grab from app instance on the router. + const { routeObjects } = useMemo(() => { + return traverseElementTree({ + root: tree, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routeObjects: routeObjectCollector, + }, + }); + }, [tree]); + + return ( + + + + ); +}; diff --git a/packages/core-app-api/src/routing/RoutingProvider.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.test.tsx index 397217e34a..12d9e9ee33 100644 --- a/packages/core-app-api/src/routing/RoutingProvider.test.tsx +++ b/packages/core-app-api/src/routing/RoutingProvider.test.tsx @@ -91,30 +91,35 @@ const MockRouteSource = (props: { const Extension1 = plugin.provide( createRoutableExtension({ + name: 'Extension1', component: () => Promise.resolve(MockComponent), mountPoint: ref1, }), ); const Extension2 = plugin.provide( createRoutableExtension({ + name: 'Extension2', component: () => Promise.resolve(MockRouteSource), mountPoint: ref2, }), ); const Extension3 = plugin.provide( createRoutableExtension({ + name: 'Extension3', component: () => Promise.resolve(MockComponent), mountPoint: ref3, }), ); const Extension4 = plugin.provide( createRoutableExtension({ + name: 'Extension4', component: () => Promise.resolve(MockRouteSource), mountPoint: ref4, }), ); const Extension5 = plugin.provide( createRoutableExtension({ + name: 'Extension5', component: () => Promise.resolve(MockComponent), mountPoint: ref5, }), diff --git a/packages/core-app-api/src/routing/collectors.test.tsx b/packages/core-app-api/src/routing/collectors.test.tsx index 36d99d2938..5fb1795f4c 100644 --- a/packages/core-app-api/src/routing/collectors.test.tsx +++ b/packages/core-app-api/src/routing/collectors.test.tsx @@ -32,6 +32,7 @@ import { createPlugin, RouteRef, attachComponentData, + BackstagePlugin, } from '@backstage/core-plugin-api'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; @@ -50,30 +51,35 @@ const refOrder = [ref1, ref2, ref3, ref4, ref5]; const Extension1 = plugin.provide( createRoutableExtension({ + name: 'Extension1', component: () => Promise.resolve(MockComponent), mountPoint: ref1, }), ); const Extension2 = plugin.provide( createRoutableExtension({ + name: 'Extension2', component: () => Promise.resolve(MockComponent), mountPoint: ref2, }), ); const Extension3 = plugin.provide( createRoutableExtension({ + name: 'Extension3', component: () => Promise.resolve(MockComponent), mountPoint: ref3, }), ); const Extension4 = plugin.provide( createRoutableExtension({ + name: 'Extension4', component: () => Promise.resolve(MockComponent), mountPoint: ref4, }), ); const Extension5 = plugin.provide( createRoutableExtension({ + name: 'Extension5', component: () => Promise.resolve(MockComponent), mountPoint: ref5, }), @@ -98,6 +104,7 @@ function routeObj( refs: RouteRef[], children: any[] = [], type: 'mounted' | 'gathered' = 'mounted', + backstagePlugin?: BackstagePlugin, ) { return { path: path, @@ -113,6 +120,7 @@ function routeObj( }, ...children, ], + plugin: backstagePlugin, }; } @@ -186,7 +194,7 @@ describe('discovery', () => { routeObj('/blop', [ref5]), ], ), - routeObj('/divsoup', [ref4]), + routeObj('/divsoup', [ref4], undefined, undefined, plugin), ]); }); diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx index 0ba83e90f8..320f1a66d8 100644 --- a/packages/core-app-api/src/routing/collectors.tsx +++ b/packages/core-app-api/src/routing/collectors.tsx @@ -15,7 +15,11 @@ */ import { isValidElement, ReactElement, ReactNode } from 'react'; -import { RouteRef, getComponentData } from '@backstage/core-plugin-api'; +import { + RouteRef, + getComponentData, + BackstagePlugin, +} from '@backstage/core-plugin-api'; import { BackstageRouteObject } from './types'; import { createCollector } from '../extensions/traversal'; import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged'; @@ -141,6 +145,10 @@ export const routeObjectCollector = createCollector( element: 'mounted', routeRefs: new Set([routeRef]), children: [MATCH_ALL_ROUTE], + plugin: getComponentData( + node.props.element, + 'core.plugin', + ), }; parentChildren.push(newObject); return newObject; @@ -163,6 +171,7 @@ export const routeObjectCollector = createCollector( element: 'gathered', routeRefs: new Set(), children: [MATCH_ALL_ROUTE], + plugin: parentObj?.plugin, }; parentChildren.push(newObject); return newObject; diff --git a/packages/core-app-api/src/routing/types.ts b/packages/core-app-api/src/routing/types.ts index 12bf0d1a0f..e2cf665982 100644 --- a/packages/core-app-api/src/routing/types.ts +++ b/packages/core-app-api/src/routing/types.ts @@ -18,6 +18,7 @@ import { RouteRef, SubRouteRef, ExternalRouteRef, + BackstagePlugin, } from '@backstage/core-plugin-api'; import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; @@ -53,6 +54,7 @@ export interface BackstageRouteObject { element: React.ReactNode; path: string; routeRefs: Set; + plugin?: BackstagePlugin; } export function isRouteRef( diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md index 8028af8b96..bf3c84c4fd 100644 --- a/packages/core-components/CHANGELOG.md +++ b/packages/core-components/CHANGELOG.md @@ -1,5 +1,55 @@ # @backstage/core-components +## 0.6.1 + +### Patch Changes + +- f139fed1ac: The `` component now automatically instruments all link clicks using + the new Analytics API. Each click triggers a `click` event, containing the + text of the link the user clicked on, as well as the location to which the user + clicked. In addition, these events inherit plugin/extension-level metadata, + allowing clicks to be attributed to the plugin/extension/route containing the + link: + + ```json + { + "action": "click", + "subject": "Text content of the link that was clicked", + "attributes": { + "to": "/value/of-the/to-prop/passed-to-the-link" + }, + "context": { + "extension": "ExtensionInWhichTheLinkWasClicked", + "pluginId": "plugin-in-which-link-was-clicked", + "routeRef": "route-ref-in-which-the-link-was-clicked" + } + } + ``` + +- 666e1f478e: Provide a clearer error message when a authentication provider used by the `SignInPage` has not been configured to support sign-in. +- 63d426bfeb: Wrap up the `Link` component in a component to reset the color so that we can actually see the button text +- ca0559444c: Avoid usage of `.to*Case()`, preferring `.toLocale*Case('en-US')` instead. +- 162e1eee65: SignInPage: move the initial invocation of `login` away from the render method +- Updated dependencies + - @backstage/core-plugin-api@0.1.10 + +## 0.6.0 + +### Minor Changes + +- 21767b08ca: Checkbox tree filters are no longer available in the Table component: + + - Deleted the `CheckboxTree` component + - Removed the filter type `'checkbox-tree'` from the `TableFilter` types. + +### Patch Changes + +- 9c3cb8d4e2: Stop forcing `target="_blank"` in the `SupportButton` but instead use the default logic of the `Link` component, that opens external targets in a new window and relative targets in the same window. +- d21e39e303: Support `material-ui` overrides in Backstage internal components +- c4e77bb34a: Added documentation for exported symbols. +- Updated dependencies + - @backstage/core-plugin-api@0.1.9 + ## 0.5.0 ### Minor Changes diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 72ce416549..4a94135b4a 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -9,7 +9,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { BackstageTheme } from '@backstage/theme'; import { Breadcrumbs as Breadcrumbs_2 } from '@material-ui/core'; -import { ButtonProps } from '@material-ui/core'; +import { ButtonProps as ButtonProps_2 } from '@material-ui/core'; import { CardHeaderProps } from '@material-ui/core'; import { Column } from '@material-table/core'; import { ComponentClass } from 'react'; @@ -26,9 +26,9 @@ import { LinkProps as LinkProps_2 } from '@material-ui/core'; import { LinkProps as LinkProps_3 } from 'react-router-dom'; import { MaterialTableProps } from '@material-table/core'; import { NavLinkProps } from 'react-router-dom'; +import { Overrides } from '@material-ui/core/styles/overrides'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; import { PropsWithChildren } from 'react'; -import PropTypes from 'prop-types'; import { default as React_2 } from 'react'; import * as React_3 from 'react'; import { ReactElement } from 'react'; @@ -39,6 +39,7 @@ import { SparklinesLineProps } from 'react-sparklines'; import { SparklinesProps } from 'react-sparklines'; import { StyledComponentProps } from '@material-ui/core'; import { StyleRules } from '@material-ui/styles'; +import { StyleRules as StyleRules_2 } from '@material-ui/core/styles/withStyles'; import { TabProps } from '@material-ui/core'; import { TextTruncateProps } from 'react-text-truncate'; import { Theme } from '@material-ui/core'; @@ -47,7 +48,7 @@ import { WithStyles } from '@material-ui/core'; // Warning: (ae-missing-release-tag) "AlertDisplay" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export function AlertDisplay(_props: {}): JSX.Element | null; // Warning: (ae-missing-release-tag) "Alignment" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -64,17 +65,79 @@ enum Alignment { UP_RIGHT = 'UR', } -// Warning: (ae-forgotten-export) The symbol "AvatarProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Avatar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export function Avatar(props: AvatarProps): JSX.Element; +// Warning: (ae-missing-release-tag) "AvatarClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AvatarClassKey = 'avatar'; + +// Warning: (ae-missing-release-tag) "AvatarProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface AvatarProps { + customStyles?: CSSProperties; + displayName?: string; + picture?: string; +} + +// Warning: (ae-missing-release-tag) "BackstageContentClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BackstageContentClassKey = 'root' | 'stretch' | 'noPadding'; + +// Warning: (ae-forgotten-export) The symbol "BackstageComponentsNameToClassKey" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "BackstageOverrides" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BackstageOverrides = Overrides & { + [Name in keyof BackstageComponentsNameToClassKey]?: Partial< + StyleRules_2 + >; +}; + +// Warning: (ae-missing-release-tag) "BoldHeaderClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BoldHeaderClassKey = 'root' | 'title' | 'subheader'; + +// Warning: (ae-missing-release-tag) "BottomLink" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function BottomLink(props: BottomLinkProps): JSX.Element; + +// Warning: (ae-missing-release-tag) "BottomLinkClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BottomLinkClassKey = 'root' | 'boxTitle' | 'arrow'; + +// Warning: (ae-missing-release-tag) "BottomLinkProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BottomLinkProps = { + link: string; + title: string; + onClick?: (event: React_2.MouseEvent) => void; +}; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Breadcrumbs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Breadcrumbs(props: Props_24): JSX.Element; +export function Breadcrumbs(props: Props_21): JSX.Element; + +// Warning: (ae-missing-release-tag) "BreadcrumbsClickableTextClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BreadcrumbsClickableTextClassKey = 'root'; + +// Warning: (ae-missing-release-tag) "BreadcrumbsStyledBoxClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BreadcrumbsStyledBoxClassKey = 'root'; // Warning: (ae-forgotten-export) The symbol "IconComponentProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "BrokenImageIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -82,11 +145,20 @@ export function Breadcrumbs(props: Props_24): JSX.Element; // @public (undocumented) export function BrokenImageIcon(props: IconComponentProps): JSX.Element; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ButtonType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // +// @public +export function Button(props: ButtonProps): JSX.Element; + +// Warning: (ae-missing-release-tag) "ButtonProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type ButtonProps = ButtonProps_2 & Omit; + +// Warning: (ae-missing-release-tag) "CardActionsTopRightClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// // @public (undocumented) -export function Button(props: Props): JSX.Element; +export type CardActionsTopRightClassKey = 'root'; // Warning: (ae-forgotten-export) The symbol "CardTabProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "CardTab" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -94,6 +166,11 @@ export function Button(props: Props): JSX.Element; // @public (undocumented) export function CardTab(props: PropsWithChildren): JSX.Element; +// Warning: (ae-missing-release-tag) "CardTabClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CardTabClassKey = 'root' | 'selected'; + // Warning: (ae-missing-release-tag) "CatalogIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -104,17 +181,34 @@ export function CatalogIcon(props: IconComponentProps): JSX.Element; // @public (undocumented) export function ChatIcon(props: IconComponentProps): JSX.Element; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "CodeSnippet" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ClosedDropdownClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const CodeSnippet: (props: Props_2) => JSX.Element; +export type ClosedDropdownClassKey = 'icon'; + +// Warning: (ae-missing-release-tag) "CodeSnippet" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function CodeSnippet(props: CodeSnippetProps): JSX.Element; + +// Warning: (ae-missing-release-tag) "CodeSnippetProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface CodeSnippetProps { + customStyle?: any; + highlightedNumbers?: number[]; + // Warning: (tsdoc-reference-missing-identifier) Syntax error in declaration reference: expecting a member identifier + language: string; + showCopyCodeButton?: boolean; + showLineNumbers?: boolean; + text: string; +} // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Content" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Content(props: PropsWithChildren): JSX.Element; +export function Content(props: PropsWithChildren): JSX.Element; // Warning: (ae-forgotten-export) The symbol "ContentHeaderProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ContentHeader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -124,29 +218,47 @@ export function ContentHeader( props: PropsWithChildren, ): JSX.Element; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "CopyTextButton" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-missing-release-tag) "CopyTextButton" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "ContentHeaderClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function CopyTextButton(props: Props_3): JSX.Element; +export type ContentHeaderClassKey = + | 'container' + | 'leftItemsBox' + | 'rightItemsBox' + | 'description' + | 'title'; -// @public (undocumented) -export namespace CopyTextButton { - var // (undocumented) - propTypes: { - text: PropTypes.Validator; - tooltipDelay: PropTypes.Requireable; - tooltipText: PropTypes.Requireable; - }; +// Warning: (ae-missing-release-tag) "CopyTextButton" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function CopyTextButton(props: CopyTextButtonProps): JSX.Element; + +// Warning: (ae-missing-release-tag) "CopyTextButtonProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface CopyTextButtonProps { + text: string; + tooltipDelay?: number; + tooltipText?: string; } -// Warning: (ae-forgotten-export) The symbol "CreateButtonProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "CreateButton" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public export function CreateButton(props: CreateButtonProps): JSX.Element | null; +// Warning: (ae-missing-release-tag) "CreateButtonProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type CreateButtonProps = { + title: string; +} & Partial>; + +// Warning: (ae-missing-release-tag) "CustomProviderClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type CustomProviderClassKey = 'form' | 'button'; + // Warning: (ae-missing-release-tag) "DashboardIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -167,6 +279,26 @@ type DependencyEdge = T & { // @public (undocumented) export function DependencyGraph(props: DependencyGraphProps): JSX.Element; +// Warning: (ae-missing-release-tag) "DependencyGraphDefaultLabelClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type DependencyGraphDefaultLabelClassKey = 'text'; + +// Warning: (ae-missing-release-tag) "DependencyGraphDefaultNodeClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type DependencyGraphDefaultNodeClassKey = 'node' | 'text'; + +// Warning: (ae-missing-release-tag) "DependencyGraphEdgeClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type DependencyGraphEdgeClassKey = 'path' | 'label'; + +// Warning: (ae-missing-release-tag) "DependencyGraphNodeClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type DependencyGraphNodeClassKey = 'node'; + // Warning: (ae-missing-release-tag) "DependencyGraphProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -236,7 +368,19 @@ enum Direction { // Warning: (ae-missing-release-tag) "DismissableBanner" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const DismissableBanner: (props: Props_4) => JSX.Element; +export const DismissableBanner: (props: Props) => JSX.Element; + +// Warning: (ae-missing-release-tag) "DismissbleBannerClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type DismissbleBannerClassKey = + | 'root' + | 'topPosition' + | 'icon' + | 'content' + | 'message' + | 'info' + | 'error'; // Warning: (ae-missing-release-tag) "DocsIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -266,7 +410,17 @@ export function EmailIcon(props: IconComponentProps): JSX.Element; // Warning: (ae-missing-release-tag) "EmptyState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function EmptyState(props: Props_5): JSX.Element; +export function EmptyState(props: Props_2): JSX.Element; + +// Warning: (ae-missing-release-tag) "EmptyStateClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type EmptyStateClassKey = 'root' | 'action' | 'imageContainer'; + +// Warning: (ae-missing-release-tag) "EmptyStateImageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type EmptyStateImageClassKey = 'generalImg'; // Warning: (ae-forgotten-export) The symbol "State" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ErrorBoundary" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -288,6 +442,11 @@ export type ErrorBoundaryProps = { // @public (undocumented) export function ErrorPage(props: IErrorPageProps): JSX.Element; +// Warning: (ae-missing-release-tag) "ErrorPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ErrorPageClassKey = 'container' | 'title' | 'subtitle'; + // Warning: (ae-missing-release-tag) "ErrorPanel" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -295,6 +454,11 @@ export function ErrorPanel( props: PropsWithChildren, ): JSX.Element; +// Warning: (ae-missing-release-tag) "ErrorPanelClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ErrorPanelClassKey = 'text' | 'divider'; + // Warning: (ae-missing-release-tag) "ErrorPanelProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -304,25 +468,52 @@ export type ErrorPanelProps = { title?: string; }; +// Warning: (ae-missing-release-tag) "FeatureCalloutCircleClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type FeatureCalloutCircleClassKey = + | '@keyframes pulsateSlightly' + | '@keyframes pulsateAndFade' + | 'featureWrapper' + | 'backdrop' + | 'dot' + | 'pulseCircle' + | 'text'; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "FeatureCalloutCircular" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function FeatureCalloutCircular( - props: PropsWithChildren, + props: PropsWithChildren, ): JSX.Element; +// Warning: (ae-missing-release-tag) "FiltersContainerClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type FiltersContainerClassKey = 'root' | 'title'; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Gauge" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Gauge(props: Props_14): JSX.Element; +export function Gauge(props: Props_11): JSX.Element; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "GaugeCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function GaugeCard(props: Props_13): JSX.Element; +export function GaugeCard(props: Props_10): JSX.Element; + +// Warning: (ae-missing-release-tag) "GaugeCardClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type GaugeCardClassKey = 'root'; + +// Warning: (ae-missing-release-tag) "GaugeClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type GaugeClassKey = 'root' | 'overlay' | 'circle' | 'colorUnknown'; // Warning: (ae-missing-release-tag) "GitHubIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -350,13 +541,32 @@ export function GroupIcon(props: IconComponentProps): JSX.Element; // Warning: (ae-missing-release-tag) "Header" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Header(props: PropsWithChildren): JSX.Element; +export function Header(props: PropsWithChildren): JSX.Element; + +// Warning: (ae-missing-release-tag) "HeaderClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type HeaderClassKey = + | 'header' + | 'leftItemsBox' + | 'rightItemsBox' + | 'title' + | 'subtitle' + | 'type' + | 'breadcrumb' + | 'breadcrumbType' + | 'breadcrumbTitle'; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "HeaderIconLinkRow" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function HeaderIconLinkRow(props: Props_8): JSX.Element; +export function HeaderIconLinkRow(props: Props_5): JSX.Element; + +// Warning: (ae-missing-release-tag) "HeaderIconLinkRowClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type HeaderIconLinkRowClassKey = 'links'; // Warning: (ae-forgotten-export) The symbol "HeaderLabelProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "HeaderLabel" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -364,12 +574,26 @@ export function HeaderIconLinkRow(props: Props_8): JSX.Element; // @public (undocumented) export function HeaderLabel(props: HeaderLabelProps): JSX.Element; +// Warning: (ae-missing-release-tag) "HeaderLabelClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type HeaderLabelClassKey = 'root' | 'label' | 'value'; + // Warning: (ae-forgotten-export) The symbol "HeaderTabsProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "HeaderTabs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function HeaderTabs(props: HeaderTabsProps): JSX.Element; +// Warning: (ae-missing-release-tag) "HeaderTabsClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type HeaderTabsClassKey = + | 'tabsWrapper' + | 'defaultTab' + | 'selected' + | 'tabRoot'; + // Warning: (ae-missing-release-tag) "HelpIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -385,9 +609,33 @@ export function HomepageTimer(_props: {}): JSX.Element | null; // // @public (undocumented) export function HorizontalScrollGrid( - props: PropsWithChildren, + props: PropsWithChildren, ): JSX.Element; +// Warning: (ae-missing-release-tag) "HorizontalScrollGridClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type HorizontalScrollGridClassKey = + | 'root' + | 'container' + | 'fade' + | 'fadeLeft' + | 'fadeRight' + | 'fadeHidden' + | 'button' + | 'buttonLeft' + | 'buttonRight'; + +// Warning: (ae-missing-release-tag) "IconLinkVerticalClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type IconLinkVerticalClassKey = + | 'link' + | 'disabled' + | 'primary' + | 'secondary' + | 'label'; + // Warning: (ae-missing-release-tag) "IconLinkVerticalProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -405,7 +653,19 @@ export type IconLinkVerticalProps = { // Warning: (ae-missing-release-tag) "InfoCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function InfoCard(props: Props_19): JSX.Element; +export function InfoCard(props: Props_16): JSX.Element; + +// Warning: (ae-missing-release-tag) "InfoCardClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type InfoCardClassKey = + | 'noPadding' + | 'header' + | 'headerTitle' + | 'headerSubheader' + | 'headerAvatar' + | 'headerAction' + | 'headerContent'; // Warning: (ae-missing-release-tag) "InfoCardVariants" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -437,6 +697,11 @@ export function ItemCard(props: ItemCardProps): JSX.Element; // @public export function ItemCardGrid(props: ItemCardGridProps): JSX.Element; +// Warning: (ae-missing-release-tag) "ItemCardGridClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ItemCardGridClassKey = 'root'; + // Warning: (ae-forgotten-export) The symbol "styles" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ItemCardGridProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -456,6 +721,11 @@ export type ItemCardGridProps = Partial> & { // @public export function ItemCardHeader(props: ItemCardHeaderProps): JSX.Element; +// Warning: (ae-missing-release-tag) "ItemCardHeaderClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ItemCardHeaderClassKey = 'root'; + // Warning: (ae-forgotten-export) The symbol "styles" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ItemCardHeaderProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -482,13 +752,18 @@ enum LabelPosition { // Warning: (ae-missing-release-tag) "Lifecycle" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Lifecycle(props: Props_10): JSX.Element; +export function Lifecycle(props: Props_7): JSX.Element; + +// Warning: (ae-missing-release-tag) "LifecycleClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type LifecycleClassKey = 'alpha' | 'beta'; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "LinearGauge" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function LinearGauge(props: Props_15): JSX.Element | null; +export function LinearGauge(props: Props_12): JSX.Element | null; // Warning: (ae-missing-release-tag) "LinkType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -503,34 +778,98 @@ export type LinkProps = LinkProps_2 & component?: ElementType; }; +// Warning: (ae-missing-release-tag) "LoginRequestListItemClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type LoginRequestListItemClassKey = 'root'; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "MarkdownContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public -export function MarkdownContent(props: Props_11): JSX.Element; +export function MarkdownContent(props: Props_8): JSX.Element; + +// Warning: (ae-missing-release-tag) "MarkdownContentClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type MarkdownContentClassKey = 'markdown'; + +// Warning: (ae-missing-release-tag) "MetadataTableCellClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type MetadataTableCellClassKey = 'root'; + +// Warning: (ae-missing-release-tag) "MetadataTableListClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type MetadataTableListClassKey = 'root'; + +// Warning: (ae-missing-release-tag) "MetadataTableListItemClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type MetadataTableListItemClassKey = 'root' | 'random'; + +// Warning: (ae-missing-release-tag) "MetadataTableTitleCellClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type MetadataTableTitleCellClassKey = 'root'; + +// Warning: (ae-missing-release-tag) "MicDropClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type MicDropClassKey = 'micDrop'; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "MissingAnnotationEmptyState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function MissingAnnotationEmptyState(props: Props_6): JSX.Element; +export function MissingAnnotationEmptyState(props: Props_3): JSX.Element; + +// Warning: (ae-missing-release-tag) "MissingAnnotationEmptyStateClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type MissingAnnotationEmptyStateClassKey = 'code'; // Warning: (ae-missing-release-tag) "OAuthRequestDialog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function OAuthRequestDialog(_props: {}): JSX.Element; +// Warning: (ae-missing-release-tag) "OAuthRequestDialogClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type OAuthRequestDialogClassKey = + | 'dialog' + | 'title' + | 'contentList' + | 'actionButtons'; + +// Warning: (ae-missing-release-tag) "OpenedDropdownClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type OpenedDropdownClassKey = 'icon'; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "OverflowTooltip" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function OverflowTooltip(props: Props_12): JSX.Element; +export function OverflowTooltip(props: Props_9): JSX.Element; + +// Warning: (ae-missing-release-tag) "OverflowTooltipClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type OverflowTooltipClassKey = 'container'; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Page" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Page(props: PropsWithChildren): JSX.Element; +export function Page(props: PropsWithChildren): JSX.Element; + +// Warning: (ae-missing-release-tag) "PageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type PageClassKey = 'root'; // Warning: (ae-forgotten-export) The symbol "PageWithHeaderProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "PageWithHeader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -588,6 +927,11 @@ type RenderNodeProps = { // @public export function ResponseErrorPanel(props: ErrorPanelProps): JSX.Element; +// Warning: (ae-missing-release-tag) "ResponseErrorPanelClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ResponseErrorPanelClassKey = 'text' | 'divider'; + // Warning: (ae-missing-release-tag) "RoutedTabs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -599,11 +943,27 @@ export function RoutedTabs(props: { routes: SubRoute_2[] }): JSX.Element; // @public (undocumented) export function Select(props: SelectProps): JSX.Element; +// Warning: (ae-missing-release-tag) "SelectClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SelectClassKey = + | 'formControl' + | 'label' + | 'chips' + | 'chip' + | 'checkbox' + | 'root'; + +// Warning: (ae-missing-release-tag) "SelectInputBaseClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SelectInputBaseClassKey = 'root' | 'input'; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Sidebar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function Sidebar(props: PropsWithChildren): JSX.Element; +export function Sidebar(props: PropsWithChildren): JSX.Element; // Warning: (ae-missing-release-tag) "SIDEBAR_INTRO_LOCAL_STORAGE" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -611,6 +971,11 @@ export function Sidebar(props: PropsWithChildren): JSX.Element; export const SIDEBAR_INTRO_LOCAL_STORAGE = '@backstage/core/sidebar-intro-dismissed'; +// Warning: (ae-missing-release-tag) "SidebarClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen'; + // Warning: (ae-missing-release-tag) "sidebarConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -915,6 +1280,16 @@ export const SidebarDivider: React_2.ComponentType< // @public (undocumented) export function SidebarIntro(_props: {}): JSX.Element | null; +// Warning: (ae-missing-release-tag) "SidebarIntroClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SidebarIntroClassKey = + | 'introCard' + | 'introDismiss' + | 'introDismissLink' + | 'introDismissText' + | 'introDismissIcon'; + // Warning: (ae-forgotten-export) The symbol "SidebarItemProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "SidebarItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -923,11 +1298,33 @@ export const SidebarItem: React_2.ForwardRefExoticComponent< SidebarItemProps & React_2.RefAttributes >; +// Warning: (ae-missing-release-tag) "SidebarItemClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SidebarItemClassKey = + | 'root' + | 'buttonItem' + | 'closed' + | 'open' + | 'label' + | 'iconContainer' + | 'searchRoot' + | 'searchField' + | 'searchFieldHTMLInput' + | 'searchContainer' + | 'secondaryAction' + | 'selected'; + // Warning: (ae-missing-release-tag) "SidebarPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function SidebarPage(props: PropsWithChildren<{}>): JSX.Element; +// Warning: (ae-missing-release-tag) "SidebarPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SidebarPageClassKey = 'root'; + // Warning: (ae-missing-release-tag) "SidebarPinStateContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1758,7 +2155,12 @@ export const SidebarSpacer: React_2.ComponentType< // Warning: (ae-missing-release-tag) "SignInPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function SignInPage(props: Props_22): JSX.Element; +export function SignInPage(props: Props_19): JSX.Element; + +// Warning: (ae-missing-release-tag) "SignInPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SignInPageClassKey = 'container' | 'item'; // Warning: (ae-missing-release-tag) "SignInProviderConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1778,6 +2180,11 @@ export function SimpleStepper( props: PropsWithChildren, ): JSX.Element; +// Warning: (ae-missing-release-tag) "SimpleStepperFooterClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SimpleStepperFooterClassKey = 'root'; + // Warning: (ae-forgotten-export) The symbol "StepProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "SimpleStepperStep" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1786,11 +2193,28 @@ export function SimpleStepperStep( props: PropsWithChildren, ): JSX.Element; +// Warning: (ae-missing-release-tag) "SimpleStepperStepClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SimpleStepperStepClassKey = 'end'; + // Warning: (ae-missing-release-tag) "StatusAborted" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function StatusAborted(props: PropsWithChildren<{}>): JSX.Element; +// Warning: (ae-missing-release-tag) "StatusClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type StatusClassKey = + | 'status' + | 'ok' + | 'warning' + | 'error' + | 'pending' + | 'running' + | 'aborted'; + // Warning: (ae-missing-release-tag) "StatusError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1820,7 +2244,17 @@ export function StatusWarning(props: PropsWithChildren<{}>): JSX.Element; // Warning: (ae-missing-release-tag) "StructuredMetadataTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function StructuredMetadataTable(props: Props_16): JSX.Element; +export function StructuredMetadataTable(props: Props_13): JSX.Element; + +// Warning: (ae-missing-release-tag) "StructuredMetadataTableListClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type StructuredMetadataTableListClassKey = 'root'; + +// Warning: (ae-missing-release-tag) "StructuredMetadataTableNestedListClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type StructuredMetadataTableNestedListClassKey = 'root'; // Warning: (ae-forgotten-export) The symbol "SubvalueCellProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "SubvalueCell" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1828,12 +2262,22 @@ export function StructuredMetadataTable(props: Props_16): JSX.Element; // @public (undocumented) export function SubvalueCell(props: SubvalueCellProps): JSX.Element; +// Warning: (ae-missing-release-tag) "SubvalueCellClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SubvalueCellClassKey = 'value' | 'subvalue'; + // Warning: (ae-forgotten-export) The symbol "SupportButtonProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "SupportButton" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function SupportButton(props: SupportButtonProps): JSX.Element; +// Warning: (ae-missing-release-tag) "SupportButtonClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SupportButtonClassKey = 'popoverList'; + // Warning: (ae-missing-release-tag) "SupportConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1873,11 +2317,21 @@ export type Tab = { >; }; +// Warning: (ae-missing-release-tag) "TabBarClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TabBarClassKey = 'indicator' | 'flexContainer' | 'root'; + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "TabbedCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export function TabbedCard(props: PropsWithChildren): JSX.Element; +export function TabbedCard(props: PropsWithChildren): JSX.Element; + +// Warning: (ae-missing-release-tag) "TabbedCardClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TabbedCardClassKey = 'root' | 'indicator'; // Warning: (ae-missing-release-tag) "TabbedLayout" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // Warning: (ae-missing-release-tag) "TabbedLayout" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1893,11 +2347,21 @@ export namespace TabbedLayout { Route: (props: SubRoute) => null; } +// Warning: (ae-missing-release-tag) "TabIconClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TabIconClassKey = 'root'; + // Warning: (ae-missing-release-tag) "Table" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function Table(props: TableProps): JSX.Element; +// Warning: (ae-missing-release-tag) "TableClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TableClassKey = 'root'; + // Warning: (ae-missing-release-tag) "TableColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1913,9 +2377,19 @@ export interface TableColumn extends Column { // @public (undocumented) export type TableFilter = { column: string; - type: 'select' | 'multiple-select' | /** @deprecated */ 'checkbox-tree'; + type: 'select' | 'multiple-select'; }; +// Warning: (ae-missing-release-tag) "TableFiltersClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TableFiltersClassKey = 'root' | 'value' | 'heder' | 'filters'; + +// Warning: (ae-missing-release-tag) "TableHeaderClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TableHeaderClassKey = 'header'; + // Warning: (ae-missing-release-tag) "TableProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1944,12 +2418,22 @@ export type TableState = { filters?: SelectedFilters; }; +// Warning: (ae-missing-release-tag) "TableToolbarClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TableToolbarClassKey = 'root' | 'title' | 'searchField'; + // Warning: (ae-forgotten-export) The symbol "TabsProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Tabs" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export function Tabs(props: TabsProps): JSX.Element; +// Warning: (ae-missing-release-tag) "TabsClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TabsClassKey = 'root' | 'styledTabs' | 'appbar'; + // Warning: (ae-missing-release-tag) "TrendLine" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2002,9 +2486,19 @@ export function WarningIcon(props: IconComponentProps): JSX.Element; // @public export function WarningPanel(props: WarningProps): JSX.Element; +// Warning: (ae-missing-release-tag) "WarningPanelClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type WarningPanelClassKey = + | 'panel' + | 'summary' + | 'summaryText' + | 'message' + | 'details'; + // Warnings were encountered during analysis: // // src/components/TabbedLayout/RoutedTabs.d.ts:9:5 - (ae-forgotten-export) The symbol "SubRoute" needs to be exported by the entry point index.d.ts -// src/components/Table/Table.d.ts:15:5 - (ae-forgotten-export) The symbol "SelectedFilters" needs to be exported by the entry point index.d.ts +// src/components/Table/Table.d.ts:19:5 - (ae-forgotten-export) The symbol "SelectedFilters" needs to be exported by the entry point index.d.ts // src/layout/ErrorBoundary/ErrorBoundary.d.ts:7:5 - (ae-forgotten-export) The symbol "SlackChannel" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 48f8dc116b..fb3d564061 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-components", "description": "Core components used by Backstage plugins and apps", - "version": "0.5.0", + "version": "0.6.1", "private": false, "publishConfig": { "access": "public", @@ -30,16 +30,14 @@ }, "dependencies": { "@backstage/config": "^0.1.10", - "@backstage/core-plugin-api": "^0.1.8", + "@backstage/core-plugin-api": "^0.1.10", "@backstage/errors": "^0.1.2", "@backstage/theme": "^0.2.10", "@material-table/core": "^3.1.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "@testing-library/react-hooks": "^3.4.2", "@types/dagre": "^0.7.44", - "@types/prop-types": "^15.7.3", "@types/react": "*", "@types/react-sparklines": "^1.7.0", "@types/react-text-truncate": "^0.14.0", @@ -70,11 +68,12 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/core-app-api": "^0.1.14", - "@backstage/cli": "^0.7.13", - "@backstage/test-utils": "^0.1.17", + "@backstage/core-app-api": "^0.1.16", + "@backstage/cli": "^0.7.15", + "@backstage/test-utils": "^0.1.18", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.1.8", "@types/classnames": "^2.2.9", "@types/d3-selection": "^2.0.0", diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx index f3e102d9a0..f43dbd08dd 100644 --- a/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx +++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx @@ -21,6 +21,13 @@ import { Alert } from '@material-ui/lab'; import { AlertMessage, useApi, alertApiRef } from '@backstage/core-plugin-api'; import pluralize from 'pluralize'; +/** + * Displays alerts from {@link @backstage/core-plugin-api#AlertApi} + * + * @remarks + * + * Shown as SnackBar at the top of the page + */ // TODO: improve on this and promote to a shared component for use by all apps. export function AlertDisplay(_props: {}) { const [messages, setMessages] = useState>([]); diff --git a/packages/core-components/src/components/Avatar/Avatar.tsx b/packages/core-components/src/components/Avatar/Avatar.tsx index f694365233..0156c58b92 100644 --- a/packages/core-components/src/components/Avatar/Avatar.tsx +++ b/packages/core-components/src/components/Avatar/Avatar.tsx @@ -22,25 +22,48 @@ import { } from '@material-ui/core'; import { extractInitials, stringToColor } from './utils'; -const useStyles = makeStyles((theme: Theme) => - createStyles({ - avatar: { - width: '4rem', - height: '4rem', - color: '#fff', - fontWeight: theme.typography.fontWeightBold, - letterSpacing: '1px', - textTransform: 'uppercase', - }, - }), +export type AvatarClassKey = 'avatar'; + +const useStyles = makeStyles( + (theme: Theme) => + createStyles({ + avatar: { + width: '4rem', + height: '4rem', + color: '#fff', + fontWeight: theme.typography.fontWeightBold, + letterSpacing: '1px', + textTransform: 'uppercase', + }, + }), + { name: 'BackstageAvatar' }, ); -export type AvatarProps = { +/** + * Properties for {@link Avatar} + */ +export interface AvatarProps { + /** + * A display name, which will be used to generate initials as a fallback in case a picture is not provided. + */ displayName?: string; + /** + * URL to avatar image source + */ picture?: string; + /** + * Custom styles applied to avatar + */ customStyles?: CSSProperties; -}; +} +/** + * Component rendering an Avatar + * + * @remarks + * + * Based on https://v4.mui.com/components/avatars/#avatar with some styling adjustment and two-letter initials + */ export function Avatar(props: AvatarProps) { const { displayName, picture, customStyles } = props; const classes = useStyles(); diff --git a/packages/core-components/src/components/Avatar/index.ts b/packages/core-components/src/components/Avatar/index.ts index a58b47eba9..f1ef69d401 100644 --- a/packages/core-components/src/components/Avatar/index.ts +++ b/packages/core-components/src/components/Avatar/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { Avatar } from './Avatar'; +export type { AvatarClassKey, AvatarProps } from './Avatar'; diff --git a/packages/core-components/src/components/Button/Button.tsx b/packages/core-components/src/components/Button/Button.tsx index e52285312b..f2fc9f93f9 100644 --- a/packages/core-components/src/components/Button/Button.tsx +++ b/packages/core-components/src/components/Button/Button.tsx @@ -21,17 +21,33 @@ import { import React from 'react'; import { Link, LinkProps } from '../Link'; -type Props = MaterialButtonProps & Omit; - -declare function ButtonType(props: Props): JSX.Element; +/** + * Properties for {@link Button} + * + * @remarks + * + * See {@link https://v4.mui.com/api/button/#props | Material-UI Button Props} for all properties + */ +export type ButtonProps = MaterialButtonProps & + Omit; /** - * Thin wrapper on top of material-ui's Button component + * Thin wrapper on top of material-ui's {@link https://v4.mui.com/components/buttons/ | Button} component + * + * @remarks + * * Makes the Button to utilise react-router */ -const ActualButton = React.forwardRef((props, ref) => ( - -)) as { (props: Props): JSX.Element }; +declare function ButtonType(props: ButtonProps): JSX.Element; + +/** + * This wrapper is here to reset the color of the Link and make typescript happy. + */ +const LinkWrapper = (props: LinkProps) => ; + +const ActualButton = React.forwardRef((props, ref) => ( + +)) as { (props: ButtonProps): JSX.Element }; // TODO(Rugvip): We use this as a workaround to make the exported type be a // function, which makes our API reference docs much nicer. diff --git a/packages/core-components/src/components/Button/index.ts b/packages/core-components/src/components/Button/index.ts index e2aa3aff98..b3dc40e6e7 100644 --- a/packages/core-components/src/components/Button/index.ts +++ b/packages/core-components/src/components/Button/index.ts @@ -13,5 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - export { Button } from './Button'; +export type { ButtonProps } from './Button'; diff --git a/packages/core-components/src/components/CheckboxTree/CheckboxTree.stories.tsx b/packages/core-components/src/components/CheckboxTree/CheckboxTree.stories.tsx deleted file mode 100644 index 48da8b92b2..0000000000 --- a/packages/core-components/src/components/CheckboxTree/CheckboxTree.stories.tsx +++ /dev/null @@ -1,105 +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 React, { useState } from 'react'; -import { CheckboxTree } from './CheckboxTree'; - -const CHECKBOX_TREE_ITEMS = [ - { - label: 'Generic subcategory name 1', - options: [ - { - label: 'Option 1', - value: 1, - }, - { - label: 'Option 2', - value: 2, - }, - ], - }, - { - label: 'Generic subcategory name 2', - options: [ - { - label: 'Option 1', - value: 1, - }, - { - label: 'Option 2', - value: 2, - }, - ], - }, - { - label: 'Generic subcategory name 3', - options: [ - { - label: 'Option 1', - value: 1, - }, - { - label: 'Option 2', - value: 2, - }, - ], - }, -]; - -export default { - title: 'Inputs/CheckboxTree', - component: CheckboxTree, -}; - -export const Default = () => ( - {}} - label="default" - subCategories={CHECKBOX_TREE_ITEMS} - /> -); - -export const DynamicTree = () => { - function generateTree(showMore: boolean = false) { - const t = [ - { - label: 'Show more', - options: [], - }, - ]; - - if (showMore) { - t.push({ - label: 'More', - options: [], - }); - } - - return t; - } - - const [tree, setTree] = useState(generateTree()); - - return ( - { - setTree(generateTree(state.some(c => c.category === 'Show more'))); - }} - label="default" - subCategories={tree} - /> - ); -}; diff --git a/packages/core-components/src/components/CheckboxTree/CheckboxTree.test.tsx b/packages/core-components/src/components/CheckboxTree/CheckboxTree.test.tsx deleted file mode 100644 index 6c84a311d2..0000000000 --- a/packages/core-components/src/components/CheckboxTree/CheckboxTree.test.tsx +++ /dev/null @@ -1,59 +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 { fireEvent, render } from '@testing-library/react'; -import React from 'react'; -import { CheckboxTree } from './CheckboxTree'; - -const CHECKBOX_TREE_ITEMS = [ - { - label: 'Generic subcategory name 1', - options: [ - { - label: 'Option 1', - value: 1, - }, - { - label: 'Option 2', - value: 2, - }, - ], - }, -]; - -const minProps = { - onChange: jest.fn(), - label: 'Default', - subCategories: CHECKBOX_TREE_ITEMS, -}; - -describe('', () => { - it('renders without exploding', async () => { - const { getByText, getByTestId } = render(); - - expect(getByText('Generic subcategory name 1')).toBeInTheDocument(); - const checkbox = await getByTestId('expandable'); - - // Simulate click on expandable arrow - fireEvent.click(checkbox); - - // Simulate click on option - const option = getByText('Option 1'); - expect(getByText('Option 1')).toBeInTheDocument(); - fireEvent.click(option); - expect(minProps.onChange).toHaveBeenCalled(); - }); -}); diff --git a/packages/core-components/src/components/CheckboxTree/CheckboxTree.tsx b/packages/core-components/src/components/CheckboxTree/CheckboxTree.tsx deleted file mode 100644 index 71b5cf7659..0000000000 --- a/packages/core-components/src/components/CheckboxTree/CheckboxTree.tsx +++ /dev/null @@ -1,357 +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. - */ -/* eslint-disable guard-for-in */ -import { - Checkbox, - Collapse, - List, - ListItem, - ListItemIcon, - ListItemText, - Typography, -} from '@material-ui/core'; -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import ExpandLess from '@material-ui/icons/ExpandLess'; -import ExpandMore from '@material-ui/icons/ExpandMore'; -import produce from 'immer'; -import { isEqual } from 'lodash'; -import React, { useEffect, useReducer } from 'react'; -import { usePrevious } from 'react-use'; - -type IndexedObject = { - [key: string]: T; -}; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - root: { - width: '100%', - minWidth: 10, - maxWidth: 360, - backgroundColor: 'transparent', - '&:hover': { - backgroundColor: 'transparent', - }, - '&:active': { - animation: 'none', - transform: 'none', - }, - }, - nested: { - paddingLeft: theme.spacing(5), - height: '32px', - '&:hover': { - backgroundColor: 'transparent', - }, - }, - listItemIcon: { - minWidth: 10, - }, - listItem: { - '&:hover': { - backgroundColor: 'transparent', - }, - }, - text: { - '& span, & svg': { - fontWeight: 'normal', - fontSize: 14, - }, - }, - }), -); - -/* SUB_CATEGORY */ - -type SubCategory = { - label: string; - isChecked?: boolean; - isOpen?: boolean; - options?: Option[]; -}; - -type SubCategoryWithIndexedOptions = { - label: string; - isChecked?: boolean; - isOpen?: boolean; - options: IndexedObject
); -}; +} diff --git a/packages/core-components/src/components/CodeSnippet/index.tsx b/packages/core-components/src/components/CodeSnippet/index.tsx index bafcf145e9..10ad489007 100644 --- a/packages/core-components/src/components/CodeSnippet/index.tsx +++ b/packages/core-components/src/components/CodeSnippet/index.tsx @@ -15,3 +15,4 @@ */ export { CodeSnippet } from './CodeSnippet'; +export type { CodeSnippetProps } from './CodeSnippet'; diff --git a/packages/core-components/src/components/CopyTextButton/CopyTextButton.tsx b/packages/core-components/src/components/CopyTextButton/CopyTextButton.tsx index 2d3afff1dc..2d2fb4006a 100644 --- a/packages/core-components/src/components/CopyTextButton/CopyTextButton.tsx +++ b/packages/core-components/src/components/CopyTextButton/CopyTextButton.tsx @@ -17,41 +17,55 @@ import { errorApiRef, useApi } from '@backstage/core-plugin-api'; import { IconButton, Tooltip } from '@material-ui/core'; import CopyIcon from '@material-ui/icons/FileCopy'; -import PropTypes from 'prop-types'; import React, { MouseEventHandler, useEffect, useState } from 'react'; import { useCopyToClipboard } from 'react-use'; /** - * Copy text button with visual feedback in the form of + * Properties for {@link CopyTextButton} + */ +export interface CopyTextButtonProps { + /** + * The text to be copied + */ + text: string; + /** + * Number of milliseconds that the tooltip is shown + * + * @remarks + * + * Default: 1000 + */ + tooltipDelay?: number; + /** + * Text to show in the tooltip when user has clicked the button + * + * @remarks + * + * Default: "Text copied to clipboard" + */ + tooltipText?: string; +} + +/** + * Copy text button with visual feedback + * + * @remarks + * + * Visual feedback takes form of: * - a hover color * - click ripple * - Tooltip shown when user has clicked * - * Properties: - * - text: the text to be copied - * - tooltipDelay: Number os ms to show the tooltip, default: 1000ms - * - tooltipText: Text to show in the tooltip when user has clicked the button, default: "Text - * copied to clipboard" + * @example * - * Example: - * + * `` */ -type Props = { - text: string; - tooltipDelay?: number; - tooltipText?: string; -}; - -const defaultProps = { - tooltipDelay: 1000, - tooltipText: 'Text copied to clipboard', -}; - -export function CopyTextButton(props: Props) { - const { text, tooltipDelay, tooltipText } = { - ...defaultProps, - ...props, - }; +export function CopyTextButton(props: CopyTextButtonProps) { + const { + text, + tooltipDelay = 1000, + tooltipText = 'Text copied to clipboard', + } = props; const errorApi = useApi(errorApiRef); const [open, setOpen] = useState(false); const [{ error }, copyToClipboard] = useCopyToClipboard(); @@ -85,10 +99,3 @@ export function CopyTextButton(props: Props) { ); } - -// Type check for the JS files using this core component -CopyTextButton.propTypes = { - text: PropTypes.string.isRequired, - tooltipDelay: PropTypes.number, - tooltipText: PropTypes.string, -}; diff --git a/packages/core-components/src/components/CopyTextButton/index.tsx b/packages/core-components/src/components/CopyTextButton/index.tsx index a90975fa77..9c8ced5035 100644 --- a/packages/core-components/src/components/CopyTextButton/index.tsx +++ b/packages/core-components/src/components/CopyTextButton/index.tsx @@ -15,3 +15,4 @@ */ export { CopyTextButton } from './CopyTextButton'; +export type { CopyTextButtonProps } from './CopyTextButton'; diff --git a/packages/core-components/src/components/CreateButton/CreateButton.tsx b/packages/core-components/src/components/CreateButton/CreateButton.tsx index 1ca4fdaa97..c6b9b7abed 100644 --- a/packages/core-components/src/components/CreateButton/CreateButton.tsx +++ b/packages/core-components/src/components/CreateButton/CreateButton.tsx @@ -20,10 +20,16 @@ import React from 'react'; import { Link as RouterLink, LinkProps } from 'react-router-dom'; import AddCircleOutline from '@material-ui/icons/AddCircleOutline'; -type CreateButtonProps = { +/** + * Properties for {@link CreateButton} + */ +export type CreateButtonProps = { title: string; } & Partial>; +/** + * Responsive Button giving consistent UX for creation of different things + */ export function CreateButton(props: CreateButtonProps) { const { title, to } = props; const isXSScreen = useMediaQuery(theme => diff --git a/packages/core-components/src/components/CreateButton/index.ts b/packages/core-components/src/components/CreateButton/index.ts index f0e4cbe646..270ce063a4 100644 --- a/packages/core-components/src/components/CreateButton/index.ts +++ b/packages/core-components/src/components/CreateButton/index.ts @@ -15,3 +15,4 @@ */ export { CreateButton } from './CreateButton'; +export type { CreateButtonProps } from './CreateButton'; diff --git a/packages/core-components/src/components/DependencyGraph/DefaultLabel.tsx b/packages/core-components/src/components/DependencyGraph/DefaultLabel.tsx index 74f0a97a2b..7aa245fc1f 100644 --- a/packages/core-components/src/components/DependencyGraph/DefaultLabel.tsx +++ b/packages/core-components/src/components/DependencyGraph/DefaultLabel.tsx @@ -19,11 +19,16 @@ import makeStyles from '@material-ui/core/styles/makeStyles'; import { BackstageTheme } from '@backstage/theme'; import { RenderLabelProps } from './types'; -const useStyles = makeStyles((theme: BackstageTheme) => ({ - text: { - fill: theme.palette.textContrast, - }, -})); +export type DependencyGraphDefaultLabelClassKey = 'text'; + +const useStyles = makeStyles( + (theme: BackstageTheme) => ({ + text: { + fill: theme.palette.textContrast, + }, + }), + { name: 'BackstageDependencyGraphDefaultLabel' }, +); export function DefaultLabel({ edge: { label } }: RenderLabelProps) { const classes = useStyles(); diff --git a/packages/core-components/src/components/DependencyGraph/DefaultNode.tsx b/packages/core-components/src/components/DependencyGraph/DefaultNode.tsx index 8feca43e9a..6e33148aec 100644 --- a/packages/core-components/src/components/DependencyGraph/DefaultNode.tsx +++ b/packages/core-components/src/components/DependencyGraph/DefaultNode.tsx @@ -19,15 +19,20 @@ import { makeStyles } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; import { RenderNodeProps } from './types'; -const useStyles = makeStyles((theme: BackstageTheme) => ({ - node: { - fill: theme.palette.primary.light, - stroke: theme.palette.primary.light, - }, - text: { - fill: theme.palette.primary.contrastText, - }, -})); +export type DependencyGraphDefaultNodeClassKey = 'node' | 'text'; + +const useStyles = makeStyles( + (theme: BackstageTheme) => ({ + node: { + fill: theme.palette.primary.light, + stroke: theme.palette.primary.light, + }, + text: { + fill: theme.palette.primary.contrastText, + }, + }), + { name: 'BackstageDependencyGraphDefaultNode' }, +); export function DefaultNode({ node: { id } }: RenderNodeProps) { const classes = useStyles(); diff --git a/packages/core-components/src/components/DependencyGraph/Edge.tsx b/packages/core-components/src/components/DependencyGraph/Edge.tsx index e0f19d3536..a82c5d4813 100644 --- a/packages/core-components/src/components/DependencyGraph/Edge.tsx +++ b/packages/core-components/src/components/DependencyGraph/Edge.tsx @@ -28,17 +28,22 @@ import { import { ARROW_MARKER_ID, EDGE_TEST_ID, LABEL_TEST_ID } from './constants'; import { DefaultLabel } from './DefaultLabel'; -const useStyles = makeStyles((theme: BackstageTheme) => ({ - path: { - strokeWidth: 2, - stroke: theme.palette.textSubtle, - fill: 'none', - transition: `${theme.transitions.duration.shortest}ms`, - }, - label: { - transition: `${theme.transitions.duration.shortest}ms`, - }, -})); +export type DependencyGraphEdgeClassKey = 'path' | 'label'; + +const useStyles = makeStyles( + (theme: BackstageTheme) => ({ + path: { + strokeWidth: 2, + stroke: theme.palette.textSubtle, + fill: 'none', + transition: `${theme.transitions.duration.shortest}ms`, + }, + label: { + transition: `${theme.transitions.duration.shortest}ms`, + }, + }), + { name: 'BackstageDependencyGraphEdge' }, +); type EdgePoint = dagre.GraphEdge['points'][0]; diff --git a/packages/core-components/src/components/DependencyGraph/Node.tsx b/packages/core-components/src/components/DependencyGraph/Node.tsx index a5e1a62f09..e37f7a6e37 100644 --- a/packages/core-components/src/components/DependencyGraph/Node.tsx +++ b/packages/core-components/src/components/DependencyGraph/Node.tsx @@ -20,11 +20,16 @@ import { DefaultNode } from './DefaultNode'; import { RenderNodeFunction, RenderNodeProps, GraphNode } from './types'; import { NODE_TEST_ID } from './constants'; -const useStyles = makeStyles(theme => ({ - node: { - transition: `${theme.transitions.duration.shortest}ms`, - }, -})); +export type DependencyGraphNodeClassKey = 'node'; + +const useStyles = makeStyles( + theme => ({ + node: { + transition: `${theme.transitions.duration.shortest}ms`, + }, + }), + { name: 'BackstageDependencyGraphNode' }, +); export type NodeComponentProps = { node: GraphNode; diff --git a/packages/core-components/src/components/DependencyGraph/index.ts b/packages/core-components/src/components/DependencyGraph/index.ts index 03a5bfd584..23ab81bab9 100644 --- a/packages/core-components/src/components/DependencyGraph/index.ts +++ b/packages/core-components/src/components/DependencyGraph/index.ts @@ -19,3 +19,8 @@ import * as DependencyGraphTypes from './types'; export { DependencyGraph } from './DependencyGraph'; export type { DependencyGraphProps } from './DependencyGraph'; export { DependencyGraphTypes }; + +export type { DependencyGraphDefaultLabelClassKey } from './DefaultLabel'; +export type { DependencyGraphDefaultNodeClassKey } from './DefaultNode'; +export type { DependencyGraphEdgeClassKey } from './Edge'; +export type { DependencyGraphNodeClassKey } from './Node'; diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx index cc20a49ad6..3d229a5d54 100644 --- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx +++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.tsx @@ -25,43 +25,55 @@ import SnackbarContent from '@material-ui/core/SnackbarContent'; import IconButton from '@material-ui/core/IconButton'; import Close from '@material-ui/icons/Close'; -const useStyles = makeStyles((theme: BackstageTheme) => ({ - root: { - padding: theme.spacing(0), - marginBottom: theme.spacing(0), - marginTop: theme.spacing(0), - display: 'flex', - flexFlow: 'row nowrap', - }, - // showing on top - topPosition: { - position: 'relative', - marginBottom: theme.spacing(6), - marginTop: -theme.spacing(3), - zIndex: 'unset', - }, - icon: { - fontSize: 20, - }, - content: { - width: '100%', - maxWidth: 'inherit', - }, - message: { - display: 'flex', - alignItems: 'center', - color: theme.palette.banner.text, - '& a': { - color: theme.palette.banner.link, +export type DismissbleBannerClassKey = + | 'root' + | 'topPosition' + | 'icon' + | 'content' + | 'message' + | 'info' + | 'error'; + +const useStyles = makeStyles( + (theme: BackstageTheme) => ({ + root: { + padding: theme.spacing(0), + marginBottom: theme.spacing(0), + marginTop: theme.spacing(0), + display: 'flex', + flexFlow: 'row nowrap', }, - }, - info: { - backgroundColor: theme.palette.banner.info, - }, - error: { - backgroundColor: theme.palette.banner.error, - }, -})); + // showing on top + topPosition: { + position: 'relative', + marginBottom: theme.spacing(6), + marginTop: -theme.spacing(3), + zIndex: 'unset', + }, + icon: { + fontSize: 20, + }, + content: { + width: '100%', + maxWidth: 'inherit', + }, + message: { + display: 'flex', + alignItems: 'center', + color: theme.palette.banner.text, + '& a': { + color: theme.palette.banner.link, + }, + }, + info: { + backgroundColor: theme.palette.banner.info, + }, + error: { + backgroundColor: theme.palette.banner.error, + }, + }), + { name: 'BackstageDismissableBanner' }, +); type Props = { variant: 'info' | 'error'; diff --git a/packages/core-components/src/components/DismissableBanner/index.ts b/packages/core-components/src/components/DismissableBanner/index.ts index 4390d44903..26bebfed59 100644 --- a/packages/core-components/src/components/DismissableBanner/index.ts +++ b/packages/core-components/src/components/DismissableBanner/index.ts @@ -15,3 +15,4 @@ */ export { DismissableBanner } from './DismissableBanner'; +export type { DismissbleBannerClassKey } from './DismissableBanner'; diff --git a/packages/core-components/src/components/EmptyState/EmptyState.tsx b/packages/core-components/src/components/EmptyState/EmptyState.tsx index b2f5eca65e..f3fc3b2399 100644 --- a/packages/core-components/src/components/EmptyState/EmptyState.tsx +++ b/packages/core-components/src/components/EmptyState/EmptyState.tsx @@ -18,18 +18,23 @@ import React from 'react'; import { makeStyles, Typography, Grid } from '@material-ui/core'; import { EmptyStateImage } from './EmptyStateImage'; -const useStyles = makeStyles(theme => ({ - root: { - backgroundColor: theme.palette.background.default, - padding: theme.spacing(2, 0, 0, 0), - }, - action: { - marginTop: theme.spacing(2), - }, - imageContainer: { - position: 'relative', - }, -})); +export type EmptyStateClassKey = 'root' | 'action' | 'imageContainer'; + +const useStyles = makeStyles( + theme => ({ + root: { + backgroundColor: theme.palette.background.default, + padding: theme.spacing(2, 0, 0, 0), + }, + action: { + marginTop: theme.spacing(2), + }, + imageContainer: { + position: 'relative', + }, + }), + { name: 'BackstageEmptyState' }, +); type Props = { title: string; diff --git a/packages/core-components/src/components/EmptyState/EmptyStateImage.tsx b/packages/core-components/src/components/EmptyState/EmptyStateImage.tsx index 660e7fc947..012fb04e76 100644 --- a/packages/core-components/src/components/EmptyState/EmptyStateImage.tsx +++ b/packages/core-components/src/components/EmptyState/EmptyStateImage.tsx @@ -25,16 +25,21 @@ type Props = { missing: 'field' | 'info' | 'content' | 'data'; }; -const useStyles = makeStyles({ - generalImg: { - width: '95%', - zIndex: 2, - position: 'relative', - left: '50%', - top: '50%', - transform: 'translate(-50%, 15%)', +export type EmptyStateImageClassKey = 'generalImg'; + +const useStyles = makeStyles( + { + generalImg: { + width: '95%', + zIndex: 2, + position: 'relative', + left: '50%', + top: '50%', + transform: 'translate(-50%, 15%)', + }, }, -}); + { name: 'BackstageEmptyStateImage' }, +); export const EmptyStateImage = ({ missing }: Props) => { const classes = useStyles(); diff --git a/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx index 17061fa237..6f20f7c8e6 100644 --- a/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx +++ b/packages/core-components/src/components/EmptyState/MissingAnnotationEmptyState.tsx @@ -37,13 +37,18 @@ type Props = { annotation: string; }; -const useStyles = makeStyles(theme => ({ - code: { - borderRadius: 6, - margin: `${theme.spacing(2)}px 0px`, - background: theme.palette.type === 'dark' ? '#444' : '#fff', - }, -})); +export type MissingAnnotationEmptyStateClassKey = 'code'; + +const useStyles = makeStyles( + theme => ({ + code: { + borderRadius: 6, + margin: `${theme.spacing(2)}px 0px`, + background: theme.palette.type === 'dark' ? '#444' : '#fff', + }, + }), + { name: 'BackstageMissingAnnotationEmptyState' }, +); export function MissingAnnotationEmptyState(props: Props) { const { annotation } = props; diff --git a/packages/core-components/src/components/EmptyState/index.ts b/packages/core-components/src/components/EmptyState/index.ts index 95e12a014e..2696fba850 100644 --- a/packages/core-components/src/components/EmptyState/index.ts +++ b/packages/core-components/src/components/EmptyState/index.ts @@ -15,4 +15,7 @@ */ export { EmptyState } from './EmptyState'; +export type { EmptyStateClassKey } from './EmptyState'; +export type { EmptyStateImageClassKey } from './EmptyStateImage'; export { MissingAnnotationEmptyState } from './MissingAnnotationEmptyState'; +export type { MissingAnnotationEmptyStateClassKey } from './MissingAnnotationEmptyState'; diff --git a/packages/core-components/src/components/ErrorPanel/ErrorPanel.tsx b/packages/core-components/src/components/ErrorPanel/ErrorPanel.tsx index 309f2b447a..22f28a7a73 100644 --- a/packages/core-components/src/components/ErrorPanel/ErrorPanel.tsx +++ b/packages/core-components/src/components/ErrorPanel/ErrorPanel.tsx @@ -19,17 +19,22 @@ import React, { PropsWithChildren } from 'react'; import { CopyTextButton } from '../CopyTextButton'; import { WarningPanel } from '../WarningPanel'; -const useStyles = makeStyles(theme => ({ - text: { - fontFamily: 'monospace', - whiteSpace: 'pre', - overflowX: 'auto', - marginRight: theme.spacing(2), - }, - divider: { - margin: theme.spacing(2), - }, -})); +export type ErrorPanelClassKey = 'text' | 'divider'; + +const useStyles = makeStyles( + theme => ({ + text: { + fontFamily: 'monospace', + whiteSpace: 'pre', + overflowX: 'auto', + marginRight: theme.spacing(2), + }, + divider: { + margin: theme.spacing(2), + }, + }), + { name: 'BackstageErrorPanel' }, +); type ErrorListProps = { error: string; diff --git a/packages/core-components/src/components/ErrorPanel/index.ts b/packages/core-components/src/components/ErrorPanel/index.ts index 1da4a76959..0ea17486a5 100644 --- a/packages/core-components/src/components/ErrorPanel/index.ts +++ b/packages/core-components/src/components/ErrorPanel/index.ts @@ -15,4 +15,4 @@ */ export { ErrorPanel } from './ErrorPanel'; -export type { ErrorPanelProps } from './ErrorPanel'; +export type { ErrorPanelProps, ErrorPanelClassKey } from './ErrorPanel'; diff --git a/packages/core-components/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx b/packages/core-components/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx index 9459c3cbe0..7c4b9736b7 100644 --- a/packages/core-components/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx +++ b/packages/core-components/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx @@ -27,55 +27,67 @@ import { createPortal } from 'react-dom'; import { usePortal } from './lib/usePortal'; import { useShowCallout } from './lib/useShowCallout'; -const useStyles = makeStyles({ - '@keyframes pulsateSlightly': { - '0%': { transform: 'scale(1.0)' }, - '100%': { transform: 'scale(1.1)' }, +export type FeatureCalloutCircleClassKey = + | '@keyframes pulsateSlightly' + | '@keyframes pulsateAndFade' + | 'featureWrapper' + | 'backdrop' + | 'dot' + | 'pulseCircle' + | 'text'; + +const useStyles = makeStyles( + { + '@keyframes pulsateSlightly': { + '0%': { transform: 'scale(1.0)' }, + '100%': { transform: 'scale(1.1)' }, + }, + '@keyframes pulsateAndFade': { + '0%': { transform: 'scale(1.0)', opacity: 0.9 }, + '100%': { transform: 'scale(1.5)', opacity: 0 }, + }, + featureWrapper: { + position: 'relative', + }, + backdrop: { + zIndex: 2000, + position: 'fixed', + overflow: 'hidden', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + dot: { + position: 'absolute', + backgroundColor: 'transparent', + borderRadius: '100%', + border: '1px solid rgba(103, 146, 180, 0.98)', + boxShadow: '0px 0px 0px 20000px rgba(0, 0, 0, 0.5)', + zIndex: 2001, + transformOrigin: 'center center', + animation: + '$pulsateSlightly 1744ms 1.2s cubic-bezier(0.4, 0, 0.2, 1) alternate infinite', + }, + pulseCircle: { + width: '100%', + height: '100%', + backgroundColor: 'transparent', + borderRadius: '100%', + border: '2px solid white', + zIndex: 2001, + transformOrigin: 'center center', + animation: + '$pulsateAndFade 872ms 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite', + }, + text: { + position: 'absolute', + color: 'white', + zIndex: 2003, + }, }, - '@keyframes pulsateAndFade': { - '0%': { transform: 'scale(1.0)', opacity: 0.9 }, - '100%': { transform: 'scale(1.5)', opacity: 0 }, - }, - featureWrapper: { - position: 'relative', - }, - backdrop: { - zIndex: 2000, - position: 'fixed', - overflow: 'hidden', - left: 0, - right: 0, - top: 0, - bottom: 0, - }, - dot: { - position: 'absolute', - backgroundColor: 'transparent', - borderRadius: '100%', - border: '1px solid rgba(103, 146, 180, 0.98)', - boxShadow: '0px 0px 0px 20000px rgba(0, 0, 0, 0.5)', - zIndex: 2001, - transformOrigin: 'center center', - animation: - '$pulsateSlightly 1744ms 1.2s cubic-bezier(0.4, 0, 0.2, 1) alternate infinite', - }, - pulseCircle: { - width: '100%', - height: '100%', - backgroundColor: 'transparent', - borderRadius: '100%', - border: '2px solid white', - zIndex: 2001, - transformOrigin: 'center center', - animation: - '$pulsateAndFade 872ms 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite', - }, - text: { - position: 'absolute', - color: 'white', - zIndex: 2003, - }, -}); + { name: 'BackstageFeatureCalloutCircular' }, +); export type Props = { featureId: string; diff --git a/packages/core-components/src/components/FeatureDiscovery/index.ts b/packages/core-components/src/components/FeatureDiscovery/index.ts index ed9ab28b17..fb5979d4a9 100644 --- a/packages/core-components/src/components/FeatureDiscovery/index.ts +++ b/packages/core-components/src/components/FeatureDiscovery/index.ts @@ -15,3 +15,4 @@ */ export { FeatureCalloutCircular } from './FeatureCalloutCircular'; +export type { FeatureCalloutCircleClassKey } from './FeatureCalloutCircular'; diff --git a/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx b/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx index 7e49537492..c4508eb9bc 100644 --- a/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx +++ b/packages/core-components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx @@ -17,15 +17,20 @@ import React from 'react'; import { IconLinkVertical, IconLinkVerticalProps } from './IconLinkVertical'; import { makeStyles } from '@material-ui/core'; -const useStyles = makeStyles(theme => ({ - links: { - margin: theme.spacing(2, 0), - display: 'grid', - gridAutoFlow: 'column', - gridAutoColumns: 'min-content', - gridGap: theme.spacing(3), - }, -})); +export type HeaderIconLinkRowClassKey = 'links'; + +const useStyles = makeStyles( + theme => ({ + links: { + margin: theme.spacing(2, 0), + display: 'grid', + gridAutoFlow: 'column', + gridAutoColumns: 'min-content', + gridGap: theme.spacing(3), + }, + }), + { name: 'BackstageHeaderIconLinkRow' }, +); type Props = { links: IconLinkVerticalProps[]; diff --git a/packages/core-components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx b/packages/core-components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx index df26bc07b8..532ba2f959 100644 --- a/packages/core-components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx +++ b/packages/core-components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx @@ -29,30 +29,40 @@ export type IconLinkVerticalProps = { title?: string; }; -const useIconStyles = makeStyles(theme => ({ - link: { - display: 'grid', - justifyItems: 'center', - gridGap: 4, - textAlign: 'center', - }, - disabled: { - color: 'gray', - cursor: 'default', - }, - primary: { - color: theme.palette.primary.main, - }, - secondary: { - color: theme.palette.secondary.main, - }, - label: { - fontSize: '0.7rem', - textTransform: 'uppercase', - fontWeight: 600, - letterSpacing: 1.2, - }, -})); +export type IconLinkVerticalClassKey = + | 'link' + | 'disabled' + | 'primary' + | 'secondary' + | 'label'; + +const useIconStyles = makeStyles( + theme => ({ + link: { + display: 'grid', + justifyItems: 'center', + gridGap: 4, + textAlign: 'center', + }, + disabled: { + color: 'gray', + cursor: 'default', + }, + primary: { + color: theme.palette.primary.main, + }, + secondary: { + color: theme.palette.secondary.main, + }, + label: { + fontSize: '0.7rem', + textTransform: 'uppercase', + fontWeight: 600, + letterSpacing: 1.2, + }, + }), + { name: 'BackstageIconLinkVertical' }, +); export function IconLinkVertical({ color = 'primary', diff --git a/packages/core-components/src/components/HeaderIconLinkRow/index.ts b/packages/core-components/src/components/HeaderIconLinkRow/index.ts index fb732644e4..f2207d32c5 100644 --- a/packages/core-components/src/components/HeaderIconLinkRow/index.ts +++ b/packages/core-components/src/components/HeaderIconLinkRow/index.ts @@ -15,5 +15,8 @@ */ export { HeaderIconLinkRow } from './HeaderIconLinkRow'; - -export type { IconLinkVerticalProps } from './IconLinkVertical'; +export type { HeaderIconLinkRowClassKey } from './HeaderIconLinkRow'; +export type { + IconLinkVerticalProps, + IconLinkVerticalClassKey, +} from './IconLinkVertical'; diff --git a/packages/core-components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx b/packages/core-components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx index a3d16bc4ec..88b61d56bb 100644 --- a/packages/core-components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx +++ b/packages/core-components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx @@ -54,52 +54,66 @@ type Props = { minScrollDistance?: number; // limits how small steps the scroll can take in px }; -const useStyles = makeStyles(theme => ({ - root: { - position: 'relative', - display: 'flex', - flexFlow: 'row nowrap', - alignItems: 'center', - }, - container: { - overflow: 'auto', - scrollbarWidth: 0 as any, // hide in FF - '&::-webkit-scrollbar': { - display: 'none', // hide in Chrome +export type HorizontalScrollGridClassKey = + | 'root' + | 'container' + | 'fade' + | 'fadeLeft' + | 'fadeRight' + | 'fadeHidden' + | 'button' + | 'buttonLeft' + | 'buttonRight'; + +const useStyles = makeStyles( + theme => ({ + root: { + position: 'relative', + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', }, - }, - fade: { - position: 'absolute', - width: fadeSize, - height: `calc(100% + ${fadePadding}px)`, - transition: 'opacity 300ms', - pointerEvents: 'none', - }, - fadeLeft: { - left: -fadePadding, - background: `linear-gradient(90deg, ${generateGradientStops( - theme.palette.type, - )})`, - }, - fadeRight: { - right: -fadePadding, - background: `linear-gradient(270deg, ${generateGradientStops( - theme.palette.type, - )})`, - }, - fadeHidden: { - opacity: 0, - }, - button: { - position: 'absolute', - }, - buttonLeft: { - left: -theme.spacing(2), - }, - buttonRight: { - right: -theme.spacing(2), - }, -})); + container: { + overflow: 'auto', + scrollbarWidth: 0 as any, // hide in FF + '&::-webkit-scrollbar': { + display: 'none', // hide in Chrome + }, + }, + fade: { + position: 'absolute', + width: fadeSize, + height: `calc(100% + ${fadePadding}px)`, + transition: 'opacity 300ms', + pointerEvents: 'none', + }, + fadeLeft: { + left: -fadePadding, + background: `linear-gradient(90deg, ${generateGradientStops( + theme.palette.type, + )})`, + }, + fadeRight: { + right: -fadePadding, + background: `linear-gradient(270deg, ${generateGradientStops( + theme.palette.type, + )})`, + }, + fadeHidden: { + opacity: 0, + }, + button: { + position: 'absolute', + }, + buttonLeft: { + left: -theme.spacing(2), + }, + buttonRight: { + right: -theme.spacing(2), + }, + }), + { name: 'BackstageHorizontalScrollGrid' }, +); // Returns scroll distance from left and right function useScrollDistance( diff --git a/packages/core-components/src/components/HorizontalScrollGrid/index.tsx b/packages/core-components/src/components/HorizontalScrollGrid/index.tsx index bbf545dab3..e126bbf70f 100644 --- a/packages/core-components/src/components/HorizontalScrollGrid/index.tsx +++ b/packages/core-components/src/components/HorizontalScrollGrid/index.tsx @@ -15,3 +15,4 @@ */ export { HorizontalScrollGrid } from './HorizontalScrollGrid'; +export type { HorizontalScrollGridClassKey } from './HorizontalScrollGrid'; diff --git a/packages/core-components/src/components/Lifecycle/Lifecycle.tsx b/packages/core-components/src/components/Lifecycle/Lifecycle.tsx index ef451ac1b0..76e63854f5 100644 --- a/packages/core-components/src/components/Lifecycle/Lifecycle.tsx +++ b/packages/core-components/src/components/Lifecycle/Lifecycle.tsx @@ -23,20 +23,25 @@ type Props = CSS.Properties & { alpha?: boolean; }; -const useStyles = makeStyles({ - alpha: { - color: '#ffffff', - fontFamily: 'serif', - fontWeight: 'normal', - fontStyle: 'italic', +export type LifecycleClassKey = 'alpha' | 'beta'; + +const useStyles = makeStyles( + { + alpha: { + color: '#ffffff', + fontFamily: 'serif', + fontWeight: 'normal', + fontStyle: 'italic', + }, + beta: { + color: '#4d65cc', + fontFamily: 'serif', + fontWeight: 'normal', + fontStyle: 'italic', + }, }, - beta: { - color: '#4d65cc', - fontFamily: 'serif', - fontWeight: 'normal', - fontStyle: 'italic', - }, -}); + { name: 'BackstageLifecycle' }, +); export function Lifecycle(props: Props) { const classes = useStyles(props); diff --git a/packages/core-components/src/components/Lifecycle/index.ts b/packages/core-components/src/components/Lifecycle/index.ts index d52e79e08b..ebf16cb685 100644 --- a/packages/core-components/src/components/Lifecycle/index.ts +++ b/packages/core-components/src/components/Lifecycle/index.ts @@ -15,3 +15,4 @@ */ export { Lifecycle } from './Lifecycle'; +export type { LifecycleClassKey } from './Lifecycle'; diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index 97a71c5760..df1bcfac03 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -15,11 +15,12 @@ */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils'; +import { ApiProvider, ApiRegistry } from '@backstage/core-app-api'; +import { analyticsApiRef } from '@backstage/core-plugin-api'; import { isExternalUri, Link } from './Link'; import { Route, Routes } from 'react-router'; -import { act } from 'react-dom/test-utils'; describe('', () => { it('navigates using react-router', async () => { @@ -34,10 +35,42 @@ describe('', () => { ), ); expect(() => getByText(testString)).toThrow(); - await act(async () => { - fireEvent.click(getByText(linkText)); + fireEvent.click(getByText(linkText)); + await waitFor(() => { + expect(getByText(testString)).toBeInTheDocument(); + }); + }); + + it('captures click using analytics api', async () => { + const linkText = 'Navigate!'; + const analyticsApi = new MockAnalyticsApi(); + const customOnClick = jest.fn(); + + const { getByText } = render( + wrapInTestApp( + + + {linkText} + + , + ), + ); + + fireEvent.click(getByText(linkText)); + + // Analytics event should have been fired. + await waitFor(() => { + expect(analyticsApi.getEvents()[0]).toMatchObject({ + action: 'click', + subject: linkText, + attributes: { + to: '/test', + }, + }); + + // Custom onClick handler should have still been fired too. + expect(customOnClick).toHaveBeenCalled(); }); - expect(getByText(testString)).toBeInTheDocument(); }); describe('isExternalUri', () => { diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index 0d301dccf9..d20773a8bb 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { useAnalytics } from '@backstage/core-plugin-api'; import { Link as MaterialLink, LinkProps as MaterialLinkProps, @@ -34,26 +35,66 @@ export type LinkProps = MaterialLinkProps & declare function LinkType(props: LinkProps): JSX.Element; /** - * Thin wrapper on top of material-ui's Link component - * Makes the Link to utilise react-router + * Given a react node, try to retrieve its text content. */ -const ActualLink = React.forwardRef((props, ref) => { - const to = String(props.to); - const external = isExternalUri(to); - const newWindow = external && !!/^https?:/.exec(to); - return external ? ( - // External links - - ) : ( - // Interact with React Router for internal links - - ); -}); +const getNodeText = (node: React.ReactNode): string => { + // If the node is an array of children, recurse and join. + if (node instanceof Array) { + return node.map(getNodeText).join(' ').trim(); + } + + // If the node is a react element, recurse on its children. + if (typeof node === 'object' && node) { + return getNodeText((node as React.ReactElement)?.props?.children); + } + + // Base case: the node is just text. Return it. + if (['string', 'number'].includes(typeof node)) { + return String(node); + } + + // Base case: just return an empty string. + return ''; +}; + +/** + * Thin wrapper on top of material-ui's Link component, which... + * - Makes the Link use react-router + * - Captures Link clicks as analytics events. + */ +const ActualLink = React.forwardRef( + ({ onClick, ...props }, ref) => { + const analytics = useAnalytics(); + const to = String(props.to); + const linkText = getNodeText(props.children) || to; + const external = isExternalUri(to); + const newWindow = external && !!/^https?:/.exec(to); + + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + analytics.captureEvent('click', linkText, { attributes: { to } }); + }; + + return external ? ( + // External links + + ) : ( + // Interact with React Router for internal links + + ); + }, +); // TODO(Rugvip): We use this as a workaround to make the exported type be a // function, which makes our API reference docs much nicer. diff --git a/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx b/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx index d2930cbf5d..65be789bb4 100644 --- a/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx +++ b/packages/core-components/src/components/MarkdownContent/MarkdownContent.tsx @@ -21,43 +21,48 @@ import React from 'react'; import { BackstageTheme } from '@backstage/theme'; import { CodeSnippet } from '../CodeSnippet'; -const useStyles = makeStyles(theme => ({ - markdown: { - '& table': { - borderCollapse: 'collapse', - border: `1px solid ${theme.palette.border}`, - }, - '& th, & td': { - border: `1px solid ${theme.palette.border}`, - padding: theme.spacing(1), - }, - '& td': { - wordBreak: 'break-word', - overflow: 'hidden', - verticalAlign: 'middle', - lineHeight: '1', - margin: 0, - padding: theme.spacing(3, 2, 3, 2.5), - borderBottom: 0, - }, - '& th': { - backgroundColor: theme.palette.background.paper, - }, - '& tr': { - backgroundColor: theme.palette.background.paper, - }, - '& tr:nth-child(odd)': { - backgroundColor: theme.palette.background.default, - }, +export type MarkdownContentClassKey = 'markdown'; - '& a': { - color: theme.palette.link, +const useStyles = makeStyles( + theme => ({ + markdown: { + '& table': { + borderCollapse: 'collapse', + border: `1px solid ${theme.palette.border}`, + }, + '& th, & td': { + border: `1px solid ${theme.palette.border}`, + padding: theme.spacing(1), + }, + '& td': { + wordBreak: 'break-word', + overflow: 'hidden', + verticalAlign: 'middle', + lineHeight: '1', + margin: 0, + padding: theme.spacing(3, 2, 3, 2.5), + borderBottom: 0, + }, + '& th': { + backgroundColor: theme.palette.background.paper, + }, + '& tr': { + backgroundColor: theme.palette.background.paper, + }, + '& tr:nth-child(odd)': { + backgroundColor: theme.palette.background.default, + }, + + '& a': { + color: theme.palette.link, + }, + '& img': { + maxWidth: '100%', + }, }, - '& img': { - maxWidth: '100%', - }, - }, -})); + }), + { name: 'BackstageMarkdownContent' }, +); type Props = { content: string; diff --git a/packages/core-components/src/components/MarkdownContent/index.ts b/packages/core-components/src/components/MarkdownContent/index.ts index 9218d9fcde..2e4ef08d3f 100644 --- a/packages/core-components/src/components/MarkdownContent/index.ts +++ b/packages/core-components/src/components/MarkdownContent/index.ts @@ -15,3 +15,4 @@ */ export { MarkdownContent } from './MarkdownContent'; +export type { MarkdownContentClassKey } from './MarkdownContent'; diff --git a/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx index f9533d9d03..7f7f51abba 100644 --- a/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx +++ b/packages/core-components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx @@ -26,11 +26,16 @@ import { import React, { useState } from 'react'; import { PendingAuthRequest } from '@backstage/core-plugin-api'; -const useItemStyles = makeStyles(theme => ({ - root: { - paddingLeft: theme.spacing(3), - }, -})); +export type LoginRequestListItemClassKey = 'root'; + +const useItemStyles = makeStyles( + theme => ({ + root: { + paddingLeft: theme.spacing(3), + }, + }), + { name: 'BackstageLoginRequestListItem' }, +); type RowProps = { request: PendingAuthRequest; diff --git a/packages/core-components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx b/packages/core-components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx index e79d28c470..73e276fce9 100644 --- a/packages/core-components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx +++ b/packages/core-components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx @@ -29,20 +29,29 @@ import { useObservable } from 'react-use'; import LoginRequestListItem from './LoginRequestListItem'; import { useApi, oauthRequestApiRef } from '@backstage/core-plugin-api'; -const useStyles = makeStyles(theme => ({ - dialog: { - paddingTop: theme.spacing(1), - }, - title: { - minWidth: 0, - }, - contentList: { - padding: 0, - }, - actionButtons: { - padding: theme.spacing(2, 0), - }, -})); +export type OAuthRequestDialogClassKey = + | 'dialog' + | 'title' + | 'contentList' + | 'actionButtons'; + +const useStyles = makeStyles( + theme => ({ + dialog: { + paddingTop: theme.spacing(1), + }, + title: { + minWidth: 0, + }, + contentList: { + padding: 0, + }, + actionButtons: { + padding: theme.spacing(2, 0), + }, + }), + { name: 'OAuthRequestDialog' }, +); export function OAuthRequestDialog(_props: {}) { const classes = useStyles(); diff --git a/packages/core-components/src/components/OAuthRequestDialog/index.ts b/packages/core-components/src/components/OAuthRequestDialog/index.ts index d2a48eac06..92347e0ed7 100644 --- a/packages/core-components/src/components/OAuthRequestDialog/index.ts +++ b/packages/core-components/src/components/OAuthRequestDialog/index.ts @@ -15,3 +15,5 @@ */ export { OAuthRequestDialog } from './OAuthRequestDialog'; +export type { OAuthRequestDialogClassKey } from './OAuthRequestDialog'; +export type { LoginRequestListItemClassKey } from './LoginRequestListItem'; diff --git a/packages/core-components/src/components/OverflowTooltip/OverflowTooltip.tsx b/packages/core-components/src/components/OverflowTooltip/OverflowTooltip.tsx index fbb1537847..a06930cdfa 100644 --- a/packages/core-components/src/components/OverflowTooltip/OverflowTooltip.tsx +++ b/packages/core-components/src/components/OverflowTooltip/OverflowTooltip.tsx @@ -26,11 +26,16 @@ type Props = { placement?: TooltipProps['placement']; }; -const useStyles = makeStyles({ - container: { - overflow: 'visible !important', +export type OverflowTooltipClassKey = 'container'; + +const useStyles = makeStyles( + { + container: { + overflow: 'visible !important', + }, }, -}); + { name: 'BackstageOverflowTooltip' }, +); export function OverflowTooltip(props: Props) { const [hover, setHover] = useState(false); diff --git a/packages/core-components/src/components/OverflowTooltip/index.ts b/packages/core-components/src/components/OverflowTooltip/index.ts index f7258b5364..af8616d740 100644 --- a/packages/core-components/src/components/OverflowTooltip/index.ts +++ b/packages/core-components/src/components/OverflowTooltip/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { OverflowTooltip } from './OverflowTooltip'; +export type { OverflowTooltipClassKey } from './OverflowTooltip'; diff --git a/packages/core-components/src/components/ProgressBars/Gauge.tsx b/packages/core-components/src/components/ProgressBars/Gauge.tsx index 3b31f64fe7..2699f8a289 100644 --- a/packages/core-components/src/components/ProgressBars/Gauge.tsx +++ b/packages/core-components/src/components/ProgressBars/Gauge.tsx @@ -19,26 +19,31 @@ import { BackstageTheme } from '@backstage/theme'; import { Circle } from 'rc-progress'; import React from 'react'; -const useStyles = makeStyles(theme => ({ - root: { - position: 'relative', - lineHeight: 0, - }, - overlay: { - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -60%)', - fontSize: 45, - fontWeight: 'bold', - color: theme.palette.textContrast, - }, - circle: { - width: '80%', - transform: 'translate(10%, 0)', - }, - colorUnknown: {}, -})); +export type GaugeClassKey = 'root' | 'overlay' | 'circle' | 'colorUnknown'; + +const useStyles = makeStyles( + theme => ({ + root: { + position: 'relative', + lineHeight: 0, + }, + overlay: { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -60%)', + fontSize: 45, + fontWeight: 'bold', + color: theme.palette.textContrast, + }, + circle: { + width: '80%', + transform: 'translate(10%, 0)', + }, + colorUnknown: {}, + }), + { name: 'BackstageGauge' }, +); type Props = { value: number; diff --git a/packages/core-components/src/components/ProgressBars/GaugeCard.tsx b/packages/core-components/src/components/ProgressBars/GaugeCard.tsx index 0a603ea991..aec63add11 100644 --- a/packages/core-components/src/components/ProgressBars/GaugeCard.tsx +++ b/packages/core-components/src/components/ProgressBars/GaugeCard.tsx @@ -30,12 +30,17 @@ type Props = { deepLink?: BottomLinkProps; }; -const useStyles = makeStyles({ - root: { - height: '100%', - width: 250, +export type GaugeCardClassKey = 'root'; + +const useStyles = makeStyles( + { + root: { + height: '100%', + width: 250, + }, }, -}); + { name: 'BackstageGaugeCard' }, +); export function GaugeCard(props: Props) { const classes = useStyles(props); diff --git a/packages/core-components/src/components/ProgressBars/index.ts b/packages/core-components/src/components/ProgressBars/index.ts index 01cab20f27..3d821eaac2 100644 --- a/packages/core-components/src/components/ProgressBars/index.ts +++ b/packages/core-components/src/components/ProgressBars/index.ts @@ -15,5 +15,7 @@ */ export { GaugeCard } from './GaugeCard'; +export type { GaugeCardClassKey } from './GaugeCard'; export { Gauge } from './Gauge'; +export type { GaugeClassKey } from './Gauge'; export { LinearGauge } from './LinearGauge'; diff --git a/packages/core-components/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx b/packages/core-components/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx index 9f25e8290f..37143000a3 100644 --- a/packages/core-components/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx +++ b/packages/core-components/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx @@ -21,17 +21,22 @@ import { CodeSnippet } from '../CodeSnippet'; import { CopyTextButton } from '../CopyTextButton'; import { ErrorPanel, ErrorPanelProps } from '../ErrorPanel'; -const useStyles = makeStyles(theme => ({ - text: { - fontFamily: 'monospace', - whiteSpace: 'pre', - overflowX: 'auto', - marginRight: theme.spacing(2), - }, - divider: { - margin: theme.spacing(2), - }, -})); +export type ResponseErrorPanelClassKey = 'text' | 'divider'; + +const useStyles = makeStyles( + theme => ({ + text: { + fontFamily: 'monospace', + whiteSpace: 'pre', + overflowX: 'auto', + marginRight: theme.spacing(2), + }, + divider: { + margin: theme.spacing(2), + }, + }), + { name: 'BackstageResponseErrorPanel' }, +); /** * Renders a warning panel as the effect of a failed server request. diff --git a/packages/core-components/src/components/ResponseErrorPanel/index.ts b/packages/core-components/src/components/ResponseErrorPanel/index.ts index c93bd9eade..c29249d01e 100644 --- a/packages/core-components/src/components/ResponseErrorPanel/index.ts +++ b/packages/core-components/src/components/ResponseErrorPanel/index.ts @@ -15,3 +15,4 @@ */ export { ResponseErrorPanel } from './ResponseErrorPanel'; +export type { ResponseErrorPanelClassKey } from './ResponseErrorPanel'; diff --git a/packages/core-components/src/components/Select/Select.tsx b/packages/core-components/src/components/Select/Select.tsx index ae14545b29..23ed2cf62c 100644 --- a/packages/core-components/src/components/Select/Select.tsx +++ b/packages/core-components/src/components/Select/Select.tsx @@ -34,59 +34,73 @@ import React, { useEffect, useState } from 'react'; import ClosedDropdown from './static/ClosedDropdown'; import OpenedDropdown from './static/OpenedDropdown'; -const BootstrapInput = withStyles((theme: Theme) => - createStyles({ - root: { - 'label + &': { - marginTop: theme.spacing(3), +export type SelectInputBaseClassKey = 'root' | 'input'; + +const BootstrapInput = withStyles( + (theme: Theme) => + createStyles({ + root: { + 'label + &': { + marginTop: theme.spacing(3), + }, }, - }, - input: { - borderRadius: 4, - position: 'relative', - backgroundColor: theme.palette.background.paper, - border: '1px solid #ced4da', - fontSize: 16, - padding: '10px 26px 10px 12px', - transition: theme.transitions.create(['border-color', 'box-shadow']), - fontFamily: 'Helvetica Neue', - '&:focus': { - background: theme.palette.background.paper, + input: { borderRadius: 4, + position: 'relative', + backgroundColor: theme.palette.background.paper, + border: '1px solid #ced4da', + fontSize: 16, + padding: '10px 26px 10px 12px', + transition: theme.transitions.create(['border-color', 'box-shadow']), + fontFamily: 'Helvetica Neue', + '&:focus': { + background: theme.palette.background.paper, + borderRadius: 4, + }, }, - }, - }), + }), + { name: 'BackstageSelectInputBase' }, )(InputBase); -const useStyles = makeStyles((theme: Theme) => - createStyles({ - formControl: { - margin: `${theme.spacing(1)} 0px`, - maxWidth: 300, - }, - label: { - transform: 'initial', - fontWeight: 'bold', - fontSize: 14, - fontFamily: theme.typography.fontFamily, - color: theme.palette.text.primary, - '&.Mui-focused': { - color: theme.palette.text.primary, +export type SelectClassKey = + | 'formControl' + | 'label' + | 'chips' + | 'chip' + | 'checkbox' + | 'root'; + +const useStyles = makeStyles( + (theme: Theme) => + createStyles({ + formControl: { + margin: `${theme.spacing(1)} 0px`, + maxWidth: 300, }, - }, - chips: { - display: 'flex', - flexWrap: 'wrap', - }, - chip: { - margin: 2, - }, - checkbox: {}, - root: { - display: 'flex', - flexDirection: 'column', - }, - }), + label: { + transform: 'initial', + fontWeight: 'bold', + fontSize: 14, + fontFamily: theme.typography.fontFamily, + color: theme.palette.text.primary, + '&.Mui-focused': { + color: theme.palette.text.primary, + }, + }, + chips: { + display: 'flex', + flexWrap: 'wrap', + }, + chip: { + margin: 2, + }, + checkbox: {}, + root: { + display: 'flex', + flexDirection: 'column', + }, + }), + { name: 'BackstageSelect' }, ); type Item = { diff --git a/packages/core-components/src/components/Select/index.tsx b/packages/core-components/src/components/Select/index.tsx index 0c35cc1378..0f149f05b5 100644 --- a/packages/core-components/src/components/Select/index.tsx +++ b/packages/core-components/src/components/Select/index.tsx @@ -15,3 +15,6 @@ */ export { SelectComponent as Select } from './Select'; +export type { SelectClassKey, SelectInputBaseClassKey } from './Select'; +export type { ClosedDropdownClassKey } from './static/ClosedDropdown'; +export type { OpenedDropdownClassKey } from './static/OpenedDropdown'; diff --git a/packages/core-components/src/components/Select/static/ClosedDropdown.tsx b/packages/core-components/src/components/Select/static/ClosedDropdown.tsx index 41155bb268..61a24abcef 100644 --- a/packages/core-components/src/components/Select/static/ClosedDropdown.tsx +++ b/packages/core-components/src/components/Select/static/ClosedDropdown.tsx @@ -16,14 +16,18 @@ import React from 'react'; import { SvgIcon, makeStyles, createStyles } from '@material-ui/core'; -const useStyles = makeStyles(() => - createStyles({ - icon: { - position: 'absolute', - right: '4px', - pointerEvents: 'none', - }, - }), +export type ClosedDropdownClassKey = 'icon'; + +const useStyles = makeStyles( + () => + createStyles({ + icon: { + position: 'absolute', + right: '4px', + pointerEvents: 'none', + }, + }), + { name: 'BackstageClosedDropdown' }, ); const ClosedDropdown = () => { diff --git a/packages/core-components/src/components/Select/static/OpenedDropdown.tsx b/packages/core-components/src/components/Select/static/OpenedDropdown.tsx index 2c91dc6989..eb86a34fe0 100644 --- a/packages/core-components/src/components/Select/static/OpenedDropdown.tsx +++ b/packages/core-components/src/components/Select/static/OpenedDropdown.tsx @@ -16,14 +16,18 @@ import React from 'react'; import { SvgIcon, makeStyles, createStyles } from '@material-ui/core'; -const useStyles = makeStyles(() => - createStyles({ - icon: { - position: 'absolute', - right: '4px', - pointerEvents: 'none', - }, - }), +export type OpenedDropdownClassKey = 'icon'; + +const useStyles = makeStyles( + () => + createStyles({ + icon: { + position: 'absolute', + right: '4px', + pointerEvents: 'none', + }, + }), + { name: 'BackstageOpenedDropdown' }, ); const OpenedDropdown = () => { diff --git a/packages/core-components/src/components/SimpleStepper/SimpleStepperFooter.tsx b/packages/core-components/src/components/SimpleStepper/SimpleStepperFooter.tsx index b126b5ba75..270099a104 100644 --- a/packages/core-components/src/components/SimpleStepper/SimpleStepperFooter.tsx +++ b/packages/core-components/src/components/SimpleStepper/SimpleStepperFooter.tsx @@ -18,14 +18,19 @@ import { Button, makeStyles } from '@material-ui/core'; import { StepActions } from './types'; import { VerticalStepperContext } from './SimpleStepper'; -const useStyles = makeStyles(theme => ({ - root: { - marginTop: theme.spacing(3), - '& button': { - marginRight: theme.spacing(1), +export type SimpleStepperFooterClassKey = 'root'; + +const useStyles = makeStyles( + theme => ({ + root: { + marginTop: theme.spacing(3), + '& button': { + marginRight: theme.spacing(1), + }, }, - }, -})); + }), + { name: 'BackstageSimpleStepperFooter' }, +); interface CommonBtnProps { text?: string; diff --git a/packages/core-components/src/components/SimpleStepper/SimpleStepperStep.tsx b/packages/core-components/src/components/SimpleStepper/SimpleStepperStep.tsx index 39383b934b..0696a1256a 100644 --- a/packages/core-components/src/components/SimpleStepper/SimpleStepperStep.tsx +++ b/packages/core-components/src/components/SimpleStepper/SimpleStepperStep.tsx @@ -24,11 +24,16 @@ import { import { SimpleStepperFooter } from './SimpleStepperFooter'; import { StepProps } from './types'; -const useStyles = makeStyles(theme => ({ - end: { - padding: theme.spacing(3), - }, -})); +export type SimpleStepperStepClassKey = 'end'; + +const useStyles = makeStyles( + theme => ({ + end: { + padding: theme.spacing(3), + }, + }), + { name: 'SimpleStepperStep' }, +); export function SimpleStepperStep(props: PropsWithChildren) { const { title, children, end, actions, ...muiProps } = props; diff --git a/packages/core-components/src/components/SimpleStepper/index.ts b/packages/core-components/src/components/SimpleStepper/index.ts index ddb6d2537a..e7f6388e27 100644 --- a/packages/core-components/src/components/SimpleStepper/index.ts +++ b/packages/core-components/src/components/SimpleStepper/index.ts @@ -15,4 +15,6 @@ */ export { SimpleStepper } from './SimpleStepper'; +export type { SimpleStepperFooterClassKey } from './SimpleStepperFooter'; export { SimpleStepperStep } from './SimpleStepperStep'; +export type { SimpleStepperStepClassKey } from './SimpleStepperStep'; diff --git a/packages/core-components/src/components/Status/Status.tsx b/packages/core-components/src/components/Status/Status.tsx index 2b2d40dee3..a6215d6127 100644 --- a/packages/core-components/src/components/Status/Status.tsx +++ b/packages/core-components/src/components/Status/Status.tsx @@ -19,49 +19,61 @@ import { BackstageTheme } from '@backstage/theme'; import classNames from 'classnames'; import React, { PropsWithChildren } from 'react'; -const useStyles = makeStyles(theme => ({ - status: { - fontWeight: 500, - '&::before': { - width: '0.7em', - height: '0.7em', - display: 'inline-block', - marginRight: 8, - borderRadius: '50%', - content: '""', +export type StatusClassKey = + | 'status' + | 'ok' + | 'warning' + | 'error' + | 'pending' + | 'running' + | 'aborted'; + +const useStyles = makeStyles( + theme => ({ + status: { + fontWeight: 500, + '&::before': { + width: '0.7em', + height: '0.7em', + display: 'inline-block', + marginRight: 8, + borderRadius: '50%', + content: '""', + }, }, - }, - ok: { - '&::before': { - backgroundColor: theme.palette.status.ok, + ok: { + '&::before': { + backgroundColor: theme.palette.status.ok, + }, }, - }, - warning: { - '&::before': { - backgroundColor: theme.palette.status.warning, + warning: { + '&::before': { + backgroundColor: theme.palette.status.warning, + }, }, - }, - error: { - '&::before': { - backgroundColor: theme.palette.status.error, + error: { + '&::before': { + backgroundColor: theme.palette.status.error, + }, }, - }, - pending: { - '&::before': { - backgroundColor: theme.palette.status.pending, + pending: { + '&::before': { + backgroundColor: theme.palette.status.pending, + }, }, - }, - running: { - '&::before': { - backgroundColor: theme.palette.status.running, + running: { + '&::before': { + backgroundColor: theme.palette.status.running, + }, }, - }, - aborted: { - '&::before': { - backgroundColor: theme.palette.status.aborted, + aborted: { + '&::before': { + backgroundColor: theme.palette.status.aborted, + }, }, - }, -})); + }), + { name: 'BackstageStatus' }, +); export function StatusOK(props: PropsWithChildren<{}>) { const classes = useStyles(props); diff --git a/packages/core-components/src/components/Status/index.ts b/packages/core-components/src/components/Status/index.ts index 2e34890482..39c4e02c5e 100644 --- a/packages/core-components/src/components/Status/index.ts +++ b/packages/core-components/src/components/Status/index.ts @@ -22,3 +22,5 @@ export { StatusRunning, StatusWarning, } from './Status'; + +export type { StatusClassKey } from './Status'; diff --git a/packages/core-components/src/components/StructuredMetadataTable/MetadataTable.tsx b/packages/core-components/src/components/StructuredMetadataTable/MetadataTable.tsx index 5740ba9500..3c869f88c3 100644 --- a/packages/core-components/src/components/StructuredMetadataTable/MetadataTable.tsx +++ b/packages/core-components/src/components/StructuredMetadataTable/MetadataTable.tsx @@ -26,6 +26,8 @@ import { Theme, } from '@material-ui/core'; +export type MetadataTableTitleCellClassKey = 'root'; + const tableTitleCellStyles = (theme: Theme) => createStyles({ root: { @@ -37,6 +39,8 @@ const tableTitleCellStyles = (theme: Theme) => }, }); +export type MetadataTableCellClassKey = 'root'; + const tableContentCellStyles = { root: { border: '0', @@ -44,6 +48,8 @@ const tableContentCellStyles = { }, }; +export type MetadataTableListClassKey = 'root'; + const listStyles = (theme: Theme) => createStyles({ root: { @@ -53,6 +59,8 @@ const listStyles = (theme: Theme) => }, }); +export type MetadataTableListItemClassKey = 'root' | 'random'; + const listItemStyles = (theme: Theme) => createStyles({ root: { @@ -61,8 +69,12 @@ const listItemStyles = (theme: Theme) => random: {}, }); -const TitleCell = withStyles(tableTitleCellStyles)(TableCell); -const ContentCell = withStyles(tableContentCellStyles)(TableCell); +const TitleCell = withStyles(tableTitleCellStyles, { + name: 'BackstageMetadataTableTitleCell', +})(TableCell); +const ContentCell = withStyles(tableContentCellStyles, { + name: 'BackstageMetadataTableCell', +})(TableCell); export const MetadataTable = ({ dense, @@ -96,14 +108,14 @@ interface StyleProps extends WithStyles { children?: React.ReactNode; } -export const MetadataList = withStyles(listStyles)( - ({ classes, children }: StyleProps) => ( -
    {children}
- ), -); +export const MetadataList = withStyles(listStyles, { + name: 'BackstageMetadataTableList', +})(({ classes, children }: StyleProps) => ( +
    {children}
+)); -export const MetadataListItem = withStyles(listItemStyles)( - ({ classes, children }: StyleProps) => ( -
  • {children}
  • - ), -); +export const MetadataListItem = withStyles(listItemStyles, { + name: 'BackstageMetadataTableListItem', +})(({ classes, children }: StyleProps) => ( +
  • {children}
  • +)); diff --git a/packages/core-components/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx b/packages/core-components/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx index 22a1a43b69..abaa662159 100644 --- a/packages/core-components/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx +++ b/packages/core-components/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx @@ -25,6 +25,8 @@ import { MetadataListItem, } from './MetadataTable'; +export type StructuredMetadataTableListClassKey = 'root'; + const listStyle = createStyles({ root: { margin: '0 0', @@ -32,6 +34,8 @@ const listStyle = createStyles({ }, }); +export type StructuredMetadataTableNestedListClassKey = 'root'; + const nestedListStyle = (theme: Theme) => createStyles({ root: { @@ -44,16 +48,16 @@ interface StyleProps extends WithStyles { children?: React.ReactNode; } // Sub Components -const StyledList = withStyles(listStyle)( - ({ classes, children }: StyleProps) => ( - {children} - ), -); -const StyledNestedList = withStyles(nestedListStyle)( - ({ classes, children }: StyleProps) => ( - {children} - ), -); +const StyledList = withStyles(listStyle, { + name: 'BackstageStructuredMetadataTableList', +})(({ classes, children }: StyleProps) => ( + {children} +)); +const StyledNestedList = withStyles(nestedListStyle, { + name: 'BackstageStructuredMetadataTableNestedList', +})(({ classes, children }: StyleProps) => ( + {children} +)); function renderList(list: Array, nested?: boolean) { const values = list.map((item: any, index: number) => ( diff --git a/packages/core-components/src/components/StructuredMetadataTable/index.tsx b/packages/core-components/src/components/StructuredMetadataTable/index.tsx index 32ba94edd6..855df31e2f 100644 --- a/packages/core-components/src/components/StructuredMetadataTable/index.tsx +++ b/packages/core-components/src/components/StructuredMetadataTable/index.tsx @@ -14,4 +14,14 @@ * limitations under the License. */ +export type { + MetadataTableCellClassKey, + MetadataTableTitleCellClassKey, + MetadataTableListClassKey, + MetadataTableListItemClassKey, +} from './MetadataTable'; export { StructuredMetadataTable } from './StructuredMetadataTable'; +export type { + StructuredMetadataTableListClassKey, + StructuredMetadataTableNestedListClassKey, +} from './StructuredMetadataTable'; diff --git a/packages/core-components/src/components/SupportButton/SupportButton.tsx b/packages/core-components/src/components/SupportButton/SupportButton.tsx index 62d753e08b..bee2928314 100644 --- a/packages/core-components/src/components/SupportButton/SupportButton.tsx +++ b/packages/core-components/src/components/SupportButton/SupportButton.tsx @@ -40,12 +40,17 @@ type SupportButtonProps = { children?: React.ReactNode; }; -const useStyles = makeStyles({ - popoverList: { - minWidth: 260, - maxWidth: 400, +export type SupportButtonClassKey = 'popoverList'; + +const useStyles = makeStyles( + { + popoverList: { + minWidth: 260, + maxWidth: 400, + }, }, -}); + { name: 'BackstageSupportButton' }, +); const SupportIcon = ({ icon }: { icon: string | undefined }) => { const app = useApp(); diff --git a/packages/core-components/src/components/SupportButton/index.ts b/packages/core-components/src/components/SupportButton/index.ts index 57e6103889..6c5bf6475f 100644 --- a/packages/core-components/src/components/SupportButton/index.ts +++ b/packages/core-components/src/components/SupportButton/index.ts @@ -15,3 +15,4 @@ */ export { SupportButton } from './SupportButton'; +export type { SupportButtonClassKey } from './SupportButton'; diff --git a/packages/core-components/src/components/Table/Filters.tsx b/packages/core-components/src/components/Table/Filters.tsx index b482e6c808..7498138751 100644 --- a/packages/core-components/src/components/Table/Filters.tsx +++ b/packages/core-components/src/components/Table/Filters.tsx @@ -18,45 +18,46 @@ import React, { useEffect, useState } from 'react'; import { BackstageTheme } from '@backstage/theme'; import { Button, makeStyles } from '@material-ui/core'; import { Select } from '../Select'; -import { CheckboxTree } from '../CheckboxTree'; -import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree'; import { SelectProps } from '../Select/Select'; -const useSubvalueCellStyles = makeStyles(theme => ({ - root: { - height: '100%', - width: '315px', - display: 'flex', - flexDirection: 'column', - marginRight: theme.spacing(3), - }, - value: { - fontWeight: 'bold', - fontSize: 18, - }, - header: { - display: 'flex', - alignItems: 'center', - height: '60px', - justifyContent: 'space-between', - borderBottom: `1px solid ${theme.palette.grey[500]}`, - }, - filters: { - display: 'flex', - flexDirection: 'column', - '& > *': { - marginTop: theme.spacing(2), +export type TableFiltersClassKey = 'root' | 'value' | 'heder' | 'filters'; + +const useFilterStyles = makeStyles( + theme => ({ + root: { + height: '100%', + width: '315px', + display: 'flex', + flexDirection: 'column', + marginRight: theme.spacing(3), }, - }, -})); + value: { + fontWeight: 'bold', + fontSize: 18, + }, + header: { + display: 'flex', + alignItems: 'center', + height: '60px', + justifyContent: 'space-between', + borderBottom: `1px solid ${theme.palette.grey[500]}`, + }, + filters: { + display: 'flex', + flexDirection: 'column', + '& > *': { + marginTop: theme.spacing(2), + }, + }, + }), + { name: 'BackstageTableFilters' }, +); export type Without = Pick>; export type Filter = { - type: 'select' | /** @deprecated */ 'checkbox-tree' | 'multiple-select'; - element: - | Without - | Without; + type: 'select' | 'multiple-select'; + element: Without; }; export type SelectedFilters = { @@ -70,7 +71,7 @@ type Props = { }; export const Filters = (props: Props) => { - const classes = useSubvalueCellStyles(); + const classes = useFilterStyles(); const { onChangeFilters } = props; @@ -100,57 +101,20 @@ export const Filters = (props: Props) => {
    {props.filters?.length && - props.filters.map(filter => - filter.type === 'checkbox-tree' ? ( - ({ - category: s, - }), - ) - : undefined - } - onChange={el => - setSelectedFilters({ - ...selectedFilters, - [filter.element.label]: el - .filter( - (checkboxFilter: any) => - checkboxFilter.category || - checkboxFilter.selectedChildren.length, - ) - .map((checkboxFilter: any) => - checkboxFilter.category - ? [ - ...checkboxFilter.selectedChildren, - checkboxFilter.category, - ] - : checkboxFilter.selectedChildren, - ) - .flat(), - }) - } - /> - ) : ( - + setSelectedFilters({ + ...selectedFilters, + [filter.element.label]: el as any, + }) + } + /> + ))}
    ); diff --git a/packages/core-components/src/components/Table/SubvalueCell.tsx b/packages/core-components/src/components/Table/SubvalueCell.tsx index fd7d3fd9fa..43fb54fca8 100644 --- a/packages/core-components/src/components/Table/SubvalueCell.tsx +++ b/packages/core-components/src/components/Table/SubvalueCell.tsx @@ -18,15 +18,20 @@ import React from 'react'; import { BackstageTheme } from '@backstage/theme'; import { makeStyles } from '@material-ui/core'; -const useSubvalueCellStyles = makeStyles(theme => ({ - value: { - marginBottom: '6px', - }, - subvalue: { - color: theme.palette.textSubtle, - fontWeight: 'normal', - }, -})); +export type SubvalueCellClassKey = 'value' | 'subvalue'; + +const useSubvalueCellStyles = makeStyles( + theme => ({ + value: { + marginBottom: '6px', + }, + subvalue: { + color: theme.palette.textSubtle, + fontWeight: 'normal', + }, + }), + { name: 'BackstageSubvalueCell' }, +); type SubvalueCellProps = { value: React.ReactNode; diff --git a/packages/core-components/src/components/Table/Table.stories.tsx b/packages/core-components/src/components/Table/Table.stories.tsx index fd05235096..27fd33f32c 100644 --- a/packages/core-components/src/components/Table/Table.stories.tsx +++ b/packages/core-components/src/components/Table/Table.stories.tsx @@ -313,10 +313,6 @@ export const FilterTable = () => { column: 'Column 2', type: 'multiple-select', }, - { - column: 'Numeric value', - type: 'checkbox-tree', - }, ]; return ( diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index ecde7e992b..61e9cc7969 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -56,7 +56,6 @@ import React, { useEffect, useState, } from 'react'; -import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree'; import { SelectProps } from '../Select/Select'; import { Filter, Filters, SelectedFilters, Without } from './Filters'; @@ -101,52 +100,72 @@ function extractValueByField(data: any, field: string): any | undefined { return value; } -const StyledMTableHeader = withStyles(theme => ({ - header: { - padding: theme.spacing(1, 2, 1, 2.5), - borderTop: `1px solid ${theme.palette.grey.A100}`, - borderBottom: `1px solid ${theme.palette.grey.A100}`, - // withStyles hasn't a generic overload for theme - color: (theme as BackstageTheme).palette.textSubtle, - fontWeight: theme.typography.fontWeightBold, - position: 'static', - wordBreak: 'normal', - }, -}))(MTableHeader); +export type TableHeaderClassKey = 'header'; -const StyledMTableToolbar = withStyles(theme => ({ - root: { - padding: theme.spacing(3, 0, 2.5, 2.5), - }, - title: { - '& > h6': { - fontWeight: 'bold', +const StyledMTableHeader = withStyles( + theme => ({ + header: { + padding: theme.spacing(1, 2, 1, 2.5), + borderTop: `1px solid ${theme.palette.grey.A100}`, + borderBottom: `1px solid ${theme.palette.grey.A100}`, + // withStyles hasn't a generic overload for theme + color: (theme as BackstageTheme).palette.textSubtle, + fontWeight: theme.typography.fontWeightBold, + position: 'static', + wordBreak: 'normal', }, - }, - searchField: { - paddingRight: theme.spacing(2), - }, -}))(MTableToolbar); + }), + { name: 'BackstageTableHeader' }, +)(MTableHeader); -const useFilterStyles = makeStyles(() => ({ - root: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - }, - title: { - fontWeight: 'bold', - fontSize: 18, - whiteSpace: 'nowrap', - }, -})); +export type TableToolbarClassKey = 'root' | 'title' | 'searchField'; -const useTableStyles = makeStyles(() => ({ - root: { - display: 'flex', - alignItems: 'start', - }, -})); +const StyledMTableToolbar = withStyles( + theme => ({ + root: { + padding: theme.spacing(3, 0, 2.5, 2.5), + }, + title: { + '& > h6': { + fontWeight: 'bold', + }, + }, + searchField: { + paddingRight: theme.spacing(2), + }, + }), + { name: 'BackstageTableToolbar' }, +)(MTableToolbar); + +export type FiltersContainerClassKey = 'root' | 'title'; + +const useFilterStyles = makeStyles( + () => ({ + root: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + title: { + fontWeight: 'bold', + fontSize: 18, + whiteSpace: 'nowrap', + }, + }), + { name: 'BackstageTableFiltersContainer' }, +); + +export type TableClassKey = 'root'; + +const useTableStyles = makeStyles( + () => ({ + root: { + display: 'flex', + alignItems: 'start', + }, + }), + { name: 'BackstageTable' }, +); function convertColumns( columns: TableColumn[], @@ -191,7 +210,7 @@ export interface TableColumn extends Column { export type TableFilter = { column: string; - type: 'select' | 'multiple-select' | /** @deprecated */ 'checkbox-tree'; + type: 'select' | 'multiple-select'; }; export type TableState = { @@ -365,14 +384,6 @@ export function Table(props: TableProps) { setSelectedFiltersLength(selectedFiltersArray.flat().length); }, [data, selectedFilters, getFieldByTitle]); - // Check for deprecated checkbox-tree filter - useEffect(() => { - if (filters?.some(filter => filter.type === 'checkbox-tree')) { - // eslint-disable-next-line no-console - console.warn('"checkbox-tree" filter type is deprecated'); - } - }, [filters]); - const constructFilters = ( filterConfig: TableFilter[], dataValue: any[] | undefined, @@ -403,16 +414,6 @@ export function Table(props: TableProps) { return distinctValues; }; - const constructCheckboxTree = ( - filter: TableFilter, - ): Without => ({ - label: filter.column, - subCategories: [...extractDistinctValues(filter.column)].map(v => ({ - label: v, - options: [], - })), - }); - const constructSelect = ( filter: TableFilter, ): Without => { @@ -429,10 +430,7 @@ export function Table(props: TableProps) { return filterConfig.map(filter => ({ type: filter.type, - element: - filter.type === 'checkbox-tree' - ? constructCheckboxTree(filter) - : constructSelect(filter), + element: constructSelect(filter), })); }; diff --git a/packages/core-components/src/components/Table/index.ts b/packages/core-components/src/components/Table/index.ts index f46aab42df..7fc245d6d0 100644 --- a/packages/core-components/src/components/Table/index.ts +++ b/packages/core-components/src/components/Table/index.ts @@ -14,6 +14,17 @@ * limitations under the License. */ +export type { TableFiltersClassKey } from './Filters'; export { SubvalueCell } from './SubvalueCell'; +export type { SubvalueCellClassKey } from './SubvalueCell'; export { Table } from './Table'; -export type { TableColumn, TableFilter, TableProps, TableState } from './Table'; +export type { + TableColumn, + TableFilter, + TableProps, + TableState, + TableClassKey, + FiltersContainerClassKey, + TableHeaderClassKey, + TableToolbarClassKey, +} from './Table'; diff --git a/packages/core-components/src/components/Tabs/TabBar.tsx b/packages/core-components/src/components/Tabs/TabBar.tsx index 5fb44d3f09..d0febebc6a 100644 --- a/packages/core-components/src/components/Tabs/TabBar.tsx +++ b/packages/core-components/src/components/Tabs/TabBar.tsx @@ -24,22 +24,27 @@ interface StyledTabsProps { onChange: (event: React.ChangeEvent<{}>, newValue: number) => void; } -const useStyles = makeStyles(theme => ({ - indicator: { - display: 'flex', - justifyContent: 'center', - backgroundColor: theme.palette.tabbar.indicator, - height: '4px', - }, - flexContainer: { - alignItems: 'center', - }, - root: { - '&:last-child': { - marginLeft: 'auto', +export type TabBarClassKey = 'indicator' | 'flexContainer' | 'root'; + +const useStyles = makeStyles( + theme => ({ + indicator: { + display: 'flex', + justifyContent: 'center', + backgroundColor: theme.palette.tabbar.indicator, + height: '4px', }, - }, -})); + flexContainer: { + alignItems: 'center', + }, + root: { + '&:last-child': { + marginLeft: 'auto', + }, + }, + }), + { name: 'BackstageTabBar' }, +); export const StyledTabs = (props: PropsWithChildren) => { const classes = useStyles(props); diff --git a/packages/core-components/src/components/Tabs/TabIcon.tsx b/packages/core-components/src/components/Tabs/TabIcon.tsx index dde01835a3..37b38217a3 100644 --- a/packages/core-components/src/components/Tabs/TabIcon.tsx +++ b/packages/core-components/src/components/Tabs/TabIcon.tsx @@ -25,22 +25,27 @@ interface StyledIconProps { onClick: any; } -const useStyles = makeStyles(() => ({ - root: { - color: '#6E6E6E', - overflow: 'visible', - fontSize: '1.5rem', - textAlign: 'center', - borderRadius: '50%', - backgroundColor: '#E6E6E6', - marginLeft: props => (props.isNext ? 'auto' : '0'), - marginRight: props => (props.isNext ? '0' : '10px'), - '&:hover': { +export type TabIconClassKey = 'root'; + +const useStyles = makeStyles( + () => ({ + root: { + color: '#6E6E6E', + overflow: 'visible', + fontSize: '1.5rem', + textAlign: 'center', + borderRadius: '50%', backgroundColor: '#E6E6E6', - opacity: '1', + marginLeft: props => (props.isNext ? 'auto' : '0'), + marginRight: props => (props.isNext ? '0' : '10px'), + '&:hover': { + backgroundColor: '#E6E6E6', + opacity: '1', + }, }, - }, -})); + }), + { name: 'BackstageTabIcon' }, +); export const StyledIcon = (props: StyledIconProps) => { const classes = useStyles(props); diff --git a/packages/core-components/src/components/Tabs/Tabs.tsx b/packages/core-components/src/components/Tabs/Tabs.tsx index 844f54a543..d60f3ff639 100644 --- a/packages/core-components/src/components/Tabs/Tabs.tsx +++ b/packages/core-components/src/components/Tabs/Tabs.tsx @@ -42,21 +42,26 @@ export interface TabsProps { tabs: TabProps[]; } -const useStyles = makeStyles(theme => ({ - root: { - flexGrow: 1, - width: '100%', - }, - styledTabs: { - backgroundColor: theme.palette.background.paper, - }, - appbar: { - boxShadow: 'none', - backgroundColor: theme.palette.background.paper, - paddingLeft: '10px', - paddingRight: '10px', - }, -})); +export type TabsClassKey = 'root' | 'styledTabs' | 'appbar'; + +const useStyles = makeStyles( + theme => ({ + root: { + flexGrow: 1, + width: '100%', + }, + styledTabs: { + backgroundColor: theme.palette.background.paper, + }, + appbar: { + boxShadow: 'none', + backgroundColor: theme.palette.background.paper, + paddingLeft: '10px', + paddingRight: '10px', + }, + }), + { name: 'BackstageTabs' }, +); export function Tabs(props: TabsProps) { const { tabs } = props; diff --git a/packages/core-components/src/components/Tabs/index.ts b/packages/core-components/src/components/Tabs/index.ts index ae9e0b7486..015118635c 100644 --- a/packages/core-components/src/components/Tabs/index.ts +++ b/packages/core-components/src/components/Tabs/index.ts @@ -15,3 +15,7 @@ */ export { Tabs } from './Tabs'; +export type { TabsClassKey } from './Tabs'; + +export type { TabBarClassKey } from './TabBar'; +export type { TabIconClassKey } from './TabIcon'; diff --git a/packages/core-components/src/components/WarningPanel/WarningPanel.tsx b/packages/core-components/src/components/WarningPanel/WarningPanel.tsx index 536ab066b3..bf845617c9 100644 --- a/packages/core-components/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core-components/src/components/WarningPanel/WarningPanel.tsx @@ -64,56 +64,66 @@ const ExpandMoreIconStyled = ({ severity }: Pick) => { return ; }; -const useStyles = makeStyles(theme => ({ - panel: { - backgroundColor: ({ severity }: WarningProps) => - getWarningBackgroundColor( - severity as NonNullable, - theme, - ), - color: ({ severity }: WarningProps) => - getWarningTextColor( - severity as NonNullable, - theme, - ), - verticalAlign: 'middle', - }, - summary: { - display: 'flex', - flexDirection: 'row', - }, - summaryText: { - color: ({ severity }: WarningProps) => - getWarningTextColor( - severity as NonNullable, - theme, - ), - fontWeight: 'bold', - }, - message: { - width: '100%', - display: 'block', - color: ({ severity }: WarningProps) => - getWarningTextColor( - severity as NonNullable, - theme, - ), - backgroundColor: ({ severity }: WarningProps) => - getWarningBackgroundColor( - severity as NonNullable, - theme, - ), - }, - details: { - width: '100%', - display: 'block', - color: theme.palette.textContrast, - backgroundColor: theme.palette.background.default, - border: `1px solid ${theme.palette.border}`, - padding: theme.spacing(2.0), - fontFamily: 'sans-serif', - }, -})); +export type WarningPanelClassKey = + | 'panel' + | 'summary' + | 'summaryText' + | 'message' + | 'details'; + +const useStyles = makeStyles( + theme => ({ + panel: { + backgroundColor: ({ severity }: WarningProps) => + getWarningBackgroundColor( + severity as NonNullable, + theme, + ), + color: ({ severity }: WarningProps) => + getWarningTextColor( + severity as NonNullable, + theme, + ), + verticalAlign: 'middle', + }, + summary: { + display: 'flex', + flexDirection: 'row', + }, + summaryText: { + color: ({ severity }: WarningProps) => + getWarningTextColor( + severity as NonNullable, + theme, + ), + fontWeight: 'bold', + }, + message: { + width: '100%', + display: 'block', + color: ({ severity }: WarningProps) => + getWarningTextColor( + severity as NonNullable, + theme, + ), + backgroundColor: ({ severity }: WarningProps) => + getWarningBackgroundColor( + severity as NonNullable, + theme, + ), + }, + details: { + width: '100%', + display: 'block', + color: theme.palette.textContrast, + backgroundColor: theme.palette.background.default, + border: `1px solid ${theme.palette.border}`, + padding: theme.spacing(2.0), + fontFamily: 'sans-serif', + }, + }), + { name: 'BackstageWarningPanel' }, +); export type WarningProps = { title?: string; diff --git a/packages/core-components/src/components/WarningPanel/index.ts b/packages/core-components/src/components/WarningPanel/index.ts index 07f7acca42..866104795f 100644 --- a/packages/core-components/src/components/WarningPanel/index.ts +++ b/packages/core-components/src/components/WarningPanel/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { WarningPanel } from './WarningPanel'; +export type { WarningPanelClassKey } from './WarningPanel'; diff --git a/packages/core-components/src/index.ts b/packages/core-components/src/index.ts index af577ce79a..3c5e708360 100644 --- a/packages/core-components/src/index.ts +++ b/packages/core-components/src/index.ts @@ -24,3 +24,4 @@ export * from './components'; export * from './hooks'; export * from './icons'; export * from './layout'; +export * from './overridableComponents'; diff --git a/packages/core-components/src/layout/BottomLink/BottomLink.tsx b/packages/core-components/src/layout/BottomLink/BottomLink.tsx index 6d6811cc09..cb168600e5 100644 --- a/packages/core-components/src/layout/BottomLink/BottomLink.tsx +++ b/packages/core-components/src/layout/BottomLink/BottomLink.tsx @@ -21,19 +21,24 @@ import { BackstageTheme } from '@backstage/theme'; import Box from '@material-ui/core/Box'; import { Link } from '../../components/Link'; -const useStyles = makeStyles(theme => ({ - root: { - maxWidth: 'fit-content', - padding: theme.spacing(2, 2, 2, 2.5), - }, - boxTitle: { - margin: 0, - color: theme.palette.textSubtle, - }, - arrow: { - color: theme.palette.textSubtle, - }, -})); +export type BottomLinkClassKey = 'root' | 'boxTitle' | 'arrow'; + +const useStyles = makeStyles( + theme => ({ + root: { + maxWidth: 'fit-content', + padding: theme.spacing(2, 2, 2, 2.5), + }, + boxTitle: { + margin: 0, + color: theme.palette.textSubtle, + }, + arrow: { + color: theme.palette.textSubtle, + }, + }), + { name: 'BackstageBottomLink' }, +); export type BottomLinkProps = { link: string; diff --git a/packages/core-components/src/layout/BottomLink/index.ts b/packages/core-components/src/layout/BottomLink/index.ts index 853c74c880..e0252a5bb4 100644 --- a/packages/core-components/src/layout/BottomLink/index.ts +++ b/packages/core-components/src/layout/BottomLink/index.ts @@ -15,4 +15,4 @@ */ export { BottomLink } from './BottomLink'; -export type { BottomLinkProps } from './BottomLink'; +export type { BottomLinkClassKey, BottomLinkProps } from './BottomLink'; diff --git a/packages/core-components/src/layout/Breadcrumbs/Breadcrumbs.tsx b/packages/core-components/src/layout/Breadcrumbs/Breadcrumbs.tsx index 28c86d32f0..26304f9e2b 100644 --- a/packages/core-components/src/layout/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/core-components/src/layout/Breadcrumbs/Breadcrumbs.tsx @@ -27,19 +27,29 @@ import React, { ComponentProps, Fragment } from 'react'; type Props = ComponentProps; -const ClickableText = withStyles({ - root: { - textDecoration: 'underline', - cursor: 'pointer', - }, -})(Typography); +export type BreadcrumbsClickableTextClassKey = 'root'; -const StyledBox = withStyles({ - root: { - textDecoration: 'underline', - color: 'inherit', +const ClickableText = withStyles( + { + root: { + textDecoration: 'underline', + cursor: 'pointer', + }, }, -})(Box); + { name: 'BackstageBreadcrumbsClickableText' }, +)(Typography); + +export type BreadcrumbsStyledBoxClassKey = 'root'; + +const StyledBox = withStyles( + { + root: { + textDecoration: 'underline', + color: 'inherit', + }, + }, + { name: 'BackstageBreadcrumbsStyledBox' }, +)(Box); export function Breadcrumbs(props: Props) { const { children, ...restProps } = props; diff --git a/packages/core-components/src/layout/Breadcrumbs/index.ts b/packages/core-components/src/layout/Breadcrumbs/index.ts index 31bae893a4..3cbce4164a 100644 --- a/packages/core-components/src/layout/Breadcrumbs/index.ts +++ b/packages/core-components/src/layout/Breadcrumbs/index.ts @@ -15,3 +15,7 @@ */ export { Breadcrumbs } from './Breadcrumbs'; +export type { + BreadcrumbsClickableTextClassKey, + BreadcrumbsStyledBoxClassKey, +} from './Breadcrumbs'; diff --git a/packages/core-components/src/layout/Content/Content.tsx b/packages/core-components/src/layout/Content/Content.tsx index a3771dff3f..588c9fd630 100644 --- a/packages/core-components/src/layout/Content/Content.tsx +++ b/packages/core-components/src/layout/Content/Content.tsx @@ -18,28 +18,33 @@ import React, { PropsWithChildren } from 'react'; import classNames from 'classnames'; import { Theme, makeStyles } from '@material-ui/core'; -const useStyles = makeStyles((theme: Theme) => ({ - root: { - gridArea: 'pageContent', - minWidth: 0, - paddingTop: theme.spacing(3), - paddingBottom: theme.spacing(3), - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - [theme.breakpoints.up('sm')]: { - paddingLeft: theme.spacing(3), - paddingRight: theme.spacing(3), +export type BackstageContentClassKey = 'root' | 'stretch' | 'noPadding'; + +const useStyles = makeStyles( + (theme: Theme) => ({ + root: { + gridArea: 'pageContent', + minWidth: 0, + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + [theme.breakpoints.up('sm')]: { + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), + }, }, - }, - stretch: { - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - }, - noPadding: { - padding: 0, - }, -})); + stretch: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + }, + noPadding: { + padding: 0, + }, + }), + { name: 'BackstageContent' }, +); type Props = { stretch?: boolean; diff --git a/packages/core-components/src/layout/Content/index.ts b/packages/core-components/src/layout/Content/index.ts index a94ceabdfa..df7bd00b52 100644 --- a/packages/core-components/src/layout/Content/index.ts +++ b/packages/core-components/src/layout/Content/index.ts @@ -15,3 +15,4 @@ */ export { Content } from './Content'; +export type { BackstageContentClassKey } from './Content'; diff --git a/packages/core-components/src/layout/ContentHeader/ContentHeader.tsx b/packages/core-components/src/layout/ContentHeader/ContentHeader.tsx index 76fec703a9..c3faec64a2 100644 --- a/packages/core-components/src/layout/ContentHeader/ContentHeader.tsx +++ b/packages/core-components/src/layout/ContentHeader/ContentHeader.tsx @@ -22,39 +22,49 @@ import React, { PropsWithChildren, ReactNode } from 'react'; import { Typography, makeStyles } from '@material-ui/core'; import { Helmet } from 'react-helmet'; +export type ContentHeaderClassKey = + | 'container' + | 'leftItemsBox' + | 'rightItemsBox' + | 'description' + | 'title'; + const useStyles = (props: ContentHeaderProps) => - makeStyles(theme => ({ - container: { - width: '100%', - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'flex-end', - alignItems: 'center', - marginBottom: theme.spacing(2), - textAlign: props.textAlign, - }, - leftItemsBox: { - flex: '1 1 auto', - minWidth: 0, - overflow: 'visible', - }, - rightItemsBox: { - flex: '0 1 auto', - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - marginLeft: theme.spacing(1), - minWidth: 0, - overflow: 'visible', - }, - description: {}, - title: { - display: 'inline-flex', - marginBottom: 0, - }, - })); + makeStyles( + theme => ({ + container: { + width: '100%', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-end', + alignItems: 'center', + marginBottom: theme.spacing(2), + textAlign: props.textAlign, + }, + leftItemsBox: { + flex: '1 1 auto', + minWidth: 0, + overflow: 'visible', + }, + rightItemsBox: { + flex: '0 1 auto', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + marginLeft: theme.spacing(1), + minWidth: 0, + overflow: 'visible', + }, + description: {}, + title: { + display: 'inline-flex', + marginBottom: 0, + }, + }), + { name: 'BackstageContentHeader' }, + ); type ContentHeaderTitleProps = { title?: string; diff --git a/packages/core-components/src/layout/ContentHeader/index.ts b/packages/core-components/src/layout/ContentHeader/index.ts index 537a2b6ed9..3a55db4e22 100644 --- a/packages/core-components/src/layout/ContentHeader/index.ts +++ b/packages/core-components/src/layout/ContentHeader/index.ts @@ -15,3 +15,4 @@ */ export { ContentHeader } from './ContentHeader'; +export type { ContentHeaderClassKey } from './ContentHeader'; diff --git a/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx b/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx index f4b2960f96..79aaaebfc6 100644 --- a/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx +++ b/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx @@ -28,24 +28,29 @@ interface IErrorPageProps { additionalInfo?: string; } -const useStyles = makeStyles(theme => ({ - container: { - padding: theme.spacing(8), - [theme.breakpoints.down('xs')]: { - padding: theme.spacing(2), +export type ErrorPageClassKey = 'container' | 'title' | 'subtitle'; + +const useStyles = makeStyles( + theme => ({ + container: { + padding: theme.spacing(8), + [theme.breakpoints.down('xs')]: { + padding: theme.spacing(2), + }, }, - }, - title: { - paddingBottom: theme.spacing(5), - [theme.breakpoints.down('xs')]: { - paddingBottom: theme.spacing(4), - fontSize: 32, + title: { + paddingBottom: theme.spacing(5), + [theme.breakpoints.down('xs')]: { + paddingBottom: theme.spacing(4), + fontSize: 32, + }, }, - }, - subtitle: { - color: theme.palette.textSubtle, - }, -})); + subtitle: { + color: theme.palette.textSubtle, + }, + }), + { name: 'BackstageErrorPage' }, +); export function ErrorPage(props: IErrorPageProps) { const { status, statusMessage, additionalInfo } = props; @@ -57,7 +62,11 @@ export function ErrorPage(props: IErrorPageProps) { - + ERROR {status}: {statusMessage} diff --git a/packages/core-components/src/layout/ErrorPage/MicDrop.tsx b/packages/core-components/src/layout/ErrorPage/MicDrop.tsx index 666eea4f09..5500994e4f 100644 --- a/packages/core-components/src/layout/ErrorPage/MicDrop.tsx +++ b/packages/core-components/src/layout/ErrorPage/MicDrop.tsx @@ -18,21 +18,26 @@ import React from 'react'; import { makeStyles } from '@material-ui/core'; import MicDropSvgUrl from './mic-drop.svg'; -const useStyles = makeStyles(theme => ({ - micDrop: { - maxWidth: '60%', - position: 'absolute', - bottom: theme.spacing(2), - right: theme.spacing(2), - [theme.breakpoints.down('xs')]: { - maxWidth: '96%', - position: 'relative', - bottom: 'unset', - right: 'unset', - margin: `${theme.spacing(10)}px auto ${theme.spacing(4)}px`, +const useStyles = makeStyles( + theme => ({ + micDrop: { + maxWidth: '60%', + position: 'absolute', + bottom: theme.spacing(2), + right: theme.spacing(2), + [theme.breakpoints.down('xs')]: { + maxWidth: '96%', + position: 'relative', + bottom: 'unset', + right: 'unset', + margin: `${theme.spacing(10)}px auto ${theme.spacing(4)}px`, + }, }, - }, -})); + }), + { name: 'BackstageErrorPageMicDrop' }, +); + +export type MicDropClassKey = 'micDrop'; export const MicDrop = () => { const classes = useStyles(); diff --git a/packages/core-components/src/layout/ErrorPage/index.ts b/packages/core-components/src/layout/ErrorPage/index.ts index a9a5fc9b2a..429b467444 100644 --- a/packages/core-components/src/layout/ErrorPage/index.ts +++ b/packages/core-components/src/layout/ErrorPage/index.ts @@ -15,3 +15,5 @@ */ export { ErrorPage } from './ErrorPage'; +export type { ErrorPageClassKey } from './ErrorPage'; +export type { MicDropClassKey } from './MicDrop'; diff --git a/packages/core-components/src/layout/Header/Header.tsx b/packages/core-components/src/layout/Header/Header.tsx index 3f83bcc843..535e3c4ed8 100644 --- a/packages/core-components/src/layout/Header/Header.tsx +++ b/packages/core-components/src/layout/Header/Header.tsx @@ -22,64 +22,78 @@ import { Helmet } from 'react-helmet'; import { Link } from '../../components/Link'; import { Breadcrumbs } from '../Breadcrumbs'; -const useStyles = makeStyles(theme => ({ - header: { - gridArea: 'pageHeader', - padding: theme.spacing(3), - width: '100%', - boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', - position: 'relative', - zIndex: 100, - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - alignItems: 'center', - backgroundImage: theme.page.backgroundImage, - backgroundPosition: 'center', - backgroundSize: 'cover', - }, - leftItemsBox: { - maxWidth: '100%', - flexGrow: 1, - }, - rightItemsBox: { - width: 'auto', - }, - title: { - color: theme.palette.bursts.fontColor, - wordBreak: 'break-all', - fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', - marginBottom: 0, - }, - subtitle: { - color: 'rgba(255, 255, 255, 0.8)', - lineHeight: '1.0em', - display: 'inline-block', // prevents margin collapse of adjacent siblings - marginTop: theme.spacing(1), - }, - type: { - textTransform: 'uppercase', - fontSize: 11, - opacity: 0.8, - marginBottom: theme.spacing(1), - color: theme.palette.bursts.fontColor, - }, - breadcrumb: { - fontSize: 'calc(15px + 1 * ((100vw - 320px) / 680))', - color: theme.palette.bursts.fontColor, - }, - breadcrumbType: { - fontSize: 'inherit', - opacity: 0.7, - marginRight: -theme.spacing(0.3), - marginBottom: theme.spacing(0.3), - }, - breadcrumbTitle: { - fontSize: 'inherit', - marginLeft: -theme.spacing(0.3), - marginBottom: theme.spacing(0.3), - }, -})); +export type HeaderClassKey = + | 'header' + | 'leftItemsBox' + | 'rightItemsBox' + | 'title' + | 'subtitle' + | 'type' + | 'breadcrumb' + | 'breadcrumbType' + | 'breadcrumbTitle'; + +const useStyles = makeStyles( + theme => ({ + header: { + gridArea: 'pageHeader', + padding: theme.spacing(3), + width: '100%', + boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', + position: 'relative', + zIndex: 100, + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + backgroundImage: theme.page.backgroundImage, + backgroundPosition: 'center', + backgroundSize: 'cover', + }, + leftItemsBox: { + maxWidth: '100%', + flexGrow: 1, + }, + rightItemsBox: { + width: 'auto', + }, + title: { + color: theme.palette.bursts.fontColor, + wordBreak: 'break-all', + fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', + marginBottom: 0, + }, + subtitle: { + color: 'rgba(255, 255, 255, 0.8)', + lineHeight: '1.0em', + display: 'inline-block', // prevents margin collapse of adjacent siblings + marginTop: theme.spacing(1), + }, + type: { + textTransform: 'uppercase', + fontSize: 11, + opacity: 0.8, + marginBottom: theme.spacing(1), + color: theme.palette.bursts.fontColor, + }, + breadcrumb: { + fontSize: 'calc(15px + 1 * ((100vw - 320px) / 680))', + color: theme.palette.bursts.fontColor, + }, + breadcrumbType: { + fontSize: 'inherit', + opacity: 0.7, + marginRight: -theme.spacing(0.3), + marginBottom: theme.spacing(0.3), + }, + breadcrumbTitle: { + fontSize: 'inherit', + marginLeft: -theme.spacing(0.3), + marginBottom: theme.spacing(0.3), + }, + }), + { name: 'BackstageHeader' }, +); type HeaderStyles = ReturnType; diff --git a/packages/core-components/src/layout/Header/index.ts b/packages/core-components/src/layout/Header/index.ts index 2c322fe9c1..d705b8de74 100644 --- a/packages/core-components/src/layout/Header/index.ts +++ b/packages/core-components/src/layout/Header/index.ts @@ -15,3 +15,4 @@ */ export { Header } from './Header'; +export type { HeaderClassKey } from './Header'; diff --git a/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx b/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx index 88f6e26bf5..5071971f48 100644 --- a/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx +++ b/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx @@ -17,24 +17,29 @@ import { Link, makeStyles, Typography, Grid } from '@material-ui/core'; import React from 'react'; -const useStyles = makeStyles(theme => ({ - root: { - textAlign: 'left', - }, - label: { - color: theme.palette.common.white, - fontWeight: 'bold', - letterSpacing: 0, - fontSize: theme.typography.fontSize, - marginBottom: theme.spacing(1) / 2, - lineHeight: 1, - }, - value: { - color: 'rgba(255, 255, 255, 0.8)', - fontSize: theme.typography.fontSize, - lineHeight: 1, - }, -})); +export type HeaderLabelClassKey = 'root' | 'label' | 'value'; + +const useStyles = makeStyles( + theme => ({ + root: { + textAlign: 'left', + }, + label: { + color: theme.palette.common.white, + fontWeight: 'bold', + letterSpacing: 0, + fontSize: theme.typography.fontSize, + marginBottom: theme.spacing(1) / 2, + lineHeight: 1, + }, + value: { + color: 'rgba(255, 255, 255, 0.8)', + fontSize: theme.typography.fontSize, + lineHeight: 1, + }, + }), + { name: 'BackstageHeaderLabel' }, +); type HeaderLabelContentProps = { value: React.ReactNode; diff --git a/packages/core-components/src/layout/HeaderLabel/index.ts b/packages/core-components/src/layout/HeaderLabel/index.ts index cc803e7fde..df8dcaf84c 100644 --- a/packages/core-components/src/layout/HeaderLabel/index.ts +++ b/packages/core-components/src/layout/HeaderLabel/index.ts @@ -15,3 +15,4 @@ */ export { HeaderLabel } from './HeaderLabel'; +export type { HeaderLabelClassKey } from './HeaderLabel'; diff --git a/packages/core-components/src/layout/HeaderTabs/HeaderTabs.tsx b/packages/core-components/src/layout/HeaderTabs/HeaderTabs.tsx index 0fe3cd967e..b34af3eb83 100644 --- a/packages/core-components/src/layout/HeaderTabs/HeaderTabs.tsx +++ b/packages/core-components/src/layout/HeaderTabs/HeaderTabs.tsx @@ -20,29 +20,38 @@ import React, { useState, useEffect } from 'react'; import { makeStyles, Tabs, Tab as TabUI, TabProps } from '@material-ui/core'; -const useStyles = makeStyles(theme => ({ - tabsWrapper: { - gridArea: 'pageSubheader', - backgroundColor: theme.palette.background.paper, - paddingLeft: theme.spacing(3), - }, - defaultTab: { - padding: theme.spacing(3, 3), - ...theme.typography.caption, - textTransform: 'uppercase', - fontWeight: 'bold', - color: theme.palette.text.secondary, - }, - selected: { - color: theme.palette.text.primary, - }, - tabRoot: { - '&:hover': { - backgroundColor: theme.palette.background.default, +export type HeaderTabsClassKey = + | 'tabsWrapper' + | 'defaultTab' + | 'selected' + | 'tabRoot'; + +const useStyles = makeStyles( + theme => ({ + tabsWrapper: { + gridArea: 'pageSubheader', + backgroundColor: theme.palette.background.paper, + paddingLeft: theme.spacing(3), + }, + defaultTab: { + padding: theme.spacing(3, 3), + ...theme.typography.caption, + textTransform: 'uppercase', + fontWeight: 'bold', + color: theme.palette.text.secondary, + }, + selected: { color: theme.palette.text.primary, }, - }, -})); + tabRoot: { + '&:hover': { + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + }, + }, + }), + { name: 'BackstageHeaderTabs' }, +); export type Tab = { id: string; @@ -88,6 +97,7 @@ export function HeaderTabs(props: HeaderTabsProps) { {tabs.map((tab, index) => ( ({ - noPadding: { - padding: 0, - '&:last-child': { - paddingBottom: 0, - }, - }, - header: { - padding: theme.spacing(2, 2, 2, 2.5), - }, - headerTitle: { - fontWeight: 700, - }, - headerSubheader: { - paddingTop: theme.spacing(1), - }, - headerAvatar: {}, - headerAction: {}, - headerContent: {}, -})); +export type InfoCardClassKey = + | 'noPadding' + | 'header' + | 'headerTitle' + | 'headerSubheader' + | 'headerAvatar' + | 'headerAction' + | 'headerContent'; -const CardActionsTopRight = withStyles(theme => ({ - root: { - display: 'inline-block', - padding: theme.spacing(8, 8, 0, 0), - float: 'right', - }, -}))(CardActions); +const useStyles = makeStyles( + theme => ({ + noPadding: { + padding: 0, + '&:last-child': { + paddingBottom: 0, + }, + }, + header: { + padding: theme.spacing(2, 2, 2, 2.5), + }, + headerTitle: { + fontWeight: 700, + }, + headerSubheader: { + paddingTop: theme.spacing(1), + }, + headerAvatar: {}, + headerAction: {}, + headerContent: {}, + }), + { name: 'BackstageInfoCard' }, +); + +export type CardActionsTopRightClassKey = 'root'; + +const CardActionsTopRight = withStyles( + theme => ({ + root: { + display: 'inline-block', + padding: theme.spacing(8, 8, 0, 0), + float: 'right', + }, + }), + { name: 'BackstageInfoCardCardActionsTopRight' }, +)(CardActions); const VARIANT_STYLES = { card: { diff --git a/packages/core-components/src/layout/InfoCard/index.ts b/packages/core-components/src/layout/InfoCard/index.ts index cab443d9b2..1be4c01828 100644 --- a/packages/core-components/src/layout/InfoCard/index.ts +++ b/packages/core-components/src/layout/InfoCard/index.ts @@ -15,4 +15,8 @@ */ export { InfoCard } from './InfoCard'; -export type { InfoCardVariants } from './InfoCard'; +export type { + InfoCardVariants, + InfoCardClassKey, + CardActionsTopRightClassKey, +} from './InfoCard'; diff --git a/packages/core-components/src/layout/ItemCard/ItemCardGrid.tsx b/packages/core-components/src/layout/ItemCard/ItemCardGrid.tsx index 137c48d272..739398252e 100644 --- a/packages/core-components/src/layout/ItemCard/ItemCardGrid.tsx +++ b/packages/core-components/src/layout/ItemCard/ItemCardGrid.tsx @@ -17,6 +17,8 @@ import { createStyles, makeStyles, Theme, WithStyles } from '@material-ui/core'; import React from 'react'; +export type ItemCardGridClassKey = 'root'; + const styles = (theme: Theme) => createStyles({ root: { @@ -27,7 +29,7 @@ const styles = (theme: Theme) => }, }); -const useStyles = makeStyles(styles); +const useStyles = makeStyles(styles, { name: 'BackstageItemCardGrid' }); export type ItemCardGridProps = Partial> & { /** diff --git a/packages/core-components/src/layout/ItemCard/ItemCardHeader.tsx b/packages/core-components/src/layout/ItemCard/ItemCardHeader.tsx index 4dbc5d7c1a..7ad6732e02 100644 --- a/packages/core-components/src/layout/ItemCard/ItemCardHeader.tsx +++ b/packages/core-components/src/layout/ItemCard/ItemCardHeader.tsx @@ -23,6 +23,8 @@ import { import React from 'react'; import { BackstageTheme } from '@backstage/theme'; +export type ItemCardHeaderClassKey = 'root'; + const styles = (theme: BackstageTheme) => createStyles({ root: { @@ -34,7 +36,7 @@ const styles = (theme: BackstageTheme) => }, }); -const useStyles = makeStyles(styles); +const useStyles = makeStyles(styles, { name: 'BackstageItemCardHeader' }); export type ItemCardHeaderProps = Partial> & { /** diff --git a/packages/core-components/src/layout/ItemCard/index.ts b/packages/core-components/src/layout/ItemCard/index.ts index 7c4f77f43a..f69374c03d 100644 --- a/packages/core-components/src/layout/ItemCard/index.ts +++ b/packages/core-components/src/layout/ItemCard/index.ts @@ -16,6 +16,9 @@ export { ItemCard } from './ItemCard'; export { ItemCardGrid } from './ItemCardGrid'; -export type { ItemCardGridProps } from './ItemCardGrid'; +export type { ItemCardGridProps, ItemCardGridClassKey } from './ItemCardGrid'; export { ItemCardHeader } from './ItemCardHeader'; -export type { ItemCardHeaderProps } from './ItemCardHeader'; +export type { + ItemCardHeaderProps, + ItemCardHeaderClassKey, +} from './ItemCardHeader'; diff --git a/packages/core-components/src/layout/Page/Page.tsx b/packages/core-components/src/layout/Page/Page.tsx index b838693a3c..37d6c663f2 100644 --- a/packages/core-components/src/layout/Page/Page.tsx +++ b/packages/core-components/src/layout/Page/Page.tsx @@ -18,17 +18,22 @@ import React, { PropsWithChildren } from 'react'; import { BackstageTheme } from '@backstage/theme'; import { makeStyles, ThemeProvider } from '@material-ui/core'; -const useStyles = makeStyles(() => ({ - root: { - display: 'grid', - gridTemplateAreas: - "'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'", - gridTemplateRows: 'max-content auto 1fr', - gridTemplateColumns: 'auto 1fr auto', - height: '100vh', - overflowY: 'auto', - }, -})); +export type PageClassKey = 'root'; + +const useStyles = makeStyles( + () => ({ + root: { + display: 'grid', + gridTemplateAreas: + "'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'", + gridTemplateRows: 'max-content auto 1fr', + gridTemplateColumns: 'auto 1fr auto', + height: '100vh', + overflowY: 'auto', + }, + }), + { name: 'BackstagePage' }, +); type Props = { themeId: string; diff --git a/packages/core-components/src/layout/Page/index.ts b/packages/core-components/src/layout/Page/index.ts index 91db73657e..4022075c7b 100644 --- a/packages/core-components/src/layout/Page/index.ts +++ b/packages/core-components/src/layout/Page/index.ts @@ -15,4 +15,5 @@ */ export { Page } from './Page'; +export type { PageClassKey } from './Page'; export { PageWithHeader } from './PageWithHeader'; diff --git a/packages/core-components/src/layout/Sidebar/Bar.tsx b/packages/core-components/src/layout/Sidebar/Bar.tsx index 8855f01104..750ec6bbdc 100644 --- a/packages/core-components/src/layout/Sidebar/Bar.tsx +++ b/packages/core-components/src/layout/Sidebar/Bar.tsx @@ -21,47 +21,52 @@ import { sidebarConfig, SidebarContext } from './config'; import { BackstageTheme } from '@backstage/theme'; import { SidebarPinStateContext } from './Page'; -const useStyles = makeStyles(theme => ({ - root: { - zIndex: 1000, - position: 'relative', - overflow: 'visible', - width: theme.spacing(7) + 1, - }, - drawer: { - display: 'flex', - flexFlow: 'column nowrap', - alignItems: 'flex-start', - position: 'fixed', - left: 0, - top: 0, - bottom: 0, - padding: 0, - background: theme.palette.navigation.background, - overflowX: 'hidden', - msOverflowStyle: 'none', - scrollbarWidth: 'none', - width: sidebarConfig.drawerWidthClosed, - borderRight: `1px solid #383838`, - transition: theme.transitions.create('width', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.shortest, - }), - '& > *': { - flexShrink: 0, +export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen'; + +const useStyles = makeStyles( + theme => ({ + root: { + zIndex: 1000, + position: 'relative', + overflow: 'visible', + width: theme.spacing(7) + 1, }, - '&::-webkit-scrollbar': { - display: 'none', + drawer: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'flex-start', + position: 'fixed', + left: 0, + top: 0, + bottom: 0, + padding: 0, + background: theme.palette.navigation.background, + overflowX: 'hidden', + msOverflowStyle: 'none', + scrollbarWidth: 'none', + width: sidebarConfig.drawerWidthClosed, + borderRight: `1px solid #383838`, + transition: theme.transitions.create('width', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.shortest, + }), + '& > *': { + flexShrink: 0, + }, + '&::-webkit-scrollbar': { + display: 'none', + }, }, - }, - drawerOpen: { - width: sidebarConfig.drawerWidthOpen, - transition: theme.transitions.create('width', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.shorter, - }), - }, -})); + drawerOpen: { + width: sidebarConfig.drawerWidthOpen, + transition: theme.transitions.create('width', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.shorter, + }), + }, + }), + { name: 'BackstageSidebar' }, +); enum State { Closed, diff --git a/packages/core-components/src/layout/Sidebar/Intro.tsx b/packages/core-components/src/layout/Sidebar/Intro.tsx index 45098b49e1..70b952588d 100644 --- a/packages/core-components/src/layout/Sidebar/Intro.tsx +++ b/packages/core-components/src/layout/Sidebar/Intro.tsx @@ -26,48 +26,58 @@ import { } from './config'; import { SidebarDivider } from './Items'; -const useStyles = makeStyles(theme => ({ - introCard: { - color: '#b5b5b5', - // XXX (@koroeskohr): should I be using a Mui theme variable? - fontSize: 12, - width: sidebarConfig.drawerWidthOpen, - marginTop: 18, - marginBottom: 12, - paddingLeft: sidebarConfig.iconPadding, - paddingRight: sidebarConfig.iconPadding, - }, - introDismiss: { - display: 'flex', - justifyContent: 'flex-end', - alignItems: 'center', - marginTop: 12, - }, - introDismissLink: { - color: '#dddddd', - display: 'flex', - alignItems: 'center', - marginBottom: 4, - '&:hover': { - color: theme.palette.linkHover, - transition: theme.transitions.create('color', { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.shortest, - }), +export type SidebarIntroClassKey = + | 'introCard' + | 'introDismiss' + | 'introDismissLink' + | 'introDismissText' + | 'introDismissIcon'; + +const useStyles = makeStyles( + theme => ({ + introCard: { + color: '#b5b5b5', + // XXX (@koroeskohr): should I be using a Mui theme variable? + fontSize: 12, + width: sidebarConfig.drawerWidthOpen, + marginTop: 18, + marginBottom: 12, + paddingLeft: sidebarConfig.iconPadding, + paddingRight: sidebarConfig.iconPadding, }, - }, - introDismissText: { - fontSize: '0.7rem', - fontWeight: 'bold', - textTransform: 'uppercase', - letterSpacing: 1, - }, - introDismissIcon: { - width: 18, - height: 18, - marginRight: 12, - }, -})); + introDismiss: { + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + marginTop: 12, + }, + introDismissLink: { + color: '#dddddd', + display: 'flex', + alignItems: 'center', + marginBottom: 4, + '&:hover': { + color: theme.palette.linkHover, + transition: theme.transitions.create('color', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.shortest, + }), + }, + }, + introDismissText: { + fontSize: '0.7rem', + fontWeight: 'bold', + textTransform: 'uppercase', + letterSpacing: 1, + }, + introDismissIcon: { + width: 18, + height: 18, + marginRight: 12, + }, + }), + { name: 'BackstageSidebarIntro' }, +); type IntroCardProps = { text: string; diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index b3acc36f98..8bd80b6825 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -42,90 +42,107 @@ import { } from 'react-router-dom'; import { sidebarConfig, SidebarContext } from './config'; -const useStyles = makeStyles(theme => { - const { - selectedIndicatorWidth, - drawerWidthClosed, - drawerWidthOpen, - iconContainerWidth, - } = sidebarConfig; - return { - root: { - color: theme.palette.navigation.color, - display: 'flex', - flexFlow: 'row nowrap', - alignItems: 'center', - height: 48, - cursor: 'pointer', - }, - buttonItem: { - background: 'none', - border: 'none', - width: 'auto', - margin: 0, - padding: 0, - textAlign: 'inherit', - font: 'inherit', - }, - closed: { - width: drawerWidthClosed, - justifyContent: 'center', - }, - open: { - width: drawerWidthOpen, - }, - label: { - // XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs - fontWeight: 'bold', - whiteSpace: 'nowrap', - lineHeight: 'auto', - flex: '3 1 auto', - width: '110px', - overflow: 'hidden', - 'text-overflow': 'ellipsis', - }, - iconContainer: { - boxSizing: 'border-box', - height: '100%', - width: iconContainerWidth, - marginRight: -theme.spacing(2), - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - searchRoot: { - marginBottom: 12, - }, - searchField: { - color: '#b5b5b5', - fontWeight: 'bold', - fontSize: theme.typography.fontSize, - }, - searchFieldHTMLInput: { - padding: `${theme.spacing(2)} 0 ${theme.spacing(2)}`, - }, - searchContainer: { - width: drawerWidthOpen - iconContainerWidth, - }, - secondaryAction: { - width: theme.spacing(6), - textAlign: 'center', - marginRight: theme.spacing(1), - }, - selected: { - '&$root': { - borderLeft: `solid ${selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`, - color: theme.palette.navigation.selectedColor, +export type SidebarItemClassKey = + | 'root' + | 'buttonItem' + | 'closed' + | 'open' + | 'label' + | 'iconContainer' + | 'searchRoot' + | 'searchField' + | 'searchFieldHTMLInput' + | 'searchContainer' + | 'secondaryAction' + | 'selected'; + +const useStyles = makeStyles( + theme => { + const { + selectedIndicatorWidth, + drawerWidthClosed, + drawerWidthOpen, + iconContainerWidth, + } = sidebarConfig; + return { + root: { + color: theme.palette.navigation.color, + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + height: 48, + cursor: 'pointer', }, - '&$closed': { - width: drawerWidthClosed - selectedIndicatorWidth, + buttonItem: { + background: 'none', + border: 'none', + width: 'auto', + margin: 0, + padding: 0, + textAlign: 'inherit', + font: 'inherit', }, - '& $iconContainer': { - marginLeft: -selectedIndicatorWidth, + closed: { + width: drawerWidthClosed, + justifyContent: 'center', }, - }, - }; -}); + open: { + width: drawerWidthOpen, + }, + label: { + // XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs + fontWeight: 'bold', + whiteSpace: 'nowrap', + lineHeight: 'auto', + flex: '3 1 auto', + width: '110px', + overflow: 'hidden', + 'text-overflow': 'ellipsis', + }, + iconContainer: { + boxSizing: 'border-box', + height: '100%', + width: iconContainerWidth, + marginRight: -theme.spacing(2), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + searchRoot: { + marginBottom: 12, + }, + searchField: { + color: '#b5b5b5', + fontWeight: 'bold', + fontSize: theme.typography.fontSize, + }, + searchFieldHTMLInput: { + padding: `${theme.spacing(2)} 0 ${theme.spacing(2)}`, + }, + searchContainer: { + width: drawerWidthOpen - iconContainerWidth, + }, + secondaryAction: { + width: theme.spacing(6), + textAlign: 'center', + marginRight: theme.spacing(1), + }, + selected: { + '&$root': { + borderLeft: `solid ${selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`, + color: theme.palette.navigation.selectedColor, + }, + '&$closed': { + width: drawerWidthClosed - selectedIndicatorWidth, + }, + '& $iconContainer': { + marginLeft: -selectedIndicatorWidth, + }, + }, + }; + }, + { name: 'BackstageSidebarItem' }, +); type SidebarItemBaseProps = { icon: IconComponent; @@ -176,8 +193,8 @@ export const WorkaroundNavLink = React.forwardRef< let { pathname: toPathname } = useResolvedPath(to); if (!caseSensitive) { - locationPathname = locationPathname.toLowerCase(); - toPathname = toPathname.toLowerCase(); + locationPathname = locationPathname.toLocaleLowerCase('en-US'); + toPathname = toPathname.toLocaleLowerCase('en-US'); } let isActive = locationPathname === toPathname; diff --git a/packages/core-components/src/layout/Sidebar/Page.tsx b/packages/core-components/src/layout/Sidebar/Page.tsx index 47c5878cde..c375112321 100644 --- a/packages/core-components/src/layout/Sidebar/Page.tsx +++ b/packages/core-components/src/layout/Sidebar/Page.tsx @@ -25,17 +25,22 @@ import { sidebarConfig } from './config'; import { BackstageTheme } from '@backstage/theme'; import { LocalStorage } from './localStorage'; -const useStyles = makeStyles({ - root: { - width: '100%', - minHeight: '100%', - transition: 'padding-left 0.1s ease-out', - paddingLeft: ({ isPinned }) => - isPinned - ? sidebarConfig.drawerWidthOpen - : sidebarConfig.drawerWidthClosed, +export type SidebarPageClassKey = 'root'; + +const useStyles = makeStyles( + { + root: { + width: '100%', + minHeight: '100%', + transition: 'padding-left 0.1s ease-out', + paddingLeft: ({ isPinned }) => + isPinned + ? sidebarConfig.drawerWidthOpen + : sidebarConfig.drawerWidthClosed, + }, }, -}); + { name: 'BackstageSidebarPage' }, +); export type SidebarPinStateContextType = { isPinned: boolean; diff --git a/packages/core-components/src/layout/Sidebar/index.ts b/packages/core-components/src/layout/Sidebar/index.ts index 58e193dbaf..067a9bf2eb 100644 --- a/packages/core-components/src/layout/Sidebar/index.ts +++ b/packages/core-components/src/layout/Sidebar/index.ts @@ -15,8 +15,9 @@ */ export { Sidebar } from './Bar'; +export type { SidebarClassKey } from './Bar'; export { SidebarPage, SidebarPinStateContext } from './Page'; -export type { SidebarPinStateContextType } from './Page'; +export type { SidebarPinStateContextType, SidebarPageClassKey } from './Page'; export { SidebarDivider, SidebarItem, @@ -25,7 +26,9 @@ export { SidebarSpacer, SidebarScrollWrapper, } from './Items'; +export type { SidebarItemClassKey } from './Items'; export { IntroCard, SidebarIntro } from './Intro'; +export type { SidebarIntroClassKey } from './Intro'; export { SIDEBAR_INTRO_LOCAL_STORAGE, SidebarContext, diff --git a/packages/core-components/src/layout/SignInPage/SignInPage.tsx b/packages/core-components/src/layout/SignInPage/SignInPage.tsx index d64a59158d..910fc6a7bf 100644 --- a/packages/core-components/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core-components/src/layout/SignInPage/SignInPage.tsx @@ -22,6 +22,7 @@ import { } from '@backstage/core-plugin-api'; import { Button, Grid, Typography } from '@material-ui/core'; import React, { useState } from 'react'; +import { useMount } from 'react-use'; import { Progress } from '../../components/Progress'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; @@ -93,7 +94,6 @@ export const SingleSignInPage = ({ const configApi = useApi(configApiRef); const [error, setError] = useState(); - const [loginCount, setLoginCount] = useState(0); // The SignIn component takes some time to decide whether the user is logged-in or not. // showLoginPage is used to prevent a glitch-like experience where the sign-in page is @@ -102,7 +102,6 @@ export const SingleSignInPage = ({ type LoginOpts = { checkExisting?: boolean; showPopup?: boolean }; const login = async ({ checkExisting, showPopup }: LoginOpts) => { - setLoginCount(prev => prev + 1); try { let identity: BackstageIdentity | undefined; if (checkExisting) { @@ -120,6 +119,11 @@ export const SingleSignInPage = ({ identity = await authApi.getBackstageIdentity({ instantPopup: true, }); + if (!identity) { + throw new Error( + `The ${provider.title} provider is not configured to support sign-in`, + ); + } } if (!identity) { @@ -146,9 +150,8 @@ export const SingleSignInPage = ({ setShowLoginPage(true); } }; - if (loginCount === 0) { - login({ checkExisting: true }); - } + + useMount(() => login({ checkExisting: true })); return showLoginPage ? ( diff --git a/packages/core-components/src/layout/SignInPage/auth0Provider.tsx b/packages/core-components/src/layout/SignInPage/auth0Provider.tsx index 518304d58c..78043cb3fb 100644 --- a/packages/core-components/src/layout/SignInPage/auth0Provider.tsx +++ b/packages/core-components/src/layout/SignInPage/auth0Provider.tsx @@ -33,6 +33,11 @@ const Component: ProviderComponent = ({ onResult }) => { const identity = await auth0AuthApi.getBackstageIdentity({ instantPopup: true, }); + if (!identity) { + throw new Error( + 'The Auth0 provider is not configured to support sign-in', + ); + } const profile = await auth0AuthApi.getProfile(); diff --git a/packages/core-components/src/layout/SignInPage/commonProvider.tsx b/packages/core-components/src/layout/SignInPage/commonProvider.tsx index 0c5d195b1d..3160bd171b 100644 --- a/packages/core-components/src/layout/SignInPage/commonProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/commonProvider.tsx @@ -36,6 +36,11 @@ const Component: ProviderComponent = ({ config, onResult }) => { const identity = await authApi.getBackstageIdentity({ instantPopup: true, }); + if (!identity) { + throw new Error( + `The ${title} provider is not configured to support sign-in`, + ); + } const profile = await authApi.getProfile(); onResult({ diff --git a/packages/core-components/src/layout/SignInPage/customProvider.tsx b/packages/core-components/src/layout/SignInPage/customProvider.tsx index 98753ad444..46fed6b5ae 100644 --- a/packages/core-components/src/layout/SignInPage/customProvider.tsx +++ b/packages/core-components/src/layout/SignInPage/customProvider.tsx @@ -32,16 +32,21 @@ import { GridItem } from './styles'; // accept base64url format according to RFC7515 (https://tools.ietf.org/html/rfc7515#section-3) const ID_TOKEN_REGEX = /^[a-z0-9_\-]+\.[a-z0-9_\-]+\.[a-z0-9_\-]+$/i; -const useFormStyles = makeStyles(theme => ({ - form: { - display: 'flex', - flexFlow: 'column nowrap', - }, - button: { - alignSelf: 'center', - marginTop: theme.spacing(2), - }, -})); +export type CustomProviderClassKey = 'form' | 'button'; + +const useFormStyles = makeStyles( + theme => ({ + form: { + display: 'flex', + flexFlow: 'column nowrap', + }, + button: { + alignSelf: 'center', + marginTop: theme.spacing(2), + }, + }), + { name: 'BackstageCustomProvider' }, +); type Data = { userId: string; diff --git a/packages/core-components/src/layout/SignInPage/index.ts b/packages/core-components/src/layout/SignInPage/index.ts index 2e5502d7ea..caa8506399 100644 --- a/packages/core-components/src/layout/SignInPage/index.ts +++ b/packages/core-components/src/layout/SignInPage/index.ts @@ -16,3 +16,5 @@ export type { SignInProviderConfig } from './types'; export { SignInPage } from './SignInPage'; +export type { SignInPageClassKey } from './styles'; +export type { CustomProviderClassKey } from './customProvider'; diff --git a/packages/core-components/src/layout/SignInPage/styles.tsx b/packages/core-components/src/layout/SignInPage/styles.tsx index 96559a4e78..55db36229b 100644 --- a/packages/core-components/src/layout/SignInPage/styles.tsx +++ b/packages/core-components/src/layout/SignInPage/styles.tsx @@ -16,20 +16,25 @@ import React from 'react'; import { Grid, makeStyles } from '@material-ui/core'; -export const useStyles = makeStyles({ - container: { - padding: 0, - listStyle: 'none', +export type SignInPageClassKey = 'container' | 'item'; + +export const useStyles = makeStyles( + { + container: { + padding: 0, + listStyle: 'none', + }, + item: { + display: 'flex', + flexDirection: 'column', + width: '100%', + maxWidth: '400px', + margin: 0, + padding: 0, + }, }, - item: { - display: 'flex', - flexDirection: 'column', - width: '100%', - maxWidth: '400px', - margin: 0, - padding: 0, - }, -}); + { name: 'BackstageSignInPage' }, +); export const GridItem = ({ children }: { children: JSX.Element }) => { const classes = useStyles(); diff --git a/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx b/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx index f350b24f1d..e9905663b2 100644 --- a/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx +++ b/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx @@ -34,22 +34,32 @@ import { import { BottomLink, BottomLinkProps } from '../BottomLink'; import { ErrorBoundary, ErrorBoundaryProps } from '../ErrorBoundary'; -const useTabsStyles = makeStyles(theme => ({ - root: { - padding: theme.spacing(0, 2, 0, 2.5), - minHeight: theme.spacing(3), - }, - indicator: { - backgroundColor: theme.palette.info.main, - height: theme.spacing(0.3), - }, -})); +export type TabbedCardClassKey = 'root' | 'indicator'; -const BoldHeader = withStyles(theme => ({ - root: { padding: theme.spacing(2, 2, 2, 2.5), display: 'inline-block' }, - title: { fontWeight: 700 }, - subheader: { paddingTop: theme.spacing(1) }, -}))(CardHeader); +const useTabsStyles = makeStyles( + theme => ({ + root: { + padding: theme.spacing(0, 2, 0, 2.5), + minHeight: theme.spacing(3), + }, + indicator: { + backgroundColor: theme.palette.info.main, + height: theme.spacing(0.3), + }, + }), + { name: 'BackstageTabbedCard' }, +); + +export type BoldHeaderClassKey = 'root' | 'title' | 'subheader'; + +const BoldHeader = withStyles( + theme => ({ + root: { padding: theme.spacing(2, 2, 2, 2.5), display: 'inline-block' }, + title: { fontWeight: 700 }, + subheader: { paddingTop: theme.spacing(1) }, + }), + { name: 'BackstageTabbedCardBoldHeader' }, +)(CardHeader); type Props = { /** @deprecated Use errorBoundaryProps instead */ @@ -114,23 +124,28 @@ export function TabbedCard(props: PropsWithChildren) { ); } -const useCardTabStyles = makeStyles(theme => ({ - root: { - minWidth: theme.spacing(6), - minHeight: theme.spacing(3), - margin: theme.spacing(0, 2, 0, 0), - padding: theme.spacing(0.5, 0, 0.5, 0), - textTransform: 'none', - '&:hover': { - opacity: 1, - backgroundColor: 'transparent', - color: theme.palette.text.primary, +export type CardTabClassKey = 'root' | 'selected'; + +const useCardTabStyles = makeStyles( + theme => ({ + root: { + minWidth: theme.spacing(6), + minHeight: theme.spacing(3), + margin: theme.spacing(0, 2, 0, 0), + padding: theme.spacing(0.5, 0, 0.5, 0), + textTransform: 'none', + '&:hover': { + opacity: 1, + backgroundColor: 'transparent', + color: theme.palette.text.primary, + }, }, - }, - selected: { - fontWeight: 'bold', - }, -})); + selected: { + fontWeight: 'bold', + }, + }), + { name: 'BackstageCardTab' }, +); type CardTabProps = TabProps & { children: ReactNode; diff --git a/packages/core-components/src/layout/TabbedCard/index.ts b/packages/core-components/src/layout/TabbedCard/index.ts index f3e32be054..2f31163beb 100644 --- a/packages/core-components/src/layout/TabbedCard/index.ts +++ b/packages/core-components/src/layout/TabbedCard/index.ts @@ -15,3 +15,8 @@ */ export { CardTab, TabbedCard } from './TabbedCard'; +export type { + CardTabClassKey, + TabbedCardClassKey, + BoldHeaderClassKey, +} from './TabbedCard'; diff --git a/packages/core-components/src/layout/index.ts b/packages/core-components/src/layout/index.ts index 4abde642dc..19cd6dfb78 100644 --- a/packages/core-components/src/layout/index.ts +++ b/packages/core-components/src/layout/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './BottomLink'; export * from './Content'; export * from './ContentHeader'; export * from './ErrorBoundary'; diff --git a/packages/core-components/src/overridableComponents.ts b/packages/core-components/src/overridableComponents.ts new file mode 100644 index 0000000000..b3186ae48d --- /dev/null +++ b/packages/core-components/src/overridableComponents.ts @@ -0,0 +1,172 @@ +/* + * 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 { Overrides } from '@material-ui/core/styles/overrides'; +import { StyleRules } from '@material-ui/core/styles/withStyles'; + +import { + AvatarClassKey, + DependencyGraphDefaultLabelClassKey, + DependencyGraphDefaultNodeClassKey, + DependencyGraphEdgeClassKey, + DependencyGraphNodeClassKey, + DismissbleBannerClassKey, + EmptyStateClassKey, + EmptyStateImageClassKey, + MissingAnnotationEmptyStateClassKey, + ErrorPanelClassKey, + FeatureCalloutCircleClassKey, + HeaderIconLinkRowClassKey, + IconLinkVerticalClassKey, + HorizontalScrollGridClassKey, + LifecycleClassKey, + MarkdownContentClassKey, + LoginRequestListItemClassKey, + OAuthRequestDialogClassKey, + OverflowTooltipClassKey, + GaugeClassKey, + GaugeCardClassKey, + ResponseErrorPanelClassKey, + SelectInputBaseClassKey, + SelectClassKey, + ClosedDropdownClassKey, + OpenedDropdownClassKey, + SimpleStepperFooterClassKey, + SimpleStepperStepClassKey, + StatusClassKey, + MetadataTableTitleCellClassKey, + MetadataTableCellClassKey, + MetadataTableListClassKey, + MetadataTableListItemClassKey, + StructuredMetadataTableListClassKey, + StructuredMetadataTableNestedListClassKey, + SupportButtonClassKey, + TableFiltersClassKey, + SubvalueCellClassKey, + TableHeaderClassKey, + TableToolbarClassKey, + FiltersContainerClassKey, + TableClassKey, + TabBarClassKey, + TabIconClassKey, + TabsClassKey, + WarningPanelClassKey, +} from './components'; + +import { + BottomLinkClassKey, + BreadcrumbsClickableTextClassKey, + BreadcrumbsStyledBoxClassKey, + BackstageContentClassKey, + ContentHeaderClassKey, + ErrorPageClassKey, + MicDropClassKey, + HeaderClassKey, + HeaderLabelClassKey, + HeaderTabsClassKey, + InfoCardClassKey, + CardActionsTopRightClassKey, + ItemCardGridClassKey, + ItemCardHeaderClassKey, + PageClassKey, + SidebarClassKey, + SidebarIntroClassKey, + SidebarItemClassKey, + SidebarPageClassKey, + CustomProviderClassKey, + SignInPageClassKey, + TabbedCardClassKey, + BoldHeaderClassKey, + CardTabClassKey, +} from './layout'; + +type BackstageComponentsNameToClassKey = { + BackstageAvatar: AvatarClassKey; + BackstageDependencyGraphDefaultLabel: DependencyGraphDefaultLabelClassKey; + BackstageDependencyGraphDefaultNode: DependencyGraphDefaultNodeClassKey; + BackstageDependencyGraphEdge: DependencyGraphEdgeClassKey; + BackstageDependencyGraphNode: DependencyGraphNodeClassKey; + BackstageDismissableBanner: DismissbleBannerClassKey; + BackstageEmptyState: EmptyStateClassKey; + BackstageEmptyStateImage: EmptyStateImageClassKey; + BackstageMissingAnnotationEmptyState: MissingAnnotationEmptyStateClassKey; + BackstageErrorPanel: ErrorPanelClassKey; + BackstageFeatureCalloutCircular: FeatureCalloutCircleClassKey; + BackstageHeaderIconLinkRow: HeaderIconLinkRowClassKey; + BackstageIconLinkVertical: IconLinkVerticalClassKey; + BackstageHorizontalScrollGrid: HorizontalScrollGridClassKey; + BackstageLifecycle: LifecycleClassKey; + BackstageMarkdownContent: MarkdownContentClassKey; + BackstageLoginRequestListItem: LoginRequestListItemClassKey; + OAuthRequestDialog: OAuthRequestDialogClassKey; + BackstageOverflowTooltip: OverflowTooltipClassKey; + BackstageGauge: GaugeClassKey; + BackstageGaugeCard: GaugeCardClassKey; + BackstageResponseErrorPanel: ResponseErrorPanelClassKey; + BackstageSelectInputBase: SelectInputBaseClassKey; + BackstageSelect: SelectClassKey; + BackstageClosedDropdown: ClosedDropdownClassKey; + BackstageOpenedDropdown: OpenedDropdownClassKey; + BackstageSimpleStepperFooter: SimpleStepperFooterClassKey; + SimpleStepperStep: SimpleStepperStepClassKey; + BackstageStatus: StatusClassKey; + BackstageMetadataTableTitleCell: MetadataTableTitleCellClassKey; + BackstageMetadataTableCell: MetadataTableCellClassKey; + BackstageMetadataTableList: MetadataTableListClassKey; + BackstageMetadataTableListItem: MetadataTableListItemClassKey; + BackstageStructuredMetadataTableList: StructuredMetadataTableListClassKey; + BackstageStructuredMetadataTableNestedList: StructuredMetadataTableNestedListClassKey; + BackstageSupportButton: SupportButtonClassKey; + BackstageTableFilters: TableFiltersClassKey; + BackstageSubvalueCell: SubvalueCellClassKey; + BackstageTableHeader: TableHeaderClassKey; + BackstageTableToolbar: TableToolbarClassKey; + BackstageTableFiltersContainer: FiltersContainerClassKey; + BackstageTable: TableClassKey; + BackstageTabBar: TabBarClassKey; + BackstageTabIcon: TabIconClassKey; + BackstageTabs: TabsClassKey; + BackstageWarningPanel: WarningPanelClassKey; + BackstageBottomLink: BottomLinkClassKey; + BackstageBreadcrumbsClickableText: BreadcrumbsClickableTextClassKey; + BackstageBreadcrumbsStyledBox: BreadcrumbsStyledBoxClassKey; + BackstageContent: BackstageContentClassKey; + BackstageContentHeader: ContentHeaderClassKey; + BackstageErrorPage: ErrorPageClassKey; + BackstageErrorPageMicDrop: MicDropClassKey; + BackstageHeader: HeaderClassKey; + BackstageHeaderLabel: HeaderLabelClassKey; + BackstageHeaderTabs: HeaderTabsClassKey; + BackstageInfoCard: InfoCardClassKey; + BackstageInfoCardCardActionsTopRight: CardActionsTopRightClassKey; + BackstageItemCardGrid: ItemCardGridClassKey; + BackstageItemCardHeader: ItemCardHeaderClassKey; + BackstagePage: PageClassKey; + BackstageSidebar: SidebarClassKey; + BackstageSidebarIntro: SidebarIntroClassKey; + BackstageSidebarItem: SidebarItemClassKey; + BackstageSidebarPage: SidebarPageClassKey; + BackstageCustomProvider: CustomProviderClassKey; + BackstageSignInPage: SignInPageClassKey; + BackstageTabbedCard: TabbedCardClassKey; + BackstageTabbedCardBoldHeader: BoldHeaderClassKey; + BackstageCardTab: CardTabClassKey; +}; + +export type BackstageOverrides = Overrides & { + [Name in keyof BackstageComponentsNameToClassKey]?: Partial< + StyleRules + >; +}; diff --git a/packages/core-plugin-api/CHANGELOG.md b/packages/core-plugin-api/CHANGELOG.md index ba6dc46c4b..8982002abc 100644 --- a/packages/core-plugin-api/CHANGELOG.md +++ b/packages/core-plugin-api/CHANGELOG.md @@ -1,5 +1,41 @@ # @backstage/core-plugin-api +## 0.1.10 + +### Patch Changes + +- 829bc698f4: Introducing the Analytics API: a lightweight way for plugins to instrument key + events that could help inform a Backstage Integrator how their instance of + Backstage is being used. The API consists of the following: + + - `useAnalytics()`, a hook to be used inside plugin components which retrieves + an Analytics Tracker. + - `tracker.captureEvent()`, a method on the tracker used to instrument key + events. The method expects an action (the event name) and a subject (a unique + identifier of the object the action is being taken on). + - ``, a way to declaratively attach additional information + to any/all events captured in the underlying React tree. There is also a + `withAnalyticsContext()` HOC utility. + - The `tracker.captureEvent()` method also accepts an `attributes` option for + providing additional run-time information about an event, as well as a + `value` option for capturing a numeric/metric value. + + By default, captured events are not sent anywhere. In order to collect and + redirect events to an analytics system, the `analyticsApi` will need to be + implemented and instantiated by an App Integrator. + +- 4c3eea7788: Bitbucket Cloud authentication - based on the existing GitHub authentication + changes around BB apis and updated scope. + + - BitbucketAuth added to core-app-api. + - Bitbucket provider added to plugin-auth-backend. + - Cosmetic entry for Bitbucket connection in user-settings Authentication Providers tab. + +## 0.1.9 + +### Patch Changes + +- 98bd661240: Improve compatibility between different versions by defining the route reference type using a string key rather than a unique symbol. This change only applies to type checking and has no effect on the runtime value, where we still use the symbol. + ## 0.1.8 ### Patch Changes diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 39040d1c91..4d1fe4fffd 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -34,6 +34,74 @@ export type AlertMessage = { severity?: 'success' | 'info' | 'warning' | 'error'; }; +// Warning: (ae-missing-release-tag) "AnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsApi = { + captureEvent(event: AnalyticsEvent): void; +}; + +// Warning: (ae-missing-release-tag) "analyticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const analyticsApiRef: ApiRef; + +// Warning: (ae-missing-release-tag) "AnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const AnalyticsContext: ({ + attributes, + children, +}: { + attributes: Partial; + children: ReactNode; +}) => JSX.Element; + +// Warning: (ae-missing-release-tag) "AnalyticsContextValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsContextValue = CommonAnalyticsContext & + AnyAnalyticsContext; + +// Warning: (ae-missing-release-tag) "AnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsEvent = { + action: string; + subject: string; + value?: number; + attributes?: AnalyticsEventAttributes; + context: AnalyticsContextValue; +}; + +// Warning: (ae-missing-release-tag) "AnalyticsEventAttributes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsEventAttributes = { + [attribute in string]: string | boolean | number; +}; + +// Warning: (ae-missing-release-tag) "AnalyticsTracker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsTracker = { + captureEvent: ( + action: string, + subject: string, + options?: { + value?: number; + attributes?: AnalyticsEventAttributes; + }, + ) => void; +}; + +// Warning: (ae-missing-release-tag) "AnyAnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnyAnalyticsContext = { + [param in string]: string | boolean | number | undefined; +}; + // Warning: (ae-missing-release-tag) "AnyApiFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -226,6 +294,13 @@ export type BackstagePlugin< externalRoutes: ExternalRoutes; }; +// Warning: (ae-missing-release-tag) "bitbucketAuthApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const bitbucketAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +>; + // Warning: (ae-missing-release-tag) "BootErrorPageProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -234,6 +309,15 @@ export type BootErrorPageProps = { error: Error; }; +// Warning: (ae-missing-release-tag) "CommonAnalyticsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type CommonAnalyticsContext = { + pluginId: string; + routeRef: string; + extension: string; +}; + // Warning: (ae-missing-release-tag) "ConfigApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -273,7 +357,7 @@ export function createApiRef(config: ApiRefConfig): ApiRef; // @public (undocumented) export function createComponentExtension< T extends (props: any) => JSX.Element | null, ->(options: { component: ComponentLoader }): Extension; +>(options: { component: ComponentLoader; name?: string }): Extension; // Warning: (ae-forgotten-export) The symbol "OptionalParams" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "createExternalRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -309,6 +393,7 @@ export function createReactExtension< >(options: { component: ComponentLoader; data?: Record; + name?: string; }): Extension; // Warning: (ae-missing-release-tag) "createRoutableExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -316,7 +401,11 @@ export function createReactExtension< // @public (undocumented) export function createRoutableExtension< T extends (props: any) => JSX.Element | null, ->(options: { component: () => Promise; mountPoint: RouteRef }): Extension; +>(options: { + component: () => Promise; + mountPoint: RouteRef; + name?: string; +}): Extension; // Warning: (ae-missing-release-tag) "createRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -423,7 +512,7 @@ export type ExternalRouteRef< Params extends AnyParams = any, Optional extends boolean = any, > = { - readonly [routeRefType]: 'external'; + $$routeRefType: 'external'; params: ParamKeys; optional?: Optional; }; @@ -720,7 +809,7 @@ export type RoutePath = string; // // @public (undocumented) export type RouteRef = { - readonly [routeRefType]: 'absolute'; + $$routeRefType: 'absolute'; params: ParamKeys; path: string; icon?: OldIconComponent; @@ -811,7 +900,7 @@ export type StorageValueChange = { // // @public (undocumented) export type SubRouteRef = { - readonly [routeRefType]: 'sub'; + $$routeRefType: 'sub'; parent: RouteRef; path: string; params: ParamKeys; @@ -832,6 +921,11 @@ export type TypesToApiRefs = { [key in keyof T]: ApiRef; }; +// Warning: (ae-missing-release-tag) "useAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function useAnalytics(): AnalyticsTracker; + // Warning: (ae-missing-release-tag) "useApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -900,7 +994,7 @@ export function withApis(apis: TypesToApiRefs):

    ( // src/apis/definitions/auth.d.ts:96:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration // src/apis/definitions/auth.d.ts:110:16 - (tsdoc-undefined-tag) The TSDoc tag "@IdentityApi" is not defined in this configuration // src/apis/definitions/auth.d.ts:113:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration -// src/extensions/extensions.d.ts:14:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts -// src/routing/RouteRef.d.ts:34:5 - (ae-forgotten-export) The symbol "OldIconComponent" needs to be exported by the entry point index.d.ts +// src/extensions/extensions.d.ts:15:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts +// src/routing/RouteRef.d.ts:35:5 - (ae-forgotten-export) The symbol "OldIconComponent" needs to be exported by the entry point index.d.ts // src/routing/types.d.ts:30:5 - (ae-forgotten-export) The symbol "ParamKeys" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index abd64bd4d5..5c8daec606 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-plugin-api", "description": "Core API used by Backstage plugins", - "version": "0.1.8", + "version": "0.1.10", "private": false, "publishConfig": { "access": "public", @@ -42,13 +42,13 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.7.12", - "@backstage/core-app-api": "^0.1.13", - "@backstage/test-utils": "^0.1.17", - "@backstage/test-utils-core": "^0.1.2", + "@backstage/cli": "^0.7.15", + "@backstage/core-app-api": "^0.1.16", + "@backstage/test-utils": "^0.1.18", + "@backstage/test-utils-core": "^0.1.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/react-hooks": "^3.4.2", + "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx new file mode 100644 index 0000000000..88e8afa6ef --- /dev/null +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.test.tsx @@ -0,0 +1,85 @@ +/* + * 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 { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { AnalyticsContext, useAnalyticsContext } from './AnalyticsContext'; + +const AnalyticsSpy = () => { + const context = useAnalyticsContext(); + return ( + <> +

    {context.routeRef}
    +
    {context.pluginId}
    +
    {context.extension}
    +
    {context.custom}
    + + ); +}; + +describe('AnalyticsContext', () => { + describe('useAnalyticsContext', () => { + it('returns default values', () => { + const { result } = renderHook(() => useAnalyticsContext()); + expect(result.current).toEqual({ + extension: 'App', + pluginId: 'root', + routeRef: 'unknown', + }); + }); + }); + + describe('AnalyticsContext', () => { + it('uses default analytics context', () => { + const result = render( + + + , + ); + + expect(result.getByTestId('extension')).toHaveTextContent('App'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('root'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + + it('uses provided analytics context', () => { + const result = render( + + + , + ); + + expect(result.getByTestId('extension')).toHaveTextContent('App'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + + it('uses nested analytics context', () => { + const result = render( + + + + + , + ); + + expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + }); +}); diff --git a/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx new file mode 100644 index 0000000000..adbd9be6f3 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/AnalyticsContext.tsx @@ -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 { + createVersionedContext, + createVersionedValueMap, +} from '@backstage/version-bridge'; +import React, { ReactNode, useContext } from 'react'; +import { AnalyticsContextValue } from './types'; + +const AnalyticsReactContext = + createVersionedContext<{ 1: AnalyticsContextValue }>('analytics-context'); + +/** + * A "private" (to this package) hook that enables context inheritance and a + * way to read Analytics Context values at event capture-time. + * @private + */ +export const useAnalyticsContext = (): AnalyticsContextValue => { + const theContext = useContext(AnalyticsReactContext); + + // Provide a default value if no value exists. + if (theContext === undefined) { + return { + routeRef: 'unknown', + pluginId: 'root', + extension: 'App', + }; + } + + // This should probably never happen, but check for it. + const theValue = theContext.atVersion(1); + if (theValue === undefined) { + throw new Error('No context found for version 1.'); + } + + return theValue; +}; + +/** + * Provides components in the child react tree an Analytics Context, ensuring + * all analytics events captured within the context have relevant attributes. + * + * Analytics contexts are additive, meaning the context ultimately emitted with + * an event is the combination of all contexts in the parent tree. + */ +export const AnalyticsContext = ({ + attributes, + children, +}: { + attributes: Partial; + children: ReactNode; +}) => { + const parentValues = useAnalyticsContext(); + const combinedValue = { + ...parentValues, + ...attributes, + }; + + const versionedCombinedValue = createVersionedValueMap({ 1: combinedValue }); + return ( + + {children} + + ); +}; + +/** + * Returns an HOC wrapping the provided component in an Analytics context with + * the given values. + * + * @param Component - Component to be wrapped with analytics context attributes + * @param values - Analytics context key/value pairs. + */ +export function withAnalyticsContext

    ( + Component: React.ComponentType

    , + values: AnalyticsContextValue, +) { + const ComponentWithAnalyticsContext = (props: P) => { + return ( + + + + ); + }; + ComponentWithAnalyticsContext.displayName = `WithAnalyticsContext(${ + Component.displayName || Component.name || 'Component' + })`; + return ComponentWithAnalyticsContext; +} diff --git a/packages/core-plugin-api/src/analytics/Tracker.ts b/packages/core-plugin-api/src/analytics/Tracker.ts new file mode 100644 index 0000000000..51e99bff14 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/Tracker.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AnalyticsApi, + AnalyticsEventAttributes, + AnalyticsTracker, +} from '../apis'; +import { AnalyticsContextValue } from './'; + +export class Tracker implements AnalyticsTracker { + constructor( + private readonly analyticsApi: AnalyticsApi, + private context: AnalyticsContextValue = { + routeRef: 'unknown', + pluginId: 'root', + extension: 'App', + }, + ) {} + + setContext(context: AnalyticsContextValue) { + this.context = context; + } + + captureEvent( + action: string, + subject: string, + { + value, + attributes, + }: { value?: number; attributes?: AnalyticsEventAttributes } = {}, + ) { + try { + this.analyticsApi.captureEvent({ + action, + subject, + value, + attributes, + context: this.context, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Error during analytics event capture. %o', e); + } + } +} diff --git a/packages/core-plugin-api/src/analytics/index.ts b/packages/core-plugin-api/src/analytics/index.ts new file mode 100644 index 0000000000..942df56ef8 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/index.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +export { AnalyticsContext } from './AnalyticsContext'; +export type { + AnalyticsContextValue, + AnyAnalyticsContext, + CommonAnalyticsContext, +} from './types'; +export { useAnalytics } from './useAnalytics'; diff --git a/packages/core-plugin-api/src/analytics/types.ts b/packages/core-plugin-api/src/analytics/types.ts new file mode 100644 index 0000000000..ea6c3b030b --- /dev/null +++ b/packages/core-plugin-api/src/analytics/types.ts @@ -0,0 +1,48 @@ +/* + * 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. + */ + +/** + * Common analytics context attributes. + */ +export type CommonAnalyticsContext = { + /** + * The nearest known parent plugin where the event was captured. + */ + pluginId: string; + + /** + * The ID of the routeRef that was active when the event was captured. + */ + routeRef: string; + + /** + * The nearest known parent extension where the event was captured. + */ + extension: string; +}; + +/** + * Allow arbitrary scalar values as context attributes too. + */ +export type AnyAnalyticsContext = { + [param in string]: string | boolean | number | undefined; +}; + +/** + * Analytics context envelope. + */ +export type AnalyticsContextValue = CommonAnalyticsContext & + AnyAnalyticsContext; diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx new file mode 100644 index 0000000000..22021026af --- /dev/null +++ b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useAnalytics } from './useAnalytics'; +import { useApi } from '../apis'; + +jest.mock('../apis'); + +const mocked = (f: Function) => f as jest.Mock; + +describe('useAnalytics', () => { + it('returns tracker with no implementation defined', () => { + // Simulate useApi() throwing an error. + mocked(useApi).mockImplementation(() => { + throw new Error(); + }); + + // Result should still have a captureEvent method. + const { result } = renderHook(() => useAnalytics()); + expect(result.current.captureEvent).toBeDefined(); + }); + + it('returns tracker from defined analytics api', () => { + const captureEvent = jest.fn(); + + // Simulate useApi returning a valid tracker. + mocked(useApi).mockReturnValue({ captureEvent }); + + // Calling the captureEvent method of the underlying implementation should + // pass along the given event as well as the default context. + const { result } = renderHook(() => useAnalytics()); + result.current.captureEvent('an action', 'a subject', { + value: 42, + attributes: { some: 'value' }, + }); + expect(captureEvent).toHaveBeenCalledWith({ + action: 'an action', + subject: 'a subject', + value: 42, + attributes: { + some: 'value', + }, + context: { + extension: 'App', + pluginId: 'root', + routeRef: 'unknown', + }, + }); + }); +}); diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx new file mode 100644 index 0000000000..02d1ecc3c3 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -0,0 +1,57 @@ +/* + * 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 { useAnalyticsContext } from './AnalyticsContext'; +import { + analyticsApiRef, + AnalyticsTracker, + AnalyticsApi, + useApi, +} from '../apis'; +import { useRef } from 'react'; +import { Tracker } from './Tracker'; + +function useAnalyticsApi(): AnalyticsApi { + try { + return useApi(analyticsApiRef); + } catch { + return { captureEvent: () => {} }; + } +} + +/** + * Get a pre-configured analytics tracker. + */ +export function useAnalytics(): AnalyticsTracker { + const trackerRef = useRef(null); + const context = useAnalyticsContext(); + // Our goal is to make this API truly optional for any/all consuming code + // (including tests). This hook runs last to ensure hook order is, as much as + // possible, maintained. + const analyticsApi = useAnalyticsApi(); + + function getTracker(): Tracker { + if (trackerRef.current === null) { + trackerRef.current = new Tracker(analyticsApi); + } + return trackerRef.current; + } + + const tracker = getTracker(); + tracker.setContext(context); + + return tracker; +} diff --git a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts new file mode 100644 index 0000000000..43e35607a3 --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -0,0 +1,116 @@ +/* + * 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 { ApiRef, createApiRef } from '../system'; +import { AnalyticsContextValue } from '../../analytics/types'; + +/** + * Represents an event worth tracking in an analytics system that could inform + * how users of a Backstage instance are using its features. + */ +export type AnalyticsEvent = { + /** + * A string that identifies the event being tracked by the type of action the + * event represents. Be careful not to encode extra metadata in this string + * that should instead be placed in the Analytics Context or attributes. + * Examples include: + * + * - view + * - click + * - filter + * - search + * - hover + * - scroll + */ + action: string; + + /** + * A string that uniquely identifies the object that the action is being + * taken on. Examples include: + * + * - The path of the page viewed + * - The url of the link clicked + * - The value that was filtered by + * - The text that was searched for + */ + subject: string; + + /** + * An optional numeric value relevant to the event that could be aggregated + * by analytics tools. Examples include: + * + * - The index or position of the clicked element in an ordered list + * - The percentage of an element that has been scrolled through + * - The amount of time that has elapsed since a fixed point + * - A satisfaction score on a fixed scale + */ + value?: number; + + /** + * Optional, additional attributes (representing dimensions or metrics) + * specific to the event that could be forwarded on to analytics systems. + */ + attributes?: AnalyticsEventAttributes; + + /** + * Contextual metadata relating to where the event was captured and by whom. + * This could include information about the route, plugin, or extension in + * which an event was captured. + */ + context: AnalyticsContextValue; +}; + +/** + * A structure allowing other arbitrary metadata to be provided by analytics + * event emitters. + */ +export type AnalyticsEventAttributes = { + [attribute in string]: string | boolean | number; +}; + +/** + * Represents a tracker with methods that can be called to track events in a + * configured analytics service. + */ +export type AnalyticsTracker = { + captureEvent: ( + action: string, + subject: string, + options?: { + value?: number; + attributes?: AnalyticsEventAttributes; + }, + ) => void; +}; + +/** + * The Analytics API is used to track user behavior in a Backstage instance. + * + * To instrument your App or Plugin, retrieve an analytics tracker using the + * useAnalytics() hook. This will return a pre-configured AnalyticsTracker + * with relevant methods for instrumentation. + */ +export type AnalyticsApi = { + /** + * Primary event handler responsible for compiling and forwarding events to + * an analytics system. + */ + captureEvent(event: AnalyticsEvent): void; +}; + +export const analyticsApiRef: ApiRef = createApiRef({ + id: 'core.analytics', +}); diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index c585619ba5..a78414048c 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -340,3 +340,15 @@ export const oneloginAuthApiRef: ApiRef< > = createApiRef({ id: 'core.auth.onelogin', }); + +/** + * Provides authentication towards Bitbucket APIs. + * + * See https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/ + * for a full list of supported scopes. + */ +export const bitbucketAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.bitbucket', +}); diff --git a/packages/core-plugin-api/src/apis/definitions/index.ts b/packages/core-plugin-api/src/apis/definitions/index.ts index d4350ddbf6..b7666bcb54 100644 --- a/packages/core-plugin-api/src/apis/definitions/index.ts +++ b/packages/core-plugin-api/src/apis/definitions/index.ts @@ -23,6 +23,7 @@ export * from './auth'; export * from './AlertApi'; +export * from './AnalyticsApi'; export * from './AppThemeApi'; export * from './ConfigApi'; export * from './DiscoveryApi'; diff --git a/packages/core-plugin-api/src/extensions/extensions.test.tsx b/packages/core-plugin-api/src/extensions/extensions.test.tsx index e8b9d9c534..fabf90eebb 100644 --- a/packages/core-plugin-api/src/extensions/extensions.test.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.test.tsx @@ -17,6 +17,7 @@ import { withLogCollector } from '@backstage/test-utils-core'; import { render, screen } from '@testing-library/react'; import React from 'react'; +import { useAnalyticsContext } from '../analytics/AnalyticsContext'; import { useApp, ErrorBoundaryFallbackProps } from '../app'; import { createPlugin } from '../plugin'; import { createRouteRef } from '../routing'; @@ -84,6 +85,7 @@ describe('extensions', () => { it('should wrap extended component with error boundary', async () => { const BrokenComponent = plugin.provide( createComponentExtension({ + name: 'BrokenComponent', component: { sync: () => { throw new Error('Test error'); @@ -107,4 +109,32 @@ describe('extensions', () => { screen.getByText('Error in my-plugin'); expect(errors[0]).toMatch('Test error'); }); + + it('should wrap extended component with analytics context', async () => { + const AnalyticsSpyExtension = plugin.provide( + createReactExtension({ + name: 'AnalyticsSpy', + component: { + sync: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const context = useAnalyticsContext(); + return ( + <> +

    {context.pluginId}
    +
    {context.routeRef}
    +
    {context.extension}
    + + ); + }, + }, + data: { 'core.mountPoint': { id: 'some-ref' } }, + }), + ); + + const result = render(); + + expect(result.getByTestId('plugin-id')).toHaveTextContent('my-plugin'); + expect(result.getByTestId('route-ref')).toHaveTextContent('some-ref'); + expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy'); + }); }); diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index 73b927afc8..4aff027f31 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -15,6 +15,7 @@ */ import React, { lazy, Suspense } from 'react'; +import { AnalyticsContext } from '../analytics/AnalyticsContext'; import { useApp } from '../app'; import { RouteRef, useRouteRef } from '../routing'; import { attachComponentData } from './componentData'; @@ -37,6 +38,7 @@ export function createRoutableExtension< >(options: { component: () => Promise; mountPoint: RouteRef; + name?: string; }): Extension { const { component, mountPoint } = options; return createReactExtension({ @@ -62,6 +64,7 @@ export function createRoutableExtension< }; const componentName = + options.name || (InnerComponent as { displayName?: string }).displayName || InnerComponent.name || 'LazyComponent'; @@ -84,6 +87,7 @@ export function createRoutableExtension< data: { 'core.mountPoint': mountPoint, }, + name: options.name, }); } @@ -92,9 +96,9 @@ export function createRoutableExtension< // making it impossible to make the usage of children type safe. export function createComponentExtension< T extends (props: any) => JSX.Element | null, ->(options: { component: ComponentLoader }): Extension { - const { component } = options; - return createReactExtension({ component }); +>(options: { component: ComponentLoader; name?: string }): Extension { + const { component, name } = options; + return createReactExtension({ component, name }); } // We do not use ComponentType as the return type, since it doesn't let us convey the children prop. @@ -105,6 +109,7 @@ export function createReactExtension< >(options: { component: ComponentLoader; data?: Record; + name?: string; }): Extension { const { data = {} } = options; @@ -118,6 +123,7 @@ export function createReactExtension< Component = options.component.sync; } const componentName = + options.name || (Component as { displayName?: string }).displayName || Component.name || 'Component'; @@ -127,11 +133,24 @@ export function createReactExtension< const Result: any = (props: any) => { const app = useApp(); const { Progress } = app.getComponents(); + // todo(iamEAP): Account for situations where this is attached via + // separate calls to attachComponentData(). + const mountPoint = data?.['core.mountPoint'] as + | { id?: string } + | undefined; return ( }> - + + + ); diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index dc5e0046f1..4d42a0c969 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -330,7 +330,7 @@ describe('useElementFilter', () => { }, ); - expect(result.error.message).toEqual('Could not find component'); + expect(result.error?.message).toEqual('Could not find component'); }); it('should support fragments and text node iteration', () => { diff --git a/packages/core-plugin-api/src/index.ts b/packages/core-plugin-api/src/index.ts index f0c1069652..782416c4d0 100644 --- a/packages/core-plugin-api/src/index.ts +++ b/packages/core-plugin-api/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './analytics'; export * from './apis'; export * from './app'; export * from './extensions'; diff --git a/packages/core-plugin-api/src/routing/ExternalRouteRef.ts b/packages/core-plugin-api/src/routing/ExternalRouteRef.ts index 401e34773c..c1c17c86df 100644 --- a/packages/core-plugin-api/src/routing/ExternalRouteRef.ts +++ b/packages/core-plugin-api/src/routing/ExternalRouteRef.ts @@ -27,6 +27,8 @@ export class ExternalRouteRefImpl< Optional extends boolean, > implements ExternalRouteRef { + // The marker is used for type checking while the symbol is used at runtime. + declare $$routeRefType: 'external'; readonly [routeRefType] = 'external'; constructor( diff --git a/packages/core-plugin-api/src/routing/RouteRef.ts b/packages/core-plugin-api/src/routing/RouteRef.ts index 31dd9ef518..78211e29c6 100644 --- a/packages/core-plugin-api/src/routing/RouteRef.ts +++ b/packages/core-plugin-api/src/routing/RouteRef.ts @@ -34,6 +34,8 @@ export type RouteRefConfig = { export class RouteRefImpl implements RouteRef { + // The marker is used for type checking while the symbol is used at runtime. + declare $$routeRefType: 'absolute'; readonly [routeRefType] = 'absolute'; constructor( diff --git a/packages/core-plugin-api/src/routing/SubRouteRef.ts b/packages/core-plugin-api/src/routing/SubRouteRef.ts index 6b329dfd42..be30d56096 100644 --- a/packages/core-plugin-api/src/routing/SubRouteRef.ts +++ b/packages/core-plugin-api/src/routing/SubRouteRef.ts @@ -29,6 +29,8 @@ const PARAM_PATTERN = /^\w+$/; export class SubRouteRefImpl implements SubRouteRef { + // The marker is used for type checking while the symbol is used at runtime. + declare $$routeRefType: 'sub'; readonly [routeRefType] = 'sub'; constructor( diff --git a/packages/core-plugin-api/src/routing/types.ts b/packages/core-plugin-api/src/routing/types.ts index 86d469c2cc..7e20d1ff67 100644 --- a/packages/core-plugin-api/src/routing/types.ts +++ b/packages/core-plugin-api/src/routing/types.ts @@ -33,13 +33,18 @@ export type RouteFunc = ( ...[params]: Params extends undefined ? readonly [] : readonly [Params] ) => string; +// This symbol is what we use at runtime to determine whether a given object +// is a type of RouteRef or not. It doesn't work well in TypeScript though since +// the `unique symbol` will refer to different values between package versions. +// For that reason we use the marker $$routeRefType to represent the symbol at +// compile-time instead of using the symbol directly. export const routeRefType: unique symbol = getOrCreateGlobalSingleton( 'route-ref-type', () => Symbol('route-ref-type'), ); export type RouteRef = { - readonly [routeRefType]: 'absolute'; + $$routeRefType: 'absolute'; // See routeRefType above params: ParamKeys; @@ -53,7 +58,7 @@ export type RouteRef = { }; export type SubRouteRef = { - readonly [routeRefType]: 'sub'; + $$routeRefType: 'sub'; // See routeRefType above parent: RouteRef; @@ -66,7 +71,7 @@ export type ExternalRouteRef< Params extends AnyParams = any, Optional extends boolean = any, > = { - readonly [routeRefType]: 'external'; + $$routeRefType: 'external'; // See routeRefType above params: ParamKeys; diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 0a2e7d013d..d9b91a2053 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,62 @@ # @backstage/create-app +## 0.3.44 + +### Patch Changes + +- e254368371: Switched the default `test` script in the package root to use `backstage-cli test` rather than `lerna run test`. This is thanks to the `@backstage/cli` now supporting running the test command from the project root. + + To apply this change to an existing project, apply the following change to your root `package.json`: + + ```diff + - "test": "lerna run test --since origin/master -- --coverage", + + "test": "backstage-cli test", + ``` + +- Updated dependencies + - @backstage/cli-common@0.1.4 + +## 0.3.43 + +### Patch Changes + +- 9325075eea: Added the default `ScmAuth` implementation to the app. + + To apply this change to an existing app, head to `packages/app/apis.ts`, import `ScmAuth` from `@backstage/integration-react`, and add a `ScmAuth.createDefaultApiFactory()` to your list of APIs: + + ```diff + import { + ScmIntegrationsApi, + scmIntegrationsApiRef, + + ScmAuth, + } from '@backstage/integration-react'; + + export const apis: AnyApiFactory[] = [ + ... + + ScmAuth.createDefaultApiFactory(), + ... + ]; + ``` + + If you have integrations towards SCM providers other than the default ones (github.com, gitlab.com, etc.), you will want to create a custom `ScmAuth` factory instead, for example like this: + + ```ts + createApiFactory({ + api: scmAuthApiRef, + deps: { + gheAuthApi: gheAuthApiRef, + githubAuthApi: githubAuthApiRef, + }, + factory: ({ githubAuthApi, gheAuthApi }) => + ScmAuth.merge( + ScmAuth.forGithub(githubAuthApi), + ScmAuth.forGithub(gheAuthApi, { + host: 'ghe.example.com', + }), + ), + }); + ``` + ## 0.3.42 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index eab773d562..22e1924d32 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "A CLI that helps you create your own Backstage app", - "version": "0.3.42", + "version": "0.3.44", "private": false, "publishConfig": { "access": "public" @@ -27,7 +27,7 @@ "start": "nodemon --" }, "dependencies": { - "@backstage/cli-common": "^0.1.3", + "@backstage/cli-common": "^0.1.4", "chalk": "^4.0.0", "commander": "^6.1.0", "fs-extra": "9.1.0", diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index eb28e5fe0c..7e2702e213 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -15,7 +15,7 @@ "tsc:full": "tsc --skipLibCheck false --incremental false", "clean": "backstage-cli clean && lerna run clean", "diff": "lerna run diff --", - "test": "lerna run test --since origin/master -- --coverage", + "test": "backstage-cli test", "test:all": "lerna run test -- --coverage", "lint": "lerna run lint --since origin/master --", "lint:all": "lerna run lint --", diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index 1105ea48ca..696e426aa6 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/dev-utils +## 0.2.11 + +### Patch Changes + +- Updated dependencies + - @backstage/core-app-api@0.1.15 + - @backstage/core-plugin-api@0.1.9 + - @backstage/core-components@0.6.0 + - @backstage/integration-react@0.1.11 + - @backstage/plugin-catalog-react@0.5.1 + ## 0.2.10 ### Patch Changes diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index d8a28eb9aa..d78a389cc5 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.2.10", + "version": "0.2.11", "private": false, "publishConfig": { "access": "public", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-app-api": "^0.1.14", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.7", + "@backstage/core-app-api": "^0.1.15", + "@backstage/core-components": "^0.6.0", + "@backstage/core-plugin-api": "^0.1.9", "@backstage/catalog-model": "^0.9.3", - "@backstage/integration-react": "^0.1.10", - "@backstage/plugin-catalog-react": "^0.5.0", + "@backstage/integration-react": "^0.1.11", + "@backstage/plugin-catalog-react": "^0.5.1", "@backstage/test-utils": "^0.1.17", "@backstage/theme": "^0.2.10", "@material-ui/core": "^4.12.2", @@ -50,7 +50,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.7.13", + "@backstage/cli": "^0.7.14", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/integration-react/CHANGELOG.md b/packages/integration-react/CHANGELOG.md index e723c266cf..18441c69aa 100644 --- a/packages/integration-react/CHANGELOG.md +++ b/packages/integration-react/CHANGELOG.md @@ -1,5 +1,100 @@ # @backstage/integration-react +## 0.1.11 + +### Patch Changes + +- 18148f23da: Added `ScmAuthApi` along with the implementation `ScmAuth`. The `ScmAuthApi` provides methods for client-side authentication towards multiple different source code management services simultaneously. + + When requesting credentials you supply a URL along with the same options as the other `OAuthApi`s, and optionally a request for additional high-level scopes. + + For example like this: + + ```ts + const { token } = await scmAuthApi.getCredentials({ + url: 'https://ghe.example.com/backstage/backstage', + additionalScope: { + repoWrite: true, + }, + }); + ``` + + The instantiation of the API can either be done with a default factory that adds support for the public providers (github.com, gitlab.com, etc.): + + ```ts + // in packages/app/apis.ts + ScmAuth.createDefaultApiFactory(); + ``` + + Or with a more custom setup that can add support for additional providers, for example like this: + + ```ts + createApiFactory({ + api: scmAuthApiRef, + deps: { + gheAuthApi: gheAuthApiRef, + githubAuthApi: githubAuthApiRef, + }, + factory: ({ githubAuthApi, gheAuthApi }) => + ScmAuth.merge( + ScmAuth.forGithub(githubAuthApi), + ScmAuth.forGithub(gheAuthApi, { + host: 'ghe.example.com', + }), + ), + }); + ``` + + The additional `gheAuthApiRef` utility API can be defined either inside the app itself if it's only used for this purpose, or inside an internal common package for APIs, such as `@internal/apis`: + + ```ts + const gheAuthApiRef: ApiRef = + createApiRef({ + id: 'internal.auth.ghe', + }); + ``` + + And then implemented using the `GithubAuth` class from `@backstage/core-app-api`: + + ```ts + createApiFactory({ + api: gheAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GithubAuth.create({ + provider: { + id: 'ghe', + icon: ..., + title: 'GHE' + }, + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user'], + environment: configApi.getOptionalString('auth.environment'), + }), + }) + ``` + + Finally you also need to add and configure another GitHub provider to the `auth-backend` using the provider ID `ghe`: + + ```ts + // Add the following options to `createRouter` in packages/backend/src/plugins/auth.ts + providerFactories: { + ghe: createGithubProvider(), + }, + ``` + + Other providers follow the same steps, but you will want to use the appropriate auth API implementation in the frontend, such as for example `GitlabAuth`. + +- Updated dependencies + - @backstage/integration@0.6.6 + - @backstage/core-plugin-api@0.1.9 + - @backstage/core-components@0.6.0 + ## 0.1.10 ### Patch Changes diff --git a/packages/integration-react/package.json b/packages/integration-react/package.json index 508497d9e0..c30c319dc5 100644 --- a/packages/integration-react/package.json +++ b/packages/integration-react/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/integration-react", "description": "Frontend package for managing integrations towards external systems", - "version": "0.1.10", + "version": "0.1.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,9 +22,9 @@ }, "dependencies": { "@backstage/config": "^0.1.10", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.8", - "@backstage/integration": "^0.6.5", + "@backstage/core-components": "^0.6.0", + "@backstage/core-plugin-api": "^0.1.9", + "@backstage/integration": "^0.6.6", "@backstage/theme": "^0.2.9", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -34,8 +34,8 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/dev-utils": "^0.2.10", + "@backstage/cli": "^0.7.14", + "@backstage/dev-utils": "^0.2.11", "@backstage/test-utils": "^0.1.17", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 8389d87f01..ced93c38ee 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/integration +## 0.6.7 + +### Patch Changes + +- a31afc5b62: Replace slash stripping regexp with trimEnd to remove CodeQL warning +- ca0559444c: Avoid usage of `.to*Case()`, preferring `.toLocale*Case('en-US')` instead. + +## 0.6.6 + +### Patch Changes + +- d1f2118389: Support selective GitHub app installation for GHE + ## 0.6.5 ### Patch Changes diff --git a/packages/integration/package.json b/packages/integration/package.json index 1866ef18de..4ed05269eb 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/integration", "description": "Helpers for managing integrations towards external systems", - "version": "0.6.5", + "version": "0.6.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -35,12 +35,13 @@ "git-url-parse": "^11.6.0", "@octokit/rest": "^18.5.3", "@octokit/auth-app": "^3.4.0", - "luxon": "^2.0.2" + "luxon": "^2.0.2", + "lodash": "^4.17.21" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/config-loader": "^0.6.7", - "@backstage/test-utils": "^0.1.17", + "@backstage/cli": "^0.7.15", + "@backstage/config-loader": "^0.6.10", + "@backstage/test-utils": "^0.1.18", "@types/jest": "^26.0.7", "@types/luxon": "^2.0.4", "msw": "^0.29.0" diff --git a/packages/integration/src/bitbucket/config.ts b/packages/integration/src/bitbucket/config.ts index b2ddfa3532..e2758952e9 100644 --- a/packages/integration/src/bitbucket/config.ts +++ b/packages/integration/src/bitbucket/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { trimEnd } from 'lodash'; import { isValidHost } from '../helpers'; const BITBUCKET_HOST = 'bitbucket.org'; @@ -83,7 +84,7 @@ export function readBitbucketIntegrationConfig( } if (apiBaseUrl) { - apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + apiBaseUrl = trimEnd(apiBaseUrl, '/'); } else if (host === BITBUCKET_HOST) { apiBaseUrl = BITBUCKET_API_BASE_URL; } diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index 62c7cd7a57..7e6057fc8d 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -141,7 +141,9 @@ class GithubAppManager { private async getInstallationData(owner: string): Promise { const allInstallations = await this.getInstallations(); const installation = allInstallations.find( - inst => inst.account?.login?.toLowerCase() === owner.toLowerCase(), + inst => + inst.account?.login?.toLocaleLowerCase('en-US') === + owner.toLocaleLowerCase('en-US'), ); if (installation) { return { diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index 4ecac99295..49674681f1 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { trimEnd } from 'lodash'; import { isValidHost } from '../helpers'; const GITHUB_HOST = 'github.com'; @@ -133,13 +134,13 @@ export function readGitHubIntegrationConfig( } if (apiBaseUrl) { - apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + apiBaseUrl = trimEnd(apiBaseUrl, '/'); } else if (host === GITHUB_HOST) { apiBaseUrl = GITHUB_API_BASE_URL; } if (rawBaseUrl) { - rawBaseUrl = rawBaseUrl.replace(/\/+$/, ''); + rawBaseUrl = trimEnd(rawBaseUrl, '/'); } else if (host === GITHUB_HOST) { rawBaseUrl = GITHUB_RAW_BASE_URL; } diff --git a/packages/integration/src/gitlab/config.ts b/packages/integration/src/gitlab/config.ts index 3bc8f76d44..7471ba0078 100644 --- a/packages/integration/src/gitlab/config.ts +++ b/packages/integration/src/gitlab/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { trimEnd } from 'lodash'; import { isValidHost, isValidUrl } from '../helpers'; const GITLAB_HOST = 'gitlab.com'; @@ -67,13 +68,13 @@ export function readGitLabIntegrationConfig( let baseUrl = config.getOptionalString('baseUrl'); if (apiBaseUrl) { - apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + apiBaseUrl = trimEnd(apiBaseUrl, '/'); } else if (host === GITLAB_HOST) { apiBaseUrl = GITLAB_API_BASE_URL; } if (baseUrl) { - baseUrl = baseUrl.replace(/\/+$/, ''); + baseUrl = trimEnd(baseUrl, '/'); } else { baseUrl = `https://${host}`; } diff --git a/packages/integration/src/helpers.ts b/packages/integration/src/helpers.ts index dc1ce1a502..52d2cf6571 100644 --- a/packages/integration/src/helpers.ts +++ b/packages/integration/src/helpers.ts @@ -15,6 +15,7 @@ */ import parseGitUrl from 'git-url-parse'; +import { trimEnd } from 'lodash'; import { ScmIntegration, ScmIntegrationsGroup } from './types'; /** Checks whether the given argument is a valid URL hostname */ @@ -83,9 +84,10 @@ export function defaultScmResolveUrl(options: { // If it is an absolute path, move relative to the repo root const { filepath } = parseGitUrl(base); updated = new URL(base); - const repoRootPath = updated.pathname - .substring(0, updated.pathname.length - filepath.length) - .replace(/\/+$/, ''); + const repoRootPath = trimEnd( + updated.pathname.substring(0, updated.pathname.length - filepath.length), + '/', + ); updated.pathname = `${repoRootPath}${url}`; } else { // For relative URLs, just let the default URL constructor handle the diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 415515a3e9..7f46d8d8b8 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/techdocs-common +## 0.10.3 + +### Patch Changes + +- 156421c59a: Sets the default techdocs docker image to the [latest released version - v0.3.3](https://github.com/backstage/techdocs-container/releases/tag/v0.3.3). +- Updated dependencies + - @backstage/catalog-model@0.9.4 + - @backstage/backend-common@0.9.6 + - @backstage/integration@0.6.7 + +## 0.10.2 + +### Patch Changes + +- 1c75e8bf98: Add more context to techdocs log lines when files are not found along with + ensuring that the routers return 404 with a descriptive message. +- e92f0f728b: Locks the version of the default docker image used to generate TechDocs. As of + this changelog entry, it is v0.3.2! +- Updated dependencies + - @backstage/backend-common@0.9.5 + - @backstage/integration@0.6.6 + ## 0.10.1 ### Patch Changes diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index 2f6d2a6f89..fca67d8948 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -256,6 +256,7 @@ export class TechdocsGenerator implements GeneratorBase { config: Config; scmIntegrations: ScmIntegrationRegistry; }); + static readonly defaultDockerImage = 'spotify/techdocs:v0.3.3'; // (undocumented) static fromConfig( config: Config, diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 8e56c9e002..cbcdc04569 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.10.1", + "version": "0.10.3", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -38,12 +38,12 @@ "dependencies": { "@azure/identity": "^1.5.0", "@azure/storage-blob": "^12.5.0", - "@backstage/backend-common": "^0.9.3", - "@backstage/catalog-model": "^0.9.1", + "@backstage/backend-common": "^0.9.6", + "@backstage/catalog-model": "^0.9.4", "@backstage/config": "^0.1.8", "@backstage/errors": "^0.1.1", "@backstage/search-common": "^0.2.0", - "@backstage/integration": "^0.6.4", + "@backstage/integration": "^0.6.7", "@google-cloud/storage": "^5.6.0", "@trendyol-js/openstack-swift-sdk": "^0.0.4", "@types/express": "^4.17.6", @@ -60,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.7.12", + "@backstage/cli": "^0.7.15", "@types/fs-extra": "^9.0.5", "@types/js-yaml": "^4.0.0", "@types/mime-types": "^2.1.0", diff --git a/packages/techdocs-common/src/stages/generate/techdocs.ts b/packages/techdocs-common/src/stages/generate/techdocs.ts index c3d48507ce..d5d4584c34 100644 --- a/packages/techdocs-common/src/stages/generate/techdocs.ts +++ b/packages/techdocs-common/src/stages/generate/techdocs.ts @@ -37,9 +37,12 @@ import { GeneratorRunOptions, } from './types'; -const defaultDockerImage = 'spotify/techdocs'; - export class TechdocsGenerator implements GeneratorBase { + /** + * The default docker image (and version) used to generate content. Public + * and static so that techdocs-common consumers can use the same version. + */ + public static readonly defaultDockerImage = 'spotify/techdocs:v0.3.3'; private readonly logger: Logger; private readonly containerRunner: ContainerRunner; private readonly options: GeneratorConfig; @@ -124,7 +127,8 @@ export class TechdocsGenerator implements GeneratorBase { break; case 'docker': await this.containerRunner.runContainer({ - imageName: this.options.dockerImage ?? defaultDockerImage, + imageName: + this.options.dockerImage ?? TechdocsGenerator.defaultDockerImage, args: ['build', '-d', '/output'], logStream, mountDirs, diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index dbc81a25b4..35bc80438b 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -352,5 +352,16 @@ describe('AwsS3Publish', () => { 'content-type': 'text/plain; charset=utf-8', }); }); + + it('should return 404 if file is not found', async () => { + const response = await request(app).get( + `/${entityTripletPath}/not-found.html`, + ); + expect(response.status).toBe(404); + + expect(Buffer.from(response.text).toString('utf8')).toEqual( + 'File Not Found', + ); + }); }); }); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index 85857929d5..f2e1b867ec 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -351,8 +351,10 @@ export class AwsS3Publish implements PublisherBase { res.send(await streamToBuffer(stream)); } catch (err) { - this.logger.warn(err.message); - res.status(404).json(err.message); + this.logger.warn( + `TechDocs S3 router failed to serve static files from bucket ${this.bucketName} at key ${filePath}: ${err.message}`, + ); + res.status(404).send('File Not Found'); } }; } diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 914cc24986..cace6caae8 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -375,5 +375,16 @@ describe('AzureBlobStoragePublish', () => { 'content-type': 'text/plain; charset=utf-8', }); }); + + it('should return 404 if file is not found', async () => { + const response = await request(app).get( + `/${entityTripletPath}/not-found.html`, + ); + expect(response.status).toBe(404); + + expect(Buffer.from(response.text).toString('utf8')).toEqual( + 'File Not Found', + ); + }); }); }); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index 4dfd8ba2f5..60a93ea520 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -328,8 +328,8 @@ export class AzureBlobStoragePublish implements PublisherBase { const fileExtension = platformPath.extname(filePath); const responseHeaders = getHeadersForFileExtension(fileExtension); - try { - this.download(this.containerName, filePath).then(fileContent => { + this.download(this.containerName, filePath) + .then(fileContent => { // Inject response headers for (const [headerKey, headerValue] of Object.entries( responseHeaders, @@ -337,11 +337,13 @@ export class AzureBlobStoragePublish implements PublisherBase { res.setHeader(headerKey, headerValue); } res.send(fileContent); + }) + .catch(e => { + this.logger.warn( + `TechDocs Azure router failed to serve content from container ${this.containerName} at path ${filePath}: ${e.message}`, + ); + res.status(404).send('File Not Found'); }); - } catch (e) { - this.logger.error(e.message); - res.status(404).json(e.message); - } }; } diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index 4247442c54..0190128e6c 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -351,5 +351,16 @@ describe('GoogleGCSPublish', () => { 'content-type': 'text/plain; charset=utf-8', }); }); + + it('should return 404 if file is not found', async () => { + const response = await request(app).get( + `/${entityTripletPath}/not-found.html`, + ); + expect(response.status).toBe(404); + + expect(Buffer.from(response.text).toString('utf8')).toEqual( + 'File Not Found', + ); + }); }); }); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 1f519829f2..81bbd556eb 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -261,10 +261,12 @@ export class GoogleGCSPublish implements PublisherBase { res.writeHead(200, responseHeaders); }) .on('error', err => { - this.logger.warn(err.message); + this.logger.warn( + `TechDocs Google GCS router failed to serve content from bucket ${this.bucketName} at path ${filePath}: ${err.message}`, + ); // Send a 404 with a meaningful message if possible. if (!res.headersSent) { - res.status(404).send(err.message); + res.status(404).send('File Not Found'); } else { res.destroy(); } diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index 84007c0f5b..8dde687997 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -364,5 +364,21 @@ describe('OpenStackSwiftPublish', () => { 'content-type': 'text/plain; charset=utf-8', }); }); + + it('should return 404 if file is not found', async () => { + const { + kind, + metadata: { namespace, name }, + } = entity; + + const response = await request(app).get( + `/${namespace}/${kind}/${name}/not-found.html`, + ); + expect(response.status).toBe(404); + + expect(Buffer.from(response.text).toString('utf8')).toEqual( + 'File Not Found', + ); + }); }); }); diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index 2902d7384f..e224fbdbaf 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -245,10 +245,15 @@ export class OpenStackSwiftPublish implements PublisherBase { res.send(await streamToBuffer(stream)); } catch (err) { - this.logger.warn(err.message); - res.status(404).send(err.message); + this.logger.warn( + `TechDocs OpenStack swift router failed to serve content from container ${this.containerName} at path ${filePath}: ${err.message}`, + ); + res.status(404).send('File Not Found'); } } else { + this.logger.warn( + `TechDocs OpenStack swift router failed to serve content from container ${this.containerName} at path ${filePath}: Not found`, + ); res.status(404).send('File Not Found'); } }; diff --git a/packages/test-utils-core/CHANGELOG.md b/packages/test-utils-core/CHANGELOG.md index a84ab931f3..24d84eb105 100644 --- a/packages/test-utils-core/CHANGELOG.md +++ b/packages/test-utils-core/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/test-utils-core +## 0.1.3 + +### Patch Changes + +- ca0559444c: Avoid usage of `.to*Case()`, preferring `.toLocale*Case('en-US')` instead. + ## 0.1.2 ### Patch Changes diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json index b88184f121..474d62a670 100644 --- a/packages/test-utils-core/package.json +++ b/packages/test-utils-core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils-core", "description": "Utilities to test Backstage core", - "version": "0.1.2", + "version": "0.1.3", "private": false, "publishConfig": { "access": "public", diff --git a/packages/test-utils-core/src/testUtils/Keyboard.js b/packages/test-utils-core/src/testUtils/Keyboard.js index 2f8e218762..3f45ca90f6 100644 --- a/packages/test-utils-core/src/testUtils/Keyboard.js +++ b/packages/test-utils-core/src/testUtils/Keyboard.js @@ -87,7 +87,7 @@ export class Keyboard { const attrs = [...element.attributes] .map(attr => `${attr.name}="${attr.value}"`) .join(' '); - return `<${element.nodeName.toLowerCase()} ${attrs}>`; + return `<${element.nodeName.toLocaleLowerCase('en-US')} ${attrs}>`; } get focused() { diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index 08594ae824..2521288c05 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/test-utils +## 0.1.18 + +### Patch Changes + +- e749a38e89: Added a mock implementation of the `AnalyticsApi`, which can be used to make + assertions about captured analytics events. +- Updated dependencies + - @backstage/core-plugin-api@0.1.10 + - @backstage/core-app-api@0.1.16 + - @backstage/test-utils-core@0.1.3 + ## 0.1.17 ### Patch Changes diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index 214c26c825..f4b3fe12bb 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsEvent } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { ErrorApi } from '@backstage/core-plugin-api'; import { ErrorContext } from '@backstage/core-plugin-api'; @@ -15,6 +17,22 @@ import { RouteRef } from '@backstage/core-plugin-api'; import { StorageApi } from '@backstage/core-plugin-api'; import { StorageValueChange } from '@backstage/core-plugin-api'; +// Warning: (ae-missing-release-tag) "MockAnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class MockAnalyticsApi implements AnalyticsApi { + // (undocumented) + captureEvent({ + action, + subject, + value, + attributes, + context, + }: AnalyticsEvent): void; + // (undocumented) + getEvents(): AnalyticsEvent[]; +} + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // Warning: (ae-missing-release-tag) "mockBreakpoint" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 0134382b7f..f63f7ae1e6 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.17", + "version": "0.1.18", "private": false, "publishConfig": { "access": "public", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-app-api": "^0.1.8", - "@backstage/core-plugin-api": "^0.1.6", - "@backstage/test-utils-core": "^0.1.2", + "@backstage/core-app-api": "^0.1.16", + "@backstage/core-plugin-api": "^0.1.10", + "@backstage/test-utils-core": "^0.1.3", "@backstage/theme": "^0.2.9", "@material-ui/core": "^4.12.2", "@testing-library/jest-dom": "^5.10.1", @@ -46,7 +46,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.7.8", + "@backstage/cli": "^0.7.15", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts new file mode 100644 index 0000000000..3593774a01 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { MockAnalyticsApi } from './MockAnalyticsApi'; + +describe('MockAnalyticsApi', () => { + const context = { + pluginId: 'some-plugin', + routeRef: 'some-route-ref', + extension: 'some-extension', + }; + + it('should collect events', () => { + const api = new MockAnalyticsApi(); + + api.captureEvent({ action: 'action-1', subject: 'subject-1', context }); + api.captureEvent({ + action: 'action-2', + subject: 'subject-2', + value: 42, + context, + }); + api.captureEvent({ + action: 'action-3', + subject: 'subject-3', + value: 1337, + attributes: { some: 'context' }, + context, + }); + + expect(api.getEvents()[0]).toMatchObject({ + subject: 'subject-1', + action: 'action-1', + context, + }); + expect(api.getEvents()[1]).toMatchObject({ + subject: 'subject-2', + action: 'action-2', + value: 42, + context, + }); + expect(api.getEvents()[2]).toMatchObject({ + subject: 'subject-3', + action: 'action-3', + value: 1337, + context, + attributes: { + some: 'context', + }, + }); + }); +}); diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts new file mode 100644 index 0000000000..28585145ad --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/MockAnalyticsApi.ts @@ -0,0 +1,41 @@ +/* + * 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 { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api'; + +export class MockAnalyticsApi implements AnalyticsApi { + private events: AnalyticsEvent[] = []; + + captureEvent({ + action, + subject, + value, + attributes, + context, + }: AnalyticsEvent) { + this.events.push({ + action, + subject, + context, + ...(value !== undefined ? { value } : {}), + ...(attributes !== undefined ? { attributes } : {}), + }); + } + + getEvents(): AnalyticsEvent[] { + return this.events; + } +} diff --git a/packages/test-utils/src/testUtils/apis/AnalyticsApi/index.ts b/packages/test-utils/src/testUtils/apis/AnalyticsApi/index.ts new file mode 100644 index 0000000000..dc5fd062aa --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/AnalyticsApi/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { MockAnalyticsApi } from './MockAnalyticsApi'; diff --git a/packages/test-utils/src/testUtils/apis/index.ts b/packages/test-utils/src/testUtils/apis/index.ts index 7bc1f74dcd..44b423a264 100644 --- a/packages/test-utils/src/testUtils/apis/index.ts +++ b/packages/test-utils/src/testUtils/apis/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export * from './AnalyticsApi'; export * from './ErrorApi'; export * from './StorageApi'; diff --git a/packages/version-bridge/package.json b/packages/version-bridge/package.json index 3adbac681d..0be6f0cacd 100644 --- a/packages/version-bridge/package.json +++ b/packages/version-bridge/package.json @@ -36,7 +36,7 @@ "@backstage/cli": "^0.7.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/react-hooks": "^3.4.2" + "@testing-library/react-hooks": "^7.0.2" }, "files": [ "dist" diff --git a/plugins/allure/CHANGELOG.md b/plugins/allure/CHANGELOG.md index 9bc2159d40..ac8d1d0cc1 100644 --- a/plugins/allure/CHANGELOG.md +++ b/plugins/allure/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-allure +## 0.1.5 + +### Patch Changes + +- 81a41ec249: Added a `name` key to all extensions in order to improve Analytics API metadata. +- Updated dependencies + - @backstage/core-components@0.6.1 + - @backstage/core-plugin-api@0.1.10 + - @backstage/plugin-catalog-react@0.5.2 + - @backstage/catalog-model@0.9.4 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@0.1.9 + - @backstage/core-components@0.6.0 + - @backstage/plugin-catalog-react@0.5.1 + ## 0.1.3 ### Patch Changes diff --git a/plugins/allure/package.json b/plugins/allure/package.json index d9eee7ab97..ec0c86b40f 100644 --- a/plugins/allure/package.json +++ b/plugins/allure/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-allure", "description": "A Backstage plugin that integrates with Allure", - "version": "0.1.3", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,10 +22,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.3", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.8", - "@backstage/plugin-catalog-react": "^0.5.0", + "@backstage/catalog-model": "^0.9.4", + "@backstage/core-components": "^0.6.1", + "@backstage/core-plugin-api": "^0.1.10", + "@backstage/plugin-catalog-react": "^0.5.2", "@backstage/theme": "^0.2.10", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -36,10 +36,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/core-app-api": "^0.1.14", - "@backstage/dev-utils": "^0.2.10", - "@backstage/test-utils": "^0.1.17", + "@backstage/cli": "^0.7.15", + "@backstage/core-app-api": "^0.1.16", + "@backstage/dev-utils": "^0.2.11", + "@backstage/test-utils": "^0.1.18", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/allure/src/plugin.ts b/plugins/allure/src/plugin.ts index 3907707b18..f5ca7beeb3 100644 --- a/plugins/allure/src/plugin.ts +++ b/plugins/allure/src/plugin.ts @@ -44,6 +44,7 @@ export const allurePlugin = createPlugin({ export const EntityAllureReportContent = allurePlugin.provide( createRoutableExtension({ + name: 'EntityAllureReportContent', component: () => import('./components/AllureReportComponent').then( m => m.AllureReportComponent, diff --git a/plugins/analytics-module-ga/.eslintrc.js b/plugins/analytics-module-ga/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/analytics-module-ga/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/analytics-module-ga/README.md b/plugins/analytics-module-ga/README.md new file mode 100644 index 0000000000..a0c4fe517a --- /dev/null +++ b/plugins/analytics-module-ga/README.md @@ -0,0 +1,149 @@ +# Analytics Module: Google Analytics + +This plugin provides an opinionated implementation of the Backstage Analytics +API for Google Analytics. Once installed and configured, analytics events will +be sent to GA as your users navigate and use your Backstage instance. + +This plugin contains no other functionality. + +## Installation + +1. Install the plugin package in your Backstage app: + `cd packages/app && yarn add @backstage/plugin-analytics-module-ga` +2. Wire up the API implementation to your App: + +```tsx +// packages/app/src/apis.ts +import { analyticsApiRef, configApiRef } from '@backstage/core-plugin-api'; +import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga'; + +export const apis: AnyApiFactory[] = [ + // Instantiate and register the GA Analytics API Implementation. + createApiFactory({ + api: analyticsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => GoogleAnalytics.fromConfig(configApi), + }), +]; +``` + +3. Configure the plugin in your `app-config.yaml`: + +The following is the minimum configuration required to start sending analytics +events to GA. All that's needed is your Universal Analytics tracking ID: + +```yaml +# app-config.yaml +app: + analytics: + ga: + trackingId: UA-0000000-0 +``` + +## Configuration + +In order to be able to analyze usage of your Backstage instance _by plugin_, we +strongly recommend configuring at least one [custom dimension][what-is-a-custom-dimension] +to capture Plugin IDs associated with events, including page views. + +1. First, [configure the custom dimension in GA][configure-custom-dimension]. + Be sure to set the Scope to `hit`, and name it something like `Plugin`. Note + the index of the dimension you just created (e.g. `1`, if this is the first + custom dimension you've created in your GA property). +2. Then, add a mapping to your `app.analytics.ga` configuration that instructs + the plugin to capture Plugin IDs on the custom dimension you just created. + It should look like this: + +```yaml +app: + analytics: + ga: + trackingId: UA-0000000-0 + customDimensionsMetrics: + - type: dimension + index: 1 + source: context + key: pluginId +``` + +You can configure additional custom dimension and metric collection by adding +more entries to the `customDimensionsMetrics` array: + +```yaml +app: + analytics: + ga: + customDimensionsMetrics: + - type: dimension + index: 1 + source: context + key: pluginId + - type: dimension + index: 2 + source: context + key: routeRef + - type: dimension + index: 3 + source: context + key: extension + - type: metric + index: 1 + source: attributes + key: someEventContextAttr +``` + +### Debugging and Testing + +In pre-production environments, you may wish to set additional configurations +to turn off reporting to Analytics and/or print debug statements to the +console. You can do so like this: + +```yaml +app: + analytics: + ga: + testMode: true # Prevents data being sent to GA + debug: true # Logs analytics event to the web console +``` + +You might commonly set the above in an `app-config.local.yaml` file, which is +normally `gitignore`'d but loaded and merged in when Backstage is bootstrapped. + +## Development + +If you would like to contribute improvements to this plugin, the easiest way to +make and test changes is to do the following: + +1. Clone the main Backstage monorepo `git clone git@github.com:backstage/backstage.git` +2. Install all dependencies `yarn install` +3. If one does not exist, create an `app-config.local.yaml` file in the root of + the monorepo and add config for this plugin (see below) +4. Enter this plugin's working directory: `cd plugins/analytics-provider-ga` +5. Start the plugin in isolation: `yarn start` +6. Navigate to the playground page at `http://localhost:3000/ga` +7. Open the web console to see events fire when you navigate or when you + interact with instrumented components. + +Code for the isolated version of the plugin can be found inside the [/dev](./dev) +directory. Changes to the plugin are hot-reloaded. + +#### Recommended Dev Config + +Paste this into your `app-config.local.yaml` while developing this plugin: + +```yaml +app: + analytics: + ga: + trackingId: UA-0000000-0 + debug: true + testMode: true + customDimensionsMetrics: + - type: dimension + index: 1 + source: context + key: pluginId +``` + +[what-is-a-custom-dimension]: https://support.google.com/analytics/answer/2709828 +[configure-custom-dimension]: https://support.google.com/analytics/answer/2709828#configuration diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md new file mode 100644 index 0000000000..7ca3b1646f --- /dev/null +++ b/plugins/analytics-module-ga/api-report.md @@ -0,0 +1,31 @@ +## API Report File for "@backstage/plugin-analytics-module-ga" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { Config } from '@backstage/config'; + +// Warning: (ae-missing-release-tag) "analyticsModuleGA" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const analyticsModuleGA: BackstagePlugin<{}, {}>; + +// Warning: (ae-missing-release-tag) "GoogleAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class GoogleAnalytics implements AnalyticsApi { + captureEvent({ + context, + action, + subject, + value, + attributes, + }: AnalyticsEvent): void; + static fromConfig(config: Config): GoogleAnalytics; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/analytics-module-ga/config.d.ts b/plugins/analytics-module-ga/config.d.ts new file mode 100644 index 0000000000..4ccd66e1dd --- /dev/null +++ b/plugins/analytics-module-ga/config.d.ts @@ -0,0 +1,87 @@ +/* + * 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. + */ + +export interface Config { + app: { + // TODO: Only marked as optional because backstage-cli config:check in the + // context of the monorepo is too strict. Ideally, this would be marked as + // required. + analytics?: { + ga: { + /** + * Google Analytics tracking ID, e.g. UA-000000-0 + * @visibility frontend + */ + trackingId: string; + + /** + * Whether or not to log analytics debug statements to the console. + * Defaults to false. + * + * @visibility frontend + */ + debug?: boolean; + + /** + * Prevents events from actually being sent when set to true. Defaults + * to false. + * + * @visibility frontend + */ + testMode?: boolean; + + /** + * Configuration informing how Analytics Context and Event Attributes + * metadata will be captured in Google Analytics. + */ + customDimensionsMetrics?: Array<{ + /** + * Specifies whether the corresponding metadata should be collected + * as a Google Analytics custom dimension or custom metric. + * + * @visibility frontend + */ + type: 'dimension' | 'metric'; + + /** + * The index of the Google Analytics custom dimension or metric that + * the metadata should be written to. + * + * @visibility frontend + */ + index: number; + + /** + * Specifies whether the desired value lives as an attribute on the + * Analytics Context or the Event's Attributes. + * + * @visibility frontend + */ + source: 'context' | 'attributes'; + + /** + * The property of the context or attributes that should be captured. + * e.g. to capture the Plugin ID associated with an event, the source + * should be set to "context" and the key should be set to pluginId. + * + * @visibility frontend + */ + key: string; + }>; + }; + }; + }; +} diff --git a/plugins/analytics-module-ga/dev/Playground.tsx b/plugins/analytics-module-ga/dev/Playground.tsx new file mode 100644 index 0000000000..cf21aadacc --- /dev/null +++ b/plugins/analytics-module-ga/dev/Playground.tsx @@ -0,0 +1,26 @@ +/* + * 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 { Link } from '@backstage/core-components'; + +export const Playground = () => { + return ( + <> + Click Here + + ); +}; diff --git a/plugins/analytics-module-ga/dev/index.tsx b/plugins/analytics-module-ga/dev/index.tsx new file mode 100644 index 0000000000..74b3439cff --- /dev/null +++ b/plugins/analytics-module-ga/dev/index.tsx @@ -0,0 +1,28 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import { analyticsModuleGA } from '../src/plugin'; +import { Playground } from './Playground'; + +createDevApp() + .registerPlugin(analyticsModuleGA) + .addPage({ + path: '/ga', + title: 'GA Playground', + element: , + }) + .render(); diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json new file mode 100644 index 0000000000..3bb9d72948 --- /dev/null +++ b/plugins/analytics-module-ga/package.json @@ -0,0 +1,54 @@ +{ + "name": "@backstage/plugin-analytics-module-ga", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.5", + "@backstage/core-components": "^0.6.1", + "@backstage/core-plugin-api": "^0.1.10", + "@backstage/theme": "^0.2.10", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.57", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-ga": "^3.3.0", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.7.15", + "@backstage/core-app-api": "^0.1.16", + "@backstage/dev-utils": "^0.2.11", + "@backstage/test-utils": "^0.1.18", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.29.0" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts new file mode 100644 index 0000000000..cbcf95cb55 --- /dev/null +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -0,0 +1,211 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import ReactGA from 'react-ga'; +import { GoogleAnalytics } from './GoogleAnalytics'; + +describe('GoogleAnalytics', () => { + const trackingId = 'UA-000000-0'; + const basicValidConfig = new ConfigReader({ + app: { analytics: { ga: { trackingId, testMode: true } } }, + }); + + beforeEach(() => { + ReactGA.testModeAPI.resetCalls(); + }); + + describe('fromConfig', () => { + it('throws when missing trackingId', () => { + const config = new ConfigReader({ app: { analytics: { ga: {} } } }); + expect(() => GoogleAnalytics.fromConfig(config)).toThrowError( + /Missing required config value/, + ); + }); + + it('returns implementation', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + expect(api.captureEvent).toBeDefined(); + + // Initializes GA with tracking ID. + expect(ReactGA.testModeAPI.calls[0]).toEqual([ + 'create', + trackingId, + 'auto', + ]); + }); + }); + + describe('integration', () => { + const context = { + extension: 'App', + pluginId: 'some-plugin', + routeRef: 'unknown', + releaseNum: 1337, + }; + const advancedConfig = new ConfigReader({ + app: { + analytics: { + ga: { + trackingId, + testMode: true, + customDimensionsMetrics: [ + { + type: 'dimension', + index: 1, + source: 'context', + key: 'pluginId', + }, + { + type: 'dimension', + index: 2, + source: 'attributes', + key: 'extraDimension', + }, + { + type: 'metric', + index: 1, + source: 'context', + key: 'releaseNum', + }, + { + type: 'metric', + index: 2, + source: 'attributes', + key: 'extraMetric', + }, + ], + }, + }, + }, + }); + + it('tracks basic pageview', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + const [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + }); + + it('tracks basic event', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + + const expectedAction = 'click'; + const expectedLabel = 'on something'; + const expectedValue = 42; + api.captureEvent({ + action: expectedAction, + subject: expectedLabel, + value: expectedValue, + context, + }); + + const [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + eventCategory: context.extension, + eventAction: expectedAction, + eventLabel: expectedLabel, + eventValue: expectedValue, + }); + }); + + it('captures configured custom dimensions/metrics on pageviews', () => { + const api = GoogleAnalytics.fromConfig(advancedConfig); + api.captureEvent({ + action: 'navigate', + subject: '/a-page', + context, + }); + + // Expect a set command first. + const [setCommand, setData] = ReactGA.testModeAPI.calls[1]; + expect(setCommand).toBe('set'); + expect(setData).toMatchObject({ + dimension1: context.pluginId, + metric1: context.releaseNum, + }); + + // Followed by a send command. + const [sendCommand, sendData] = ReactGA.testModeAPI.calls[2]; + expect(sendCommand).toBe('send'); + expect(sendData).toMatchObject({ + hitType: 'pageview', + page: '/a-page', + }); + }); + + it('captures configured custom dimensions/metrics on events', () => { + const api = GoogleAnalytics.fromConfig(advancedConfig); + + const expectedAction = 'search'; + const expectedLabel = 'some query'; + const expectedValue = 5; + api.captureEvent({ + action: expectedAction, + subject: expectedLabel, + value: expectedValue, + attributes: { + extraDimension: false, + extraMetric: 0, + }, + context, + }); + + const [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + eventCategory: context.extension, + eventAction: expectedAction, + eventLabel: expectedLabel, + eventValue: expectedValue, + dimension1: context.pluginId, + metric1: context.releaseNum, + dimension2: false, + metric2: 0, + }); + }); + + it('does not pass non-numeric data on metrics', () => { + const api = GoogleAnalytics.fromConfig(advancedConfig); + + api.captureEvent({ + action: 'verb', + subject: 'noun', + attributes: { + extraMetric: 'not a number', + }, + context, + }); + + const [, data] = ReactGA.testModeAPI.calls[1]; + expect(data).not.toMatchObject({ + metric2: 'not a number', + }); + }); + }); +}); diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts new file mode 100644 index 0000000000..1e6216936c --- /dev/null +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -0,0 +1,154 @@ +/* + * 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 ReactGA from 'react-ga'; +import { + AnalyticsApi, + AnalyticsContextValue, + AnalyticsEventAttributes, + AnalyticsEvent, +} from '@backstage/core-plugin-api'; +import { Config } from '@backstage/config'; + +type CustomDimensionOrMetricConfig = { + type: 'dimension' | 'metric'; + index: number; + source: 'context' | 'attributes'; + key: string; +}; + +/** + * Google Analytics API provider for the Backstage Analytics API. + */ +export class GoogleAnalytics implements AnalyticsApi { + private readonly cdmConfig: CustomDimensionOrMetricConfig[]; + + /** + * Instantiate the implementation and initialize ReactGA. + */ + private constructor({ + cdmConfig, + trackingId, + testMode, + debug, + }: { + cdmConfig: CustomDimensionOrMetricConfig[]; + trackingId: string; + testMode: boolean; + debug: boolean; + }) { + this.cdmConfig = cdmConfig; + + // Initialize Google Analytics. + ReactGA.initialize(trackingId, { testMode, debug, titleCase: false }); + } + + /** + * Instantiate a fully configured GA Analytics API implementation. + */ + static fromConfig(config: Config) { + // Get all necessary configuration. + const trackingId = config.getString('app.analytics.ga.trackingId'); + const debug = config.getOptionalBoolean('app.analytics.ga.debug') ?? false; + const testMode = + config.getOptionalBoolean('app.analytics.ga.testMode') ?? false; + const cdmConfig = + config + .getOptionalConfigArray('app.analytics.ga.customDimensionsMetrics') + ?.map(c => { + return { + type: c.getString('type') as CustomDimensionOrMetricConfig['type'], + index: c.getNumber('index'), + source: c.getString( + 'source', + ) as CustomDimensionOrMetricConfig['source'], + key: c.getString('key'), + }; + }) ?? []; + + // Return an implementation instance. + return new GoogleAnalytics({ + trackingId, + cdmConfig, + testMode, + debug, + }); + } + + /** + * Primary event capture implementation. Handles core navigate event as a + * pageview and the rest as custom events. All custom dimensions/metrics are + * applied as they should be (set on pageview, merged object on events). + */ + captureEvent({ + context, + action, + subject, + value, + attributes, + }: AnalyticsEvent) { + const customMetadata = this.getCustomDimensionMetrics(context, attributes); + + if (action === 'navigate' && context.extension === 'App') { + // Set any/all custom dimensions. + if (Object.keys(customMetadata).length) { + ReactGA.set(customMetadata); + } + + ReactGA.pageview(subject); + return; + } + + ReactGA.event({ + category: context.extension || 'App', + action, + label: subject, + value, + ...customMetadata, + }); + } + + /** + * Returns an object of dimensions/metrics given an Analytics Context and an + * Event Attributes, e.g. { dimension1: "some value", metric8: 42 } + */ + private getCustomDimensionMetrics( + context: AnalyticsContextValue, + attributes: AnalyticsEventAttributes = {}, + ) { + const customDimensionsMetrics: { [x: string]: string | number | boolean } = + {}; + + this.cdmConfig.forEach(config => { + const value = + config.source === 'context' + ? context[config.key] + : attributes[config.key]; + + // Never pass a non-numeric value on a metric. + if (config.type === 'metric' && typeof value !== 'number') { + return; + } + + // Only set defined values. + if (value !== undefined) { + customDimensionsMetrics[`${config.type}${config.index}`] = value; + } + }); + + return customDimensionsMetrics; + } +} diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/index.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/index.ts new file mode 100644 index 0000000000..a8d11e4c6c --- /dev/null +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { GoogleAnalytics } from './GoogleAnalytics'; diff --git a/plugins/analytics-module-ga/src/index.ts b/plugins/analytics-module-ga/src/index.ts new file mode 100644 index 0000000000..17a3213c6d --- /dev/null +++ b/plugins/analytics-module-ga/src/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { analyticsModuleGA } from './plugin'; +export { GoogleAnalytics } from './apis/implementations/AnalyticsApi'; diff --git a/plugins/analytics-module-ga/src/plugin.test.ts b/plugins/analytics-module-ga/src/plugin.test.ts new file mode 100644 index 0000000000..394e5fc070 --- /dev/null +++ b/plugins/analytics-module-ga/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * 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 { analyticsModuleGA } from './plugin'; + +describe('google-analytics', () => { + it('should export plugin', () => { + expect(analyticsModuleGA).toBeDefined(); + }); +}); diff --git a/plugins/analytics-module-ga/src/plugin.ts b/plugins/analytics-module-ga/src/plugin.ts new file mode 100644 index 0000000000..d63ae6616f --- /dev/null +++ b/plugins/analytics-module-ga/src/plugin.ts @@ -0,0 +1,20 @@ +/* + * 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 { createPlugin } from '@backstage/core-plugin-api'; + +export const analyticsModuleGA = createPlugin({ + id: 'analytics-provider-ga', +}); diff --git a/plugins/analytics-module-ga/src/setupTests.ts b/plugins/analytics-module-ga/src/setupTests.ts new file mode 100644 index 0000000000..fc6dbd98f8 --- /dev/null +++ b/plugins/analytics-module-ga/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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 '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index f544a8ef30..69b9637b3f 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-api-docs +## 0.6.11 + +### Patch Changes + +- 81a41ec249: Added a `name` key to all extensions in order to improve Analytics API metadata. +- Updated dependencies + - @backstage/core-components@0.6.1 + - @backstage/core-plugin-api@0.1.10 + - @backstage/plugin-catalog@0.7.0 + - @backstage/plugin-catalog-react@0.5.2 + - @backstage/catalog-model@0.9.4 + +## 0.6.10 + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@0.1.9 + - @backstage/core-components@0.6.0 + - @backstage/plugin-catalog@0.6.17 + - @backstage/plugin-catalog-react@0.5.1 + ## 0.6.9 ### Patch Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 7ccec47ad2..4c88a50fa8 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-api-docs", "description": "A Backstage plugin that helps represent API entities in the frontend", - "version": "0.6.9", + "version": "0.6.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,11 +31,11 @@ }, "dependencies": { "@asyncapi/react-component": "^0.23.0", - "@backstage/catalog-model": "^0.9.3", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.8", - "@backstage/plugin-catalog": "^0.6.16", - "@backstage/plugin-catalog-react": "^0.5.0", + "@backstage/catalog-model": "^0.9.4", + "@backstage/core-components": "^0.6.1", + "@backstage/core-plugin-api": "^0.1.10", + "@backstage/plugin-catalog": "^0.7.0", + "@backstage/plugin-catalog-react": "^0.5.2", "@backstage/theme": "^0.2.10", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.12.2", @@ -53,10 +53,10 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/core-app-api": "^0.1.14", - "@backstage/dev-utils": "^0.2.10", - "@backstage/test-utils": "^0.1.17", + "@backstage/cli": "^0.7.15", + "@backstage/core-app-api": "^0.1.16", + "@backstage/dev-utils": "^0.2.11", + "@backstage/test-utils": "^0.1.18", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index 340a1b4559..ac09b717f7 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -19,6 +19,7 @@ import { CatalogApi, catalogApiRef, EntityProvider, + entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; @@ -71,6 +72,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); expect(getByText(/Consumed APIs/i)).toBeInTheDocument(); @@ -116,6 +122,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); await waitFor(() => { diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx index e7a53446bc..ac833251e3 100644 --- a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx @@ -19,6 +19,7 @@ import { CatalogApi, catalogApiRef, EntityProvider, + entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; @@ -71,6 +72,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); expect(getByText('APIs')).toBeInTheDocument(); @@ -116,6 +122,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); await waitFor(() => { diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index afc81c2e84..f2719d0f4b 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -19,6 +19,7 @@ import { CatalogApi, catalogApiRef, EntityProvider, + entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; @@ -71,6 +72,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); expect(getByText(/Provided APIs/i)).toBeInTheDocument(); @@ -116,6 +122,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); await waitFor(() => { diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 5c47ec1476..274962c346 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -19,6 +19,7 @@ import { CatalogApi, catalogApiRef, EntityProvider, + entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; @@ -70,6 +71,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); expect(getByText('Consumers')).toBeInTheDocument(); @@ -121,6 +127,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); await waitFor(() => { diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index 77cc75535c..879944749a 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -19,6 +19,7 @@ import { CatalogApi, catalogApiRef, EntityProvider, + entityRouteRef, } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; @@ -70,6 +71,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); expect(getByText('Providers')).toBeInTheDocument(); @@ -121,6 +127,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); await waitFor(() => { diff --git a/plugins/api-docs/src/plugin.ts b/plugins/api-docs/src/plugin.ts index 162dc02632..f400be9c7c 100644 --- a/plugins/api-docs/src/plugin.ts +++ b/plugins/api-docs/src/plugin.ts @@ -51,6 +51,7 @@ export const apiDocsPlugin = createPlugin({ export const ApiExplorerPage = apiDocsPlugin.provide( createRoutableExtension({ + name: 'ApiExplorerPage', component: () => import('./components/ApiExplorerPage').then(m => m.ApiExplorerPage), mountPoint: rootRoute, @@ -59,6 +60,7 @@ export const ApiExplorerPage = apiDocsPlugin.provide( export const EntityApiDefinitionCard = apiDocsPlugin.provide( createComponentExtension({ + name: 'EntityApiDefinitionCard', component: { lazy: () => import('./components/ApiDefinitionCard').then(m => m.ApiDefinitionCard), @@ -68,6 +70,7 @@ export const EntityApiDefinitionCard = apiDocsPlugin.provide( export const EntityConsumedApisCard = apiDocsPlugin.provide( createComponentExtension({ + name: 'EntityConsumedApisCard', component: { lazy: () => import('./components/ApisCards').then(m => m.ConsumedApisCard), @@ -77,6 +80,7 @@ export const EntityConsumedApisCard = apiDocsPlugin.provide( export const EntityConsumingComponentsCard = apiDocsPlugin.provide( createComponentExtension({ + name: 'EntityConsumingComponentsCard', component: { lazy: () => import('./components/ComponentsCards').then( @@ -88,6 +92,7 @@ export const EntityConsumingComponentsCard = apiDocsPlugin.provide( export const EntityProvidedApisCard = apiDocsPlugin.provide( createComponentExtension({ + name: 'EntityProvidedApisCard', component: { lazy: () => import('./components/ApisCards').then(m => m.ProvidedApisCard), @@ -97,6 +102,7 @@ export const EntityProvidedApisCard = apiDocsPlugin.provide( export const EntityProvidingComponentsCard = apiDocsPlugin.provide( createComponentExtension({ + name: 'EntityProvidingComponentsCard', component: { lazy: () => import('./components/ComponentsCards').then( @@ -108,6 +114,7 @@ export const EntityProvidingComponentsCard = apiDocsPlugin.provide( export const EntityHasApisCard = apiDocsPlugin.provide( createComponentExtension({ + name: 'EntityHasApisCard', component: { lazy: () => import('./components/ApisCards').then(m => m.HasApisCard), }, diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 5f29897fd3..f04362727e 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/plugin-auth-backend +## 0.4.3 + +### Patch Changes + +- 4c3eea7788: Bitbucket Cloud authentication - based on the existing GitHub authentication + changes around BB apis and updated scope. + + - BitbucketAuth added to core-app-api. + - Bitbucket provider added to plugin-auth-backend. + - Cosmetic entry for Bitbucket connection in user-settings Authentication Providers tab. + +- Updated dependencies + - @backstage/test-utils@0.1.18 + - @backstage/catalog-model@0.9.4 + - @backstage/backend-common@0.9.6 + - @backstage/catalog-client@0.5.0 + +## 0.4.2 + +### Patch Changes + +- 88622e6422: Allow users to override callback url of GitHub provider +- c46396ebb0: Update OAuth refresh handler to pass updated refresh token to ensure cookie is updated with new value. +- Updated dependencies + - @backstage/backend-common@0.9.5 + ## 0.4.1 ### Patch Changes diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 5dfdd3df16..0b9f0eb8c4 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -84,6 +84,64 @@ export type BackstageIdentity = { entity?: Entity; }; +// Warning: (ae-missing-release-tag) "BitbucketOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BitbucketOAuthResult = { + fullProfile: BitbucketPassportProfile; + params: { + id_token?: string; + scope: string; + expires_in: number; + }; + accessToken: string; + refreshToken?: string; +}; + +// Warning: (ae-missing-release-tag) "BitbucketPassportProfile" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BitbucketPassportProfile = Profile & { + id?: string; + displayName?: string; + username?: string; + avatarUrl?: string; + _json?: { + links?: { + avatar?: { + href?: string; + }; + }; + }; +}; + +// Warning: (ae-missing-release-tag) "BitbucketProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BitbucketProviderOptions = { + authHandler?: AuthHandler; + signIn?: { + resolver: SignInResolver; + }; +}; + +// Warning: (ae-missing-release-tag) "bitbucketUserIdSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const bitbucketUserIdSignInResolver: SignInResolver; + +// Warning: (ae-missing-release-tag) "bitbucketUsernameSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const bitbucketUsernameSignInResolver: SignInResolver; + +// Warning: (ae-missing-release-tag) "createBitbucketProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const createBitbucketProvider: ( + options?: BitbucketProviderOptions | undefined, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "createGithubProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -468,8 +526,8 @@ export type WebMessageResponse = // // src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts // src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts -// src/providers/github/provider.d.ts:50:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts -// src/providers/github/provider.d.ts:58:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts +// src/providers/bitbucket/provider.d.ts:61:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts +// src/providers/bitbucket/provider.d.ts:69:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts // src/providers/types.d.ts:109:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts // src/providers/types.d.ts:115:5 - (ae-forgotten-export) The symbol "ExperimentalIdentityResolver" needs to be exported by the entry point index.d.ts // src/providers/types.d.ts:132:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 9293aec596..3ddd75a3a8 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-auth-backend", "description": "A Backstage backend plugin that handles authentication", - "version": "0.4.1", + "version": "0.4.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.4", - "@backstage/catalog-client": "^0.4.0", - "@backstage/catalog-model": "^0.9.3", + "@backstage/backend-common": "^0.9.6", + "@backstage/catalog-client": "^0.5.0", + "@backstage/catalog-model": "^0.9.4", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.1", - "@backstage/test-utils": "^0.1.17", + "@backstage/test-utils": "^0.1.18", "@types/express": "^4.17.6", "@types/passport": "^1.0.3", "compression": "^1.7.4", @@ -58,6 +58,7 @@ "node-cache": "^5.1.2", "openid-client": "^4.2.1", "passport": "^0.4.1", + "passport-bitbucket-oauth2": "^0.1.2", "passport-github2": "^0.1.12", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", @@ -71,7 +72,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.7.13", + "@backstage/cli": "^0.7.15", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 5dd3c768bb..0ddcaa6d2c 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -34,6 +34,7 @@ describe('CatalogIdentityClient', () => { getLocationByEntity: jest.fn(), removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const tokenIssuer: jest.Mocked = { issueToken: jest.fn(), diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts index c9647176cd..ca4e521316 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -77,6 +77,7 @@ describe('AwsALBAuthProvider', () => { removeEntityByUid: jest.fn(), getEntityByName: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const mockRequest = { diff --git a/plugins/auth-backend/src/providers/bitbucket/index.ts b/plugins/auth-backend/src/providers/bitbucket/index.ts new file mode 100644 index 0000000000..55a5735655 --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/index.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +export { + createBitbucketProvider, + bitbucketUsernameSignInResolver, + bitbucketUserIdSignInResolver, +} from './provider'; +export type { + BitbucketProviderOptions, + BitbucketPassportProfile, + BitbucketOAuthResult, +} from './provider'; diff --git a/plugins/auth-backend/src/providers/bitbucket/provider.test.ts b/plugins/auth-backend/src/providers/bitbucket/provider.test.ts new file mode 100644 index 0000000000..690729a5fb --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/provider.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { BitbucketAuthProvider, BitbucketOAuthResult } from './provider'; +import * as helpers from '../../lib/passport/PassportStrategyHelper'; +import { getVoidLogger } from '@backstage/backend-common'; +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient } from '../../lib/catalog'; + +const mockFrameHandler = jest.spyOn( + helpers, + 'executeFrameHandlerStrategy', +) as unknown as jest.MockedFunction< + () => Promise<{ result: BitbucketOAuthResult; privateInfo: any }> +>; + +describe('createBitbucketProvider', () => { + it('should auth', async () => { + const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + }; + const catalogIdentityClient = { + findUser: jest.fn(), + }; + + const provider = new BitbucketAuthProvider({ + logger: getVoidLogger(), + catalogIdentityClient: + catalogIdentityClient as unknown as CatalogIdentityClient, + tokenIssuer: tokenIssuer as unknown as TokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: { + email: fullProfile.emails![0]!.value, + displayName: fullProfile.displayName, + picture: 'http://google.com/lols', + }, + }), + clientId: 'mock', + clientSecret: 'mock', + callbackUrl: 'mock', + }); + + mockFrameHandler.mockResolvedValueOnce({ + result: { + fullProfile: { + _json: { + links: { + avatar: { + href: 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }, + }, + emails: [{ value: 'conrad@example.com' }], + displayName: 'Conrad', + id: 'conrad', + provider: 'google', + }, + params: { + id_token: 'idToken', + scope: 'scope', + expires_in: 123, + }, + accessToken: 'accessToken', + }, + privateInfo: { + refreshToken: 'wacka', + }, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual({ + providerInfo: { + accessToken: 'accessToken', + expiresInSeconds: 123, + idToken: 'idToken', + scope: 'scope', + }, + profile: { + email: 'conrad@example.com', + displayName: 'Conrad', + picture: 'http://google.com/lols', + }, + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/bitbucket/provider.ts b/plugins/auth-backend/src/providers/bitbucket/provider.ts new file mode 100644 index 0000000000..3a518a6baa --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/provider.ts @@ -0,0 +1,309 @@ +/* + * 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 express from 'express'; +import passport, { Profile as PassportProfile } from 'passport'; +import { Strategy as BitbucketStrategy } from 'passport-bitbucket-oauth2'; +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; +import { + encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, + OAuthRefreshRequest, + OAuthResponse, + OAuthResult, + OAuthStartRequest, +} from '../../lib/oauth'; +import { + executeFetchUserProfileStrategy, + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, + PassportDoneCallback, +} from '../../lib/passport'; +import { + AuthProviderFactory, + AuthHandler, + RedirectInfo, + SignInResolver, +} from '../types'; +import { Logger } from 'winston'; + +type PrivateInfo = { + refreshToken: string; +}; + +type Options = OAuthProviderOptions & { + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; +}; + +export type BitbucketOAuthResult = { + fullProfile: BitbucketPassportProfile; + params: { + id_token?: string; + scope: string; + expires_in: number; + }; + accessToken: string; + refreshToken?: string; +}; + +export type BitbucketPassportProfile = PassportProfile & { + id?: string; + displayName?: string; + username?: string; + avatarUrl?: string; + _json?: { + links?: { + avatar?: { + href?: string; + }; + }; + }; +}; + +export class BitbucketAuthProvider implements OAuthHandlers { + private readonly _strategy: BitbucketStrategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; + + constructor(options: Options) { + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; + this._strategy = new BitbucketStrategy( + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + // We need passReqToCallback set to false to get params, but there's + // no matching type signature for that, so instead behold this beauty + passReqToCallback: false as true, + }, + ( + accessToken: any, + refreshToken: any, + params: any, + fullProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + done( + undefined, + { + fullProfile, + params, + accessToken, + refreshToken, + }, + { + refreshToken, + }, + ); + }, + ); + } + + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { + accessType: 'offline', + prompt: 'consent', + scope: req.scope, + state: encodeState(req.state), + }); + } + + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, + PrivateInfo + >(req, this._strategy); + + return { + response: await this.handleResult(result), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(req: OAuthRefreshRequest): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + req.refreshToken, + req.scope, + ); + const fullProfile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + ); + return this.handleResult({ + fullProfile, + params, + accessToken, + refreshToken: req.refreshToken, + }); + } + + private async handleResult(result: BitbucketOAuthResult) { + result.fullProfile.avatarUrl = + result.fullProfile._json!.links!.avatar!.href; + const { profile } = await this.authHandler(result); + + const response: OAuthResponse = { + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + profile, + }; + + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); + } + + return response; + } +} + +export const bitbucketUsernameSignInResolver: SignInResolver = + async (info, ctx) => { + const { result } = info; + + if (!result.fullProfile.username) { + throw new Error('Bitbucket profile contained no Username'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'bitbucket.org/username': result.fullProfile.username, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, token }; + }; + +export const bitbucketUserIdSignInResolver: SignInResolver = + async (info, ctx) => { + const { result } = info; + + if (!result.fullProfile.id) { + throw new Error('Bitbucket profile contained no User ID'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'bitbucket.org/user-id': result.fullProfile.id, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, token }; + }; + +export type BitbucketProviderOptions = { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; + + /** + * 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; + }; +}; + +export const createBitbucketProvider = ( + options?: BitbucketProviderOptions, +): AuthProviderFactory => { + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = + options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const provider = new BitbucketAuthProvider({ + clientId, + clientSecret, + callbackUrl, + signInResolver: options?.signIn?.resolver, + authHandler, + tokenIssuer, + catalogIdentityClient, + logger, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); + }); +}; diff --git a/plugins/auth-backend/src/providers/bitbucket/types.d.ts b/plugins/auth-backend/src/providers/bitbucket/types.d.ts new file mode 100644 index 0000000000..70c4c8cba9 --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucket/types.d.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ +declare module 'passport-bitbucket-oauth2' { + import { StrategyCreated } from 'passport'; + import express = require('express'); + + export class Strategy { + name?: string | undefined; + authenticate( + this: StrategyCreated, + req: express.Request, + options?: any, + ): any; + constructor(options: any, verify: any); + } +} diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 070cdb38f3..c4894f2757 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -26,6 +26,7 @@ import { createMicrosoftProvider } from './microsoft'; import { createOneLoginProvider } from './onelogin'; import { AuthProviderFactory } from './types'; import { createAwsAlbProvider } from './aws-alb'; +import { createBitbucketProvider } from './bitbucket'; export const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider(), @@ -39,4 +40,5 @@ export const factories: { [providerId: string]: AuthProviderFactory } = { oidc: createOidcProvider(), onelogin: createOneLoginProvider(), awsalb: createAwsAlbProvider(), + bitbucket: createBitbucketProvider(), }; diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index b6b198b644..f1949eafa3 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -223,6 +223,7 @@ export const createGithubProvider = ( const enterpriseInstanceUrl = envConfig.getOptionalString( 'enterpriseInstanceUrl', ); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); const authorizationUrl = enterpriseInstanceUrl ? `${enterpriseInstanceUrl}/login/oauth/authorize` : undefined; @@ -232,7 +233,9 @@ export const createGithubProvider = ( const userProfileUrl = enterpriseInstanceUrl ? `${enterpriseInstanceUrl}/api/v3/user` : undefined; - const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; const catalogIdentityClient = new CatalogIdentityClient({ catalogApi, diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 1f879b311b..90466a8094 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -20,6 +20,7 @@ export * from './google'; export * from './microsoft'; export * from './oauth2'; export * from './okta'; +export * from './bitbucket'; export { factories as defaultAuthProviderFactories } from './factories'; diff --git a/plugins/graphql/.eslintrc.js b/plugins/azure-devops-backend/.eslintrc.js similarity index 100% rename from plugins/graphql/.eslintrc.js rename to plugins/azure-devops-backend/.eslintrc.js diff --git a/plugins/azure-devops-backend/CHANGELOG.md b/plugins/azure-devops-backend/CHANGELOG.md new file mode 100644 index 0000000000..279e7f8f8a --- /dev/null +++ b/plugins/azure-devops-backend/CHANGELOG.md @@ -0,0 +1,9 @@ +# @backstage/plugin-azure-devops-backend + +## 0.1.1 + +### Patch Changes + +- 299b43f052: Marked all configuration values as required in the schema. +- Updated dependencies + - @backstage/backend-common@0.9.5 diff --git a/plugins/azure-devops-backend/README.md b/plugins/azure-devops-backend/README.md new file mode 100644 index 0000000000..4b278572d8 --- /dev/null +++ b/plugins/azure-devops-backend/README.md @@ -0,0 +1,24 @@ +# Azure DevOps Backend + +Simple plugin that proxies requests to the [Azure DevOps](https://docs.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-6.1) API. + +## Setup + +The following values are read from the configuration file: + +```yaml +azureDevOps: + host: dev.azure.com + token: ${AZURE_TOKEN} + organization: my-company +``` + +Configuration Details: + +- `host` and `token` can be the same as the ones used for the `integration` section +- `AZURE_TOKEN` environment variable must be set to a [Personal Access Token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) with read access to both Code and Build +- `organization` is your Azure DevOps Organization name or for Azure DevOps Server (on-premise) this will be your Collection name + +## Links + +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md new file mode 100644 index 0000000000..4d66f769da --- /dev/null +++ b/plugins/azure-devops-backend/api-report.md @@ -0,0 +1,70 @@ +## API Report File for "@backstage/plugin-azure-devops-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { BuildResult } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { BuildStatus } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { Config } from '@backstage/config'; +import express from 'express'; +import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { Logger as Logger_2 } from 'winston'; +import { WebApi } from 'azure-devops-node-api'; + +// Warning: (ae-missing-release-tag) "AzureDevOpsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class AzureDevOpsApi { + constructor(logger: Logger_2, webApi: WebApi); + // (undocumented) + getBuildList( + projectName: string, + repoId: string, + top: number, + ): Promise; + // (undocumented) + getGitRepository( + projectName: string, + repoName: string, + ): Promise; + // (undocumented) + getRepoBuilds( + projectName: string, + repoName: string, + top: number, + ): Promise; +} + +// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// Warning: (ae-missing-release-tag) "RepoBuild" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type RepoBuild = { + id?: number; + title: string; + link: string; + status?: BuildStatus; + result?: BuildResult; + queueTime?: Date; + source: string; +}; + +// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + azureDevOpsApi?: AzureDevOpsApi; + // (undocumented) + config: Config; + // (undocumented) + logger: Logger_2; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/azure-devops-backend/config.d.ts b/plugins/azure-devops-backend/config.d.ts new file mode 100644 index 0000000000..433e4df22f --- /dev/null +++ b/plugins/azure-devops-backend/config.d.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +export interface Config { + /** Configuration options for the azure-devops-backend plugin */ + azureDevOps: { + /** + * The hostname of the given Azure instance + */ + host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token: string; + /** + * The organization of the given Azure instance + */ + organization: string; + }; +} diff --git a/plugins/azure-devops-backend/package.json b/plugins/azure-devops-backend/package.json new file mode 100644 index 0000000000..05be10a24b --- /dev/null +++ b/plugins/azure-devops-backend/package.json @@ -0,0 +1,43 @@ +{ + "name": "@backstage/plugin-azure-devops-backend", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.9.5", + "@backstage/config": "^0.1.9", + "@types/express": "^4.17.6", + "azure-devops-node-api": "^11.0.1", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.7.14", + "@types/supertest": "^2.0.8", + "supertest": "^4.0.2", + "msw": "^0.29.0" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts new file mode 100644 index 0000000000..077badfba1 --- /dev/null +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { repoBuildFromBuild } from './AzureDevOpsApi'; +import { RepoBuild } from './types'; +import { + Build, + BuildResult, + BuildStatus, + DefinitionReference, +} from 'azure-devops-node-api/interfaces/BuildInterfaces'; + +describe('AzureDevOpsApi', () => { + describe('repoBuildFromBuild', () => { + it('should return RepoBuild from Build', () => { + const inputBuildDefinition: DefinitionReference = { + name: 'My Build Definition', + }; + + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; + + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.9325232Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: inputBuildDefinition, + _links: inputLinks, + }; + + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'My Build Definition - Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.9325232Z'), + source: 'refs/heads/develop (f4f78b31)', + }; + + expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + }); + }); + + describe('repoBuildFromBuild with no Build definition name', () => { + it('should return RepoBuild with only Build Number for title', () => { + const inputLinks: any = { + web: { + href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + }, + }; + + const inputBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.9325232Z'), + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c', + definition: undefined, + _links: inputLinks, + }; + + const outputRepoBuild: RepoBuild = { + id: 1, + title: 'Build-1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.9325232Z'), + source: 'refs/heads/develop (f4f78b31)', + }; + + expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild); + }); + }); +}); diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts new file mode 100644 index 0000000000..13d5b391f6 --- /dev/null +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -0,0 +1,106 @@ +/* + * 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 { Logger } from 'winston'; +import { WebApi } from 'azure-devops-node-api'; +import { RepoBuild } from './types'; +import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; + +export class AzureDevOpsApi { + constructor( + private readonly logger: Logger, + private readonly webApi: WebApi, + ) {} + + async getGitRepository(projectName: string, repoName: string) { + if (this.logger) { + this.logger.debug( + `Calling Azure DevOps REST API, getting Repository ${repoName} for Project ${projectName}`, + ); + } + + const client = await this.webApi.getGitApi(); + return client.getRepository(repoName, projectName); + } + + async getBuildList(projectName: string, repoId: string, top: number) { + if (this.logger) { + this.logger.debug( + `Calling Azure DevOps REST API, getting up to ${top} Builds for Repository Id ${repoId} for Project ${projectName}`, + ); + } + + const client = await this.webApi.getBuildApi(); + return client.getBuilds( + projectName, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + top, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + repoId, + 'TfsGit', + ); + } + + async getRepoBuilds(projectName: string, repoName: string, top: number) { + if (this.logger) { + this.logger.debug( + `Calling Azure DevOps REST API, getting up to ${top} Builds for Repository ${repoName} for Project ${projectName}`, + ); + } + + const gitRepository = await this.getGitRepository(projectName, repoName); + const buildList = await this.getBuildList( + projectName, + gitRepository.id as string, + top, + ); + + const repoBuilds: RepoBuild[] = buildList.map(build => { + return repoBuildFromBuild(build); + }); + + return repoBuilds; + } +} + +export function repoBuildFromBuild(build: Build) { + return { + id: build.id, + title: [build.definition?.name, build.buildNumber] + .filter(Boolean) + .join(' - '), + link: build._links?.web.href, + status: build.status, + result: build.result, + queueTime: build.queueTime, + source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`, + }; +} diff --git a/plugins/azure-devops-backend/src/api/index.ts b/plugins/azure-devops-backend/src/api/index.ts new file mode 100644 index 0000000000..8faf37fe4e --- /dev/null +++ b/plugins/azure-devops-backend/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export { AzureDevOpsApi } from './AzureDevOpsApi'; +export type { RepoBuild } from './types'; diff --git a/plugins/azure-devops-backend/src/api/types.ts b/plugins/azure-devops-backend/src/api/types.ts new file mode 100644 index 0000000000..35d4e38ae1 --- /dev/null +++ b/plugins/azure-devops-backend/src/api/types.ts @@ -0,0 +1,30 @@ +/* + * 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 { + BuildResult, + BuildStatus, +} from 'azure-devops-node-api/interfaces/BuildInterfaces'; + +export type RepoBuild = { + id?: number; + title: string; + link: string; + status?: BuildStatus; + result?: BuildResult; + queueTime?: Date; + source: string; +}; diff --git a/plugins/azure-devops-backend/src/index.ts b/plugins/azure-devops-backend/src/index.ts new file mode 100644 index 0000000000..a7004ec73e --- /dev/null +++ b/plugins/azure-devops-backend/src/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ +export { AzureDevOpsApi } from './api'; +export type { RepoBuild } from './api'; +export * from './service/router'; diff --git a/plugins/azure-devops-backend/src/run.ts b/plugins/azure-devops-backend/src/run.ts new file mode 100644 index 0000000000..addfdfd6d7 --- /dev/null +++ b/plugins/azure-devops-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * 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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/azure-devops-backend/src/service/router.test.ts b/plugins/azure-devops-backend/src/service/router.test.ts new file mode 100644 index 0000000000..a66b86f418 --- /dev/null +++ b/plugins/azure-devops-backend/src/service/router.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import express from 'express'; +import request from 'supertest'; +import { AzureDevOpsApi } from '../api'; +import { createRouter } from './router'; +import { RepoBuild } from '../api/types'; +import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { + Build, + BuildResult, + BuildStatus, +} from 'azure-devops-node-api/interfaces/BuildInterfaces'; + +describe('createRouter', () => { + let azureDevOpsApi: jest.Mocked; + let app: express.Express; + + beforeAll(async () => { + azureDevOpsApi = { + getGitRepository: jest.fn(), + getBuildList: jest.fn(), + getRepoBuilds: jest.fn(), + } as any; + const router = await createRouter({ + azureDevOpsApi, + logger: getVoidLogger(), + config: new ConfigReader({ + azureDevOps: { + token: 'foo', + host: 'host.com', + organization: 'myOrg', + top: 5, + }, + }), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); + + describe('GET /repository/:projectName/:repoName', () => { + it('fetches a single repository', async () => { + const gitRepository: GitRepository = { + id: 'af4ae3af-e747-4129-9bbc-d1329f6b0998', + name: 'myRepo', + url: 'https://host.com/repo', + defaultBranch: 'refs/heads/develop', + sshUrl: 'ssh://host.com/repo', + webUrl: 'https://host.com/webRepo', + }; + + azureDevOpsApi.getGitRepository.mockResolvedValueOnce(gitRepository); + + const response = await request(app).get('/repository/myProject/myRepo'); + + expect(azureDevOpsApi.getGitRepository).toHaveBeenCalledWith( + 'myProject', + 'myRepo', + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(gitRepository); + }); + }); + + describe('GET /builds/:projectName/:repoId', () => { + it('fetches a list of builds', async () => { + const firstBuild: Build = { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: undefined, + sourceBranch: 'refs/heads/develop', + sourceVersion: '9bedf67800b2923982bdf60c89c57ce6fd2d9a1c', + }; + + const secondBuild: Build = { + id: 2, + buildNumber: 'Build-2', + status: BuildStatus.InProgress, + result: BuildResult.None, + queueTime: undefined, + sourceBranch: 'refs/heads/develop', + sourceVersion: '13c988d4f15e06bcdd0b0af290086a3079cdadb0', + }; + + const thirdBuild: Build = { + id: 3, + buildNumber: 'Build-3', + status: BuildStatus.Completed, + result: BuildResult.PartiallySucceeded, + queueTime: undefined, + sourceBranch: 'refs/heads/develop', + sourceVersion: 'f4f78b319c308600eab015a5d6529add21660dc1', + }; + + const builds: Build[] = [firstBuild, secondBuild, thirdBuild]; + + azureDevOpsApi.getBuildList.mockResolvedValueOnce(builds); + + const response = await request(app) + .get('/builds/myProject/af4ae3af-e747-4129-9bbc-d1329f6b0998') + .query({ top: '40' }); + + expect(azureDevOpsApi.getBuildList).toHaveBeenCalledWith( + 'myProject', + 'af4ae3af-e747-4129-9bbc-d1329f6b0998', + 40, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(builds); + }); + }); + + describe('GET /repo-builds/:projectName/:repoName', () => { + it('fetches a list of repo builds', async () => { + const firstRepoBuild: RepoBuild = { + id: 1, + title: 'My Build Definition - Build 1', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1', + status: BuildStatus.Completed, + result: BuildResult.PartiallySucceeded, + queueTime: undefined, + source: 'refs/heads/develop (f4f78b31)', + }; + + const secondRepoBuild: RepoBuild = { + id: 2, + title: 'My Build Definition - Build 2', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=2', + status: BuildStatus.InProgress, + result: BuildResult.None, + queueTime: undefined, + source: 'refs/heads/develop (13c988d4)', + }; + + const thirdRepoBuild: RepoBuild = { + id: 3, + title: 'My Build Definition - Build 3', + link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=3', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: undefined, + source: 'refs/heads/develop (9bedf678)', + }; + + const repoBuilds: RepoBuild[] = [ + firstRepoBuild, + secondRepoBuild, + thirdRepoBuild, + ]; + + azureDevOpsApi.getRepoBuilds.mockResolvedValueOnce(repoBuilds); + + const response = await request(app) + .get('/repo-builds/myProject/myRepo') + .query({ top: '50' }); + + expect(azureDevOpsApi.getRepoBuilds).toHaveBeenCalledWith( + 'myProject', + 'myRepo', + 50, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(repoBuilds); + }); + }); +}); diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts new file mode 100644 index 0000000000..1fc86128e6 --- /dev/null +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -0,0 +1,89 @@ +/* + * 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 { errorHandler } from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { Config } from '@backstage/config'; +import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; +import { AzureDevOpsApi } from '../api'; + +const DEFAULT_TOP: number = 10; + +export interface RouterOptions { + azureDevOpsApi?: AzureDevOpsApi; + logger: Logger; + config: Config; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger } = options; + const config = options.config.getConfig('azureDevOps'); + + const token = config.getString('token'); + const host = config.getString('host'); + const organization = config.getString('organization'); + + const authHandler = getPersonalAccessTokenHandler(token); + const webApi = new WebApi(`https://${host}/${organization}`, authHandler); + + const azureDevOpsApi = + options.azureDevOpsApi || new AzureDevOpsApi(logger, webApi); + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_req, res) => { + res.status(200).json({ status: 'ok' }); + }); + + router.get('/repository/:projectName/:repoName', async (req, res) => { + const { projectName, repoName } = req.params; + const gitRepository = await azureDevOpsApi.getGitRepository( + projectName, + repoName, + ); + res.status(200).json(gitRepository); + }); + + router.get('/builds/:projectName/:repoId', async (req, res) => { + const { projectName, repoId } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const buildList = await azureDevOpsApi.getBuildList( + projectName, + repoId, + top, + ); + res.status(200).json(buildList); + }); + + router.get('/repo-builds/:projectName/:repoName', async (req, res) => { + const { projectName, repoName } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const gitRepository = await azureDevOpsApi.getRepoBuilds( + projectName, + repoName, + top, + ); + res.status(200).json(gitRepository); + }); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/azure-devops-backend/src/service/standaloneServer.ts b/plugins/azure-devops-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..007ad32c62 --- /dev/null +++ b/plugins/azure-devops-backend/src/service/standaloneServer.ts @@ -0,0 +1,57 @@ +/* + * 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 { + createServiceBuilder, + loadBackendConfig, +} from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'azure-devops-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + + logger.debug('Starting application server...'); + + const router = await createRouter({ + logger, + config, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/azure-devops', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/azure-devops-backend/src/setupTests.ts b/plugins/azure-devops-backend/src/setupTests.ts new file mode 100644 index 0000000000..a330613afb --- /dev/null +++ b/plugins/azure-devops-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export {}; diff --git a/plugins/badges-backend/CHANGELOG.md b/plugins/badges-backend/CHANGELOG.md index bc04826b9c..5cd063d960 100644 --- a/plugins/badges-backend/CHANGELOG.md +++ b/plugins/badges-backend/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-badges-backend +## 0.1.11 + +### Patch Changes + +- Updated dependencies + - @backstage/catalog-model@0.9.4 + - @backstage/backend-common@0.9.6 + - @backstage/catalog-client@0.5.0 + ## 0.1.10 ### Patch Changes diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 021038b754..e38b502a49 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-badges-backend", "description": "A Backstage backend plugin that generates README badges for your entities", - "version": "0.1.10", + "version": "0.1.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.4", - "@backstage/catalog-client": "^0.4.0", - "@backstage/catalog-model": "^0.9.3", + "@backstage/backend-common": "^0.9.6", + "@backstage/catalog-client": "^0.5.0", + "@backstage/catalog-model": "^0.9.4", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.1", "@types/express": "^4.17.6", @@ -46,7 +46,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.7.13", + "@backstage/cli": "^0.7.15", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 5dc877ec32..51ad1e7e11 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -67,6 +67,7 @@ describe('createRouter', () => { removeLocationById: jest.fn(), removeEntityByUid: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; config = new ConfigReader({ diff --git a/plugins/badges/CHANGELOG.md b/plugins/badges/CHANGELOG.md index c08bb283e6..219507a4a9 100644 --- a/plugins/badges/CHANGELOG.md +++ b/plugins/badges/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/plugin-badges +## 0.2.12 + +### Patch Changes + +- ca0559444c: Avoid usage of `.to*Case()`, preferring `.toLocale*Case('en-US')` instead. +- 81a41ec249: Added a `name` key to all extensions in order to improve Analytics API metadata. +- Updated dependencies + - @backstage/core-components@0.6.1 + - @backstage/core-plugin-api@0.1.10 + - @backstage/plugin-catalog-react@0.5.2 + - @backstage/catalog-model@0.9.4 + +## 0.2.11 + +### Patch Changes + +- 504bcb2214: Added details on how to setup the Badges plugin +- Updated dependencies + - @backstage/core-plugin-api@0.1.9 + - @backstage/core-components@0.6.0 + - @backstage/plugin-catalog-react@0.5.1 + ## 0.2.10 ### Patch Changes diff --git a/plugins/badges/README.md b/plugins/badges/README.md index bfc01ec63c..b881d100c4 100644 --- a/plugins/badges/README.md +++ b/plugins/badges/README.md @@ -10,8 +10,146 @@ link below for more details. ## Entity badges To get markdown code for the entity badges, access the `Badges` context menu -(three dots in the upper right corner) of an entity page, which will popup a -badges dialog showing all available badges for that entity. +(three dots in the upper right corner) of an entity page like this: + +![Badges Context Menu](./doc/badges-context-menu.png) + +This will popup a badges dialog showing all available badges for that entity like this: + +![Badges Dialog](./doc/badges-dialog.png) + +## Sample Badges + +Here are some samples of badges for the `artists-lookup` service in the Demo Backstage site: + +- Component: [![Link to artist-lookup in Backstage Demo, Component: artist-lookup](https://demo.backstage.io/api/badges/entity/default/component/artist-lookup/badge/pingback 'Link to artist-lookup in Backstage Demo')](https://demo.backstage.io/catalog/default/component/artist-lookup) +- Lifecycle: [![Entity lifecycle badge, lifecycle: experimental](https://demo.backstage.io/api/badges/entity/default/component/artist-lookup/badge/lifecycle 'Entity lifecycle badge')](https://demo.backstage.io/catalog/default/component/artist-lookup) +- Owner: [![Entity owner badge, owner: team-a](https://demo.backstage.io/api/badges/entity/default/component/artist-lookup/badge/owner 'Entity owner badge')](https://demo.backstage.io/catalog/default/component/artist-lookup) +- Docs: [![Entity docs badge, docs: artist-lookup](https://demo.backstage.io/api/badges/entity/default/component/artist-lookup/badge/docs 'Entity docs badge')](https://demo.backstage.io/catalog/default/component/artist-lookup/docs) + +## Usage + +### Install the package + +```bash +yarn add @backstage/plugin-badges +``` + +### Register plugin + +This plugin requires explicit registration, so you will need to add it to your App's `plugins.ts` file: + +```ts +// ... +export { badgesPlugin } from '@backstage/plugin-badges'; +``` + +If you don't have a `plugins.ts` file see: [troubleshooting](#troubleshooting) + +### Update your EntityPage + +In your `EntityPage.tsx` file located in `packages\app\src\components\catalog` we'll need to make a few changes to get the Badges context menu added to the UI. + +First we need to add the following imports: + +```ts +import { EntityBadgesDialog } from '@backstage/plugin-badges'; +import BadgeIcon from '@material-ui/icons/CallToAction'; +``` + +Next we'll update the React import that looks like this: + +```ts +import React from 'react'; +``` + +To look like this: + +```ts +import React, { ReactNode, useMemo, useState } from 'react'; +``` + +Then we have to add this chunk of code after all the imports but before any of the other code: + +```ts +const EntityLayoutWrapper = (props: { children?: ReactNode }) => { + const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); + + const extraMenuItems = useMemo(() => { + return [ + { + title: 'Badges', + Icon: BadgeIcon, + onClick: () => setBadgesDialogOpen(true), + }, + ]; + }, []); + + return ( + <> + + {props.children} + + setBadgesDialogOpen(false)} + /> + + ); +}; +``` + +The last step is to wrap all the entity pages in the `EntityLayoutWrapper` like this: + +```diff +const defaultEntityPage = ( ++ + + {overviewContent} + + + + + + + + + ++ +); +``` + +Note: the above only shows an example for the `defaultEntityPage` for a full example of this you can look at [this EntityPage](https://github.com/backstage/backstage/blob/1fd9e6f601cabe42af8eb20b5d200ad1988ba309/packages/app/src/components/catalog/EntityPage.tsx#L318) + +## Troubleshooting + +If you don't have a `plugins.ts` file, you can create it with the path `packages/app/src/plugins.ts` and then import it into your `App.tsx`: + +```diff ++ import * as plugins from './plugins'; + +const app = createApp({ + apis, ++ plugins: Object.values(plugins), + bindRoutes({ bind }) { + /* ... */ + }, +}); +``` + +Or simply edit `App.tsx` with: + +```diff ++ import { badgesPlugin } from '@backstage/plugin-badges' + +const app = createApp({ + apis, ++ plugins: [badgesPlugin], + bindRoutes({ bind }) { + /* ... */ + }, +}); +``` ## Links diff --git a/plugins/badges/doc/badges-context-menu.png b/plugins/badges/doc/badges-context-menu.png new file mode 100644 index 0000000000..b25f293b5c Binary files /dev/null and b/plugins/badges/doc/badges-context-menu.png differ diff --git a/plugins/badges/doc/badges-dialog.png b/plugins/badges/doc/badges-dialog.png new file mode 100644 index 0000000000..e1deee96c4 Binary files /dev/null and b/plugins/badges/doc/badges-dialog.png differ diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 16e5121132..26fc1afe13 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-badges", "description": "A Backstage plugin that generates README badges for your entities", - "version": "0.2.10", + "version": "0.2.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,6 +10,12 @@ "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/badges" + }, "scripts": { "build": "backstage-cli plugin:build", "start": "backstage-cli plugin:serve", @@ -21,11 +27,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.3", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.8", + "@backstage/catalog-model": "^0.9.4", + "@backstage/core-components": "^0.6.1", + "@backstage/core-plugin-api": "^0.1.10", "@backstage/errors": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.5.0", + "@backstage/plugin-catalog-react": "^0.5.2", "@backstage/theme": "^0.2.10", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -36,10 +42,10 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/core-app-api": "^0.1.14", - "@backstage/dev-utils": "^0.2.10", - "@backstage/test-utils": "^0.1.17", + "@backstage/cli": "^0.7.15", + "@backstage/core-app-api": "^0.1.16", + "@backstage/dev-utils": "^0.2.11", + "@backstage/test-utils": "^0.1.18", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index 8f65e1b9de..d81b042ce5 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -61,9 +61,10 @@ export class BadgesClient implements BadgesApi { private getEntityRouteParams(entity: Entity) { return { - kind: entity.kind.toLowerCase(), + kind: entity.kind.toLocaleLowerCase('en-US'), namespace: - entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + entity.metadata.namespace?.toLocaleLowerCase('en-US') ?? + ENTITY_DEFAULT_NAMESPACE, name: entity.metadata.name, }; } diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index 815ef0f660..ee88fb31e0 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -36,6 +36,7 @@ export const badgesPlugin = createPlugin({ export const EntityBadgesDialog = badgesPlugin.provide( createComponentExtension({ + name: 'EntityBadgesDialog', component: { lazy: () => import('./components/EntityBadgesDialog').then( diff --git a/plugins/bitrise/CHANGELOG.md b/plugins/bitrise/CHANGELOG.md index 58d4d87e89..802e0a4abc 100644 --- a/plugins/bitrise/CHANGELOG.md +++ b/plugins/bitrise/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-bitrise +## 0.1.15 + +### Patch Changes + +- 81a41ec249: Added a `name` key to all extensions in order to improve Analytics API metadata. +- Updated dependencies + - @backstage/core-components@0.6.1 + - @backstage/core-plugin-api@0.1.10 + - @backstage/plugin-catalog-react@0.5.2 + - @backstage/catalog-model@0.9.4 + +## 0.1.14 + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@0.1.9 + - @backstage/core-components@0.6.0 + - @backstage/plugin-catalog-react@0.5.1 + ## 0.1.13 ### Patch Changes diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 263e27e65c..c581071c1a 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-bitrise", "description": "A Backstage plugin that integrates towards Bitrise", - "version": "0.1.13", + "version": "0.1.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,10 +21,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.3", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.8", - "@backstage/plugin-catalog-react": "^0.5.0", + "@backstage/catalog-model": "^0.9.4", + "@backstage/core-components": "^0.6.1", + "@backstage/core-plugin-api": "^0.1.10", + "@backstage/plugin-catalog-react": "^0.5.2", "@backstage/theme": "^0.2.10", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -39,10 +39,10 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/core-app-api": "^0.1.14", - "@backstage/dev-utils": "^0.2.10", - "@backstage/test-utils": "^0.1.17", + "@backstage/cli": "^0.7.15", + "@backstage/core-app-api": "^0.1.16", + "@backstage/dev-utils": "^0.2.11", + "@backstage/test-utils": "^0.1.18", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", diff --git a/plugins/bitrise/src/extensions.ts b/plugins/bitrise/src/extensions.ts index 05bc220189..ccb5fb24dd 100644 --- a/plugins/bitrise/src/extensions.ts +++ b/plugins/bitrise/src/extensions.ts @@ -19,6 +19,7 @@ import { createComponentExtension } from '@backstage/core-plugin-api'; export const EntityBitriseContent = bitrisePlugin.provide( createComponentExtension({ + name: 'EntityBitriseContent', component: { lazy: () => import('./components/BitriseBuildsComponent').then( diff --git a/plugins/catalog-backend-module-ldap/CHANGELOG.md b/plugins/catalog-backend-module-ldap/CHANGELOG.md index d50af57559..6e1a819d78 100644 --- a/plugins/catalog-backend-module-ldap/CHANGELOG.md +++ b/plugins/catalog-backend-module-ldap/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-catalog-backend-module-ldap +## 0.3.3 + +### Patch Changes + +- a31afc5b62: Replace slash stripping regexp with trimEnd to remove CodeQL warning +- Updated dependencies + - @backstage/plugin-catalog-backend@0.16.0 + - @backstage/catalog-model@0.9.4 + +## 0.3.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@0.15.0 + ## 0.3.1 ### Patch Changes diff --git a/plugins/catalog-backend-module-ldap/package.json b/plugins/catalog-backend-module-ldap/package.json index a51c0ceceb..bc71ca4688 100644 --- a/plugins/catalog-backend-module-ldap/package.json +++ b/plugins/catalog-backend-module-ldap/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-backend-module-ldap", "description": "A Backstage catalog backend modules that helps integrate towards LDAP", - "version": "0.3.1", + "version": "0.3.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,19 +29,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.9.3", + "@backstage/catalog-model": "^0.9.4", "@backstage/config": "^0.1.10", - "@backstage/plugin-catalog-backend": "^0.14.0", + "@backstage/plugin-catalog-backend": "^0.16.0", "@types/ldapjs": "^2.2.0", "ldapjs": "^2.2.0", "lodash": "^4.17.21", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/test-utils": "^0.1.16", - "@types/lodash": "^4.14.151", - "msw": "^0.29.0" + "@backstage/cli": "^0.7.15", + "@types/lodash": "^4.14.151" }, "files": [ "dist", diff --git a/plugins/catalog-backend-module-ldap/src/ldap/config.ts b/plugins/catalog-backend-module-ldap/src/ldap/config.ts index eefe43f97a..6dad7e087f 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.ts @@ -18,6 +18,7 @@ import { Config, JsonValue } from '@backstage/config'; import { SearchOptions } from 'ldapjs'; import mergeWith from 'lodash/mergeWith'; import { RecursivePartial } from '@backstage/plugin-catalog-backend'; +import { trimEnd } from 'lodash'; /** * The configuration parameters for a single LDAP provider. @@ -286,7 +287,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { const providerConfigs = config.getOptionalConfigArray('providers') ?? []; return providerConfigs.map(c => { const newConfig = { - target: c.getString('target').replace(/\/+$/, ''), + target: trimEnd(c.getString('target'), '/'), bind: readBindConfig(c.getOptionalConfig('bind')), users: readUserConfig(c.getConfig('users')), groups: readGroupConfig(c.getConfig('groups')), diff --git a/plugins/catalog-backend-module-msgraph/CHANGELOG.md b/plugins/catalog-backend-module-msgraph/CHANGELOG.md index d15def9084..0fa13b2018 100644 --- a/plugins/catalog-backend-module-msgraph/CHANGELOG.md +++ b/plugins/catalog-backend-module-msgraph/CHANGELOG.md @@ -1,5 +1,24 @@ # @backstage/plugin-catalog-backend-module-msgraph +## 0.2.6 + +### Patch Changes + +- ff7c6cec1a: Allow loading users using group membership +- 95869261ed: Adding some documentation for the `msgraph` client +- a31afc5b62: Replace slash stripping regexp with trimEnd to remove CodeQL warning +- Updated dependencies + - @backstage/plugin-catalog-backend@0.16.0 + - @backstage/catalog-model@0.9.4 + +## 0.2.5 + +### Patch Changes + +- 664bba5c45: Bumped `@microsoft/microsoft-graph-types` to v2 +- Updated dependencies + - @backstage/plugin-catalog-backend@0.15.0 + ## 0.2.4 ### Patch Changes diff --git a/plugins/catalog-backend-module-msgraph/README.md b/plugins/catalog-backend-module-msgraph/README.md index 5dedb800f1..4c41d42915 100644 --- a/plugins/catalog-backend-module-msgraph/README.md +++ b/plugins/catalog-backend-module-msgraph/README.md @@ -55,12 +55,20 @@ catalog: # Optional filter for user, see Microsoft Graph API for the syntax # See https://docs.microsoft.com/en-us/graph/api/resources/user?view=graph-rest-1.0#properties # and for the syntax https://docs.microsoft.com/en-us/graph/query-parameters#filter-parameter + # This and userGroupMemberFilter are mutually exclusive, only one can be specified userFilter: accountEnabled eq true and userType eq 'member' + # Optional filter for users, use group membership to get users. + # This and userFilter are mutually exclusive, only one can be specified + userGroupMemberFilter: "displayName eq 'Backstage Users'" # Optional filter for group, see Microsoft Graph API for the syntax # See https://docs.microsoft.com/en-us/graph/api/resources/group?view=graph-rest-1.0#properties groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') ``` +`userFilter` and `userGroupMemberFilter` are mutually exclusive, only one can be provided. If both are provided, an error will be thrown. + +By default, all users are loaded. If you want to filter users based on their attributes, use `userFilter`. `userGroupMemberFilter` can be used if you want to load users based on their group membership. + 5. Add a location that ingests from Microsoft Graph: ```yaml diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index 4e58b68cb7..d290fcf9b6 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -61,46 +61,35 @@ export const MICROSOFT_GRAPH_TENANT_ID_ANNOTATION = // @public export const MICROSOFT_GRAPH_USER_ID_ANNOTATION = 'graph.microsoft.com/user-id'; -// Warning: (ae-missing-release-tag) "MicrosoftGraphClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export class MicrosoftGraphClient { constructor(baseUrl: string, pca: msal.ConfidentialClientApplication); - // (undocumented) static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient; // Warning: (ae-forgotten-export) The symbol "GroupMember" needs to be exported by the entry point index.d.ts - // - // (undocumented) getGroupMembers(groupId: string): AsyncIterable; // (undocumented) getGroupPhoto(groupId: string, sizeId?: string): Promise; - // (undocumented) getGroupPhotoWithSizeLimit( groupId: string, maxSize: number, ): Promise; - // (undocumented) + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery" getGroups(query?: ODataQuery): AsyncIterable; - // (undocumented) getOrganization(tenantId: string): Promise; // (undocumented) getUserPhoto(userId: string, sizeId?: string): Promise; - // (undocumented) getUserPhotoWithSizeLimit( userId: string, maxSize: number, ): Promise; - // (undocumented) getUserProfile(userId: string): Promise; - // (undocumented) + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery" getUsers(query?: ODataQuery): AsyncIterable; - // (undocumented) + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery" requestApi(path: string, query?: ODataQuery): Promise; // Warning: (ae-forgotten-export) The symbol "ODataQuery" needs to be exported by the entry point index.d.ts - // - // (undocumented) + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/plugin-catalog-backend-module-msgraph" does not have an export "ODataQuery" requestCollection(path: string, query?: ODataQuery): AsyncIterable; - // (undocumented) requestRaw(url: string): Promise; } @@ -143,6 +132,7 @@ export type MicrosoftGraphProviderConfig = { clientId: string; clientSecret: string; userFilter?: string; + userGroupMemberFilter?: string; groupFilter?: string; }; @@ -173,6 +163,7 @@ export function readMicrosoftGraphOrg( tenantId: string, options: { userFilter?: string; + userGroupMemberFilter?: string; groupFilter?: string; userTransformer?: UserTransformer; groupTransformer?: GroupTransformer; diff --git a/plugins/catalog-backend-module-msgraph/config.d.ts b/plugins/catalog-backend-module-msgraph/config.d.ts index 9f6a33ee32..c7eb5ae8e4 100644 --- a/plugins/catalog-backend-module-msgraph/config.d.ts +++ b/plugins/catalog-backend-module-msgraph/config.d.ts @@ -73,6 +73,12 @@ export interface Config { * E.g. "securityEnabled eq false and mailEnabled eq true" */ groupFilter?: string; + /** + * The filter to apply to extract users by groups memberships. + * + * E.g. "displayName eq 'Backstage Users'" + */ + userGroupMemberFilter?: string; }>; }; }; diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index 3cccc69215..febaabe219 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-backend-module-msgraph", "description": "A Backstage catalog backend modules that helps integrate towards Microsoft Graph", - "version": "0.2.4", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ }, "dependencies": { "@azure/msal-node": "^1.1.0", - "@backstage/catalog-model": "^0.9.3", + "@backstage/catalog-model": "^0.9.4", "@backstage/config": "^0.1.10", - "@backstage/plugin-catalog-backend": "^0.14.0", - "@microsoft/microsoft-graph-types": "^1.25.0", + "@backstage/plugin-catalog-backend": "^0.16.0", + "@microsoft/microsoft-graph-types": "^2.6.0", "cross-fetch": "^3.0.6", "lodash": "^4.17.21", "p-limit": "^3.0.2", @@ -41,9 +41,9 @@ "qs": "^6.9.4" }, "devDependencies": { - "@backstage/backend-common": "^0.9.4", - "@backstage/cli": "^0.7.13", - "@backstage/test-utils": "^0.1.14", + "@backstage/backend-common": "^0.9.6", + "@backstage/cli": "^0.7.15", + "@backstage/test-utils": "^0.1.18", "@types/lodash": "^4.14.151", "msw": "^0.29.0" }, diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts index 06932c5813..83b4371c0d 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts @@ -20,9 +20,24 @@ import fetch from 'cross-fetch'; import qs from 'qs'; import { MicrosoftGraphProviderConfig } from './config'; +/** + * OData (Open Data Protocol) Query + * + * {@link https://docs.microsoft.com/en-us/odata/concepts/queryoptions-overview} + * @public + */ export type ODataQuery = { + /** + * filter a collection of resources + */ filter?: string; + /** + * specifies the related resources or media streams to be included in line with retrieved resources + */ expand?: string[]; + /** + * request a specific set of properties for each entity or complex type + */ select?: string[]; }; @@ -30,7 +45,23 @@ export type GroupMember = | (MicrosoftGraph.Group & { '@odata.type': '#microsoft.graph.user' }) | (MicrosoftGraph.User & { '@odata.type': '#microsoft.graph.group' }); +/** + * A HTTP Client that communicates with Microsoft Graph API. + * Simplify Authentication and API calls to get `User` and `Group` from Azure Active Directory + * + * Uses `msal-node` for authentication + * + * @public + */ export class MicrosoftGraphClient { + /** + * Factory method that instantiate `msal` client and return + * an instance of `MicrosoftGraphClient` + * + * @public + * + * @param config - Configuration for Interacting with Graph API + */ static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient { const clientConfig: msal.Configuration = { auth: { @@ -43,11 +74,25 @@ export class MicrosoftGraphClient { return new MicrosoftGraphClient(config.target, pca); } + /** + * @param baseUrl - baseUrl of Graph API {@link MicrosoftGraphProviderConfig.target} + * @param pca - instance of `msal.ConfidentialClientApplication` that is used to acquire token for Graph API calls + * + */ constructor( private readonly baseUrl: string, private readonly pca: msal.ConfidentialClientApplication, ) {} + /** + * Get a collection of resource from Graph API and + * return an `AsyncIterable` of that resource + * + * @public + * @param path - Resource in Microsoft Graph + * @param query - OData Query {@link ODataQuery} + * + */ async *requestCollection( path: string, query?: ODataQuery, @@ -60,6 +105,8 @@ export class MicrosoftGraphClient { } const result = await response.json(); + + // Graph API return array of collections const elements: T[] = result.value; yield* elements; @@ -73,6 +120,13 @@ export class MicrosoftGraphClient { } } + /** + * Abstract on top of {@link MicrosoftGraphClient.requestRaw} + * + * @public + * @param path - Resource in Microsoft Graph + * @param query - OData Query {@link ODataQuery} + */ async requestApi(path: string, query?: ODataQuery): Promise { const queryString = qs.stringify( { @@ -90,6 +144,11 @@ export class MicrosoftGraphClient { return await this.requestRaw(`${this.baseUrl}/${path}${queryString}`); } + /** + * Makes a HTTP call to Graph API with token + * + * @param url - HTTP Endpoint of Graph API + */ async requestRaw(url: string): Promise { // Make sure that we always have a valid access token (might be cached) const token = await this.pca.acquireTokenByClientCredential({ @@ -107,6 +166,14 @@ export class MicrosoftGraphClient { }); } + /** + * Get {@link https://docs.microsoft.com/en-us/graph/api/resources/user | User} + * from Graph API + * + * @public + * @param userId - The unique identifier for the `User` resource + * + */ async getUserProfile(userId: string): Promise { const response = await this.requestApi(`users/${userId}`); @@ -117,6 +184,14 @@ export class MicrosoftGraphClient { return await response.json(); } + /** + * Get {@link https://docs.microsoft.com/en-us/graph/api/resources/profilephoto | profilePhoto} + * of `User` from Graph API with size limit + * + * @param userId - The unique identifier for the `User` resource + * @param maxSize - Maximum pixel height of the photo + * + */ async getUserPhotoWithSizeLimit( userId: string, maxSize: number, @@ -131,10 +206,27 @@ export class MicrosoftGraphClient { return await this.getPhoto('users', userId, sizeId); } + /** + * Get a collection of + * {@link https://docs.microsoft.com/en-us/graph/api/resources/user | User} + * from Graph API and return as `AsyncIterable` + * + * @public + * @param query - OData Query {@link ODataQuery} + * + */ async *getUsers(query?: ODataQuery): AsyncIterable { yield* this.requestCollection(`users`, query); } + /** + * Get {@link https://docs.microsoft.com/en-us/graph/api/resources/profilephoto | profilePhoto} + * of `Group` from Graph API with size limit + * + * @param groupId - The unique identifier for the `Group` resource + * @param maxSize - Maximum pixel height of the photo + * + */ async getGroupPhotoWithSizeLimit( groupId: string, maxSize: number, @@ -149,14 +241,37 @@ export class MicrosoftGraphClient { return await this.getPhoto('groups', groupId, sizeId); } + /** + * Get a collection of + * {@link https://docs.microsoft.com/en-us/graph/api/resources/group | Group} + * from Graph API and return as `AsyncIterable` + * @public + * @param query - OData Query {@link ODataQuery} + * + */ async *getGroups(query?: ODataQuery): AsyncIterable { yield* this.requestCollection(`groups`, query); } + /** + * Get a collection of + * {@link https://docs.microsoft.com/en-us/graph/api/resources/user | User} + * belonging to a `Group` from Graph API and return as `AsyncIterable` + * @public + * @param groupId - The unique identifier for the `Group` resource + * + */ async *getGroupMembers(groupId: string): AsyncIterable { yield* this.requestCollection(`groups/${groupId}/members`); } + /** + * Get {@link https://docs.microsoft.com/en-us/graph/api/resources/organization | Organization} + * from Graph API + * @public + * @param tenantId - The unique identifier for the `Organization` resource + * + */ async getOrganization( tenantId: string, ): Promise { @@ -169,6 +284,15 @@ export class MicrosoftGraphClient { return await response.json(); } + /** + * Get {@link https://docs.microsoft.com/en-us/graph/api/resources/profilephoto | profilePhoto} + * from Graph API + * + * @param entityName - type of parent resource, either `User` or `Group` + * @param id - The unique identifier for the {@link entityName | entityName} resource + * @param maxSize - Maximum pixel height of the photo + * + */ private async getPhotoWithSizeLimit( entityName: string, id: string, diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts index db8114eb39..fe0678372a 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { trimEnd } from 'lodash'; /** * The configuration parameters for a single Microsoft Graph provider. @@ -51,6 +52,12 @@ export type MicrosoftGraphProviderConfig = { * E.g. "accountEnabled eq true and userType eq 'member'" */ userFilter?: string; + /** + * The filter to apply to extract users by groups memberships. + * + * E.g. "displayName eq 'Backstage Users'" + */ + userGroupMemberFilter?: string; /** * The filter to apply to extract groups. * @@ -66,14 +73,18 @@ export function readMicrosoftGraphConfig( const providerConfigs = config.getOptionalConfigArray('providers') ?? []; for (const providerConfig of providerConfigs) { - const target = providerConfig.getString('target').replace(/\/+$/, ''); - const authority = - providerConfig.getOptionalString('authority')?.replace(/\/+$/, '') || - 'https://login.microsoftonline.com'; + const target = trimEnd(providerConfig.getString('target'), '/'); + + const authority = providerConfig.getOptionalString('authority') + ? trimEnd(providerConfig.getOptionalString('authority'), '/') + : 'https://login.microsoftonline.com'; const tenantId = providerConfig.getString('tenantId'); const clientId = providerConfig.getString('clientId'); const clientSecret = providerConfig.getString('clientSecret'); const userFilter = providerConfig.getOptionalString('userFilter'); + const userGroupMemberFilter = providerConfig.getOptionalString( + 'userGroupMemberFilter', + ); const groupFilter = providerConfig.getOptionalString('groupFilter'); providers.push({ @@ -83,6 +94,7 @@ export function readMicrosoftGraphConfig( clientId, clientSecret, userFilter, + userGroupMemberFilter, groupFilter, }); } diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts index 82279b76c6..fa86d77b27 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.test.ts @@ -19,8 +19,10 @@ import merge from 'lodash/merge'; import { GroupMember, MicrosoftGraphClient } from './client'; import { readMicrosoftGraphGroups, + readMicrosoftGraphOrg, readMicrosoftGraphOrganization, readMicrosoftGraphUsers, + readMicrosoftGraphUsersInGroups, resolveRelations, } from './read'; import { getVoidLogger } from '@backstage/backend-common'; @@ -59,6 +61,7 @@ function group(data: Partial): GroupEntity { describe('read microsoft graph', () => { const client: jest.Mocked = { getUsers: jest.fn(), + getUserProfile: jest.fn(), getGroups: jest.fn(), getGroupMembers: jest.fn(), getUserPhotoWithSizeLimit: jest.fn(), @@ -158,6 +161,144 @@ describe('read microsoft graph', () => { }); }); + describe('readMicrosoftGraphUsersInGroups', () => { + it('should read users from Groups', async () => { + async function* getExampleGroups() { + yield { + id: 'groupid', + displayName: 'Group Name', + description: 'Group Description', + mail: 'group@example.com', + }; + } + + async function* getExampleGroupMembers(): AsyncIterable { + yield { + '@odata.type': '#microsoft.graph.group', + id: 'childgroupid', + }; + yield { + '@odata.type': '#microsoft.graph.user', + id: 'userid', + }; + } + + client.getGroups.mockImplementation(getExampleGroups); + client.getGroupMembers.mockImplementation(getExampleGroupMembers); + + client.getUserProfile.mockResolvedValue({ + id: 'userid', + displayName: 'User Name', + mail: 'user.name@example.com', + }); + client.getUserPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + const { users } = await readMicrosoftGraphUsersInGroups(client, { + userGroupMemberFilter: 'securityEnabled eq true', + logger: getVoidLogger(), + }); + + expect(users).toEqual([ + user({ + metadata: { + annotations: { + 'graph.microsoft.com/user-id': 'userid', + }, + name: 'user.name_example.com', + }, + spec: { + profile: { + displayName: 'User Name', + email: 'user.name@example.com', + picture: 'data:image/jpeg;base64,...', + }, + memberOf: [], + }, + }), + ]); + + expect(client.getGroups).toBeCalledTimes(1); + expect(client.getGroups).toBeCalledWith({ + filter: 'securityEnabled eq true', + }); + expect(client.getGroupMembers).toBeCalledTimes(1); + expect(client.getGroupMembers).toBeCalledWith('groupid'); + + expect(client.getUserProfile).toBeCalledTimes(1); + expect(client.getUserProfile).toBeCalledWith('userid'); + expect(client.getUserPhotoWithSizeLimit).toBeCalledTimes(1); + expect(client.getUserPhotoWithSizeLimit).toBeCalledWith('userid', 120); + }); + + it('should read users with custom transformer', async () => { + async function* getExampleGroups() { + yield { + id: 'groupid', + displayName: 'Group Name', + description: 'Group Description', + mail: 'group@example.com', + }; + } + + async function* getExampleGroupMembers(): AsyncIterable { + yield { + '@odata.type': '#microsoft.graph.group', + id: 'childgroupid', + }; + yield { + '@odata.type': '#microsoft.graph.user', + id: 'userid', + }; + } + + client.getGroups.mockImplementation(getExampleGroups); + client.getGroupMembers.mockImplementation(getExampleGroupMembers); + + client.getUserProfile.mockResolvedValue({ + id: 'userid', + displayName: 'User Name', + mail: 'user.name@example.com', + }); + client.getUserPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + const { users } = await readMicrosoftGraphUsersInGroups(client, { + userGroupMemberFilter: 'securityEnabled eq true', + transformer: async () => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'x' }, + spec: { memberOf: [] }, + }), + logger: getVoidLogger(), + }); + + expect(users).toEqual([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'x' }, + spec: { memberOf: [] }, + }, + ]); + + expect(client.getGroups).toBeCalledTimes(1); + expect(client.getGroups).toBeCalledWith({ + filter: 'securityEnabled eq true', + }); + expect(client.getGroupMembers).toBeCalledTimes(1); + expect(client.getGroupMembers).toBeCalledWith('groupid'); + + expect(client.getUserProfile).toBeCalledTimes(1); + expect(client.getUserProfile).toBeCalledWith('userid'); + expect(client.getUserPhotoWithSizeLimit).toBeCalledTimes(1); + expect(client.getUserPhotoWithSizeLimit).toBeCalledWith('userid', 120); + }); + }); + describe('readMicrosoftGraphOrganization', () => { it('should read organization', async () => { client.getOrganization.mockResolvedValue({ @@ -397,4 +538,132 @@ describe('read microsoft graph', () => { ); }); }); + + describe('readMicrosoftGraphOrg', () => { + async function* getExampleUsers() { + yield { + id: 'userid', + displayName: 'User Name', + mail: 'user.name@example.com', + }; + } + + async function* getExampleGroups() { + yield { + id: 'groupid', + displayName: 'Group Name', + description: 'Group Description', + mail: 'group@example.com', + }; + } + + async function* getExampleGroupMembers(): AsyncIterable { + yield { + '@odata.type': '#microsoft.graph.group', + id: 'childgroupid', + }; + yield { + '@odata.type': '#microsoft.graph.user', + id: 'userid', + }; + } + + it('should read all users if no filter provided', async () => { + client.getOrganization.mockResolvedValue({ + id: 'tenantid', + displayName: 'Organization Name', + }); + + client.getUsers.mockImplementation(getExampleUsers); + client.getUserPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + client.getGroups.mockImplementation(getExampleGroups); + client.getGroupMembers.mockImplementation(getExampleGroupMembers); + client.getGroupPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + await readMicrosoftGraphOrg(client, 'tenantid', { + logger: getVoidLogger(), + groupFilter: 'securityEnabled eq false', + }); + + expect(client.getUsers).toBeCalledTimes(1); + expect(client.getUsers).toBeCalledWith({ + filter: undefined, + }); + expect(client.getGroups).toBeCalledTimes(1); + expect(client.getGroups).toBeCalledWith({ + filter: 'securityEnabled eq false', + }); + }); + + it('should read users using userFilter', async () => { + client.getOrganization.mockResolvedValue({ + id: 'tenantid', + displayName: 'Organization Name', + }); + + client.getUsers.mockImplementation(getExampleUsers); + client.getUserPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + client.getGroups.mockImplementation(getExampleGroups); + client.getGroupMembers.mockImplementation(getExampleGroupMembers); + client.getGroupPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + await readMicrosoftGraphOrg(client, 'tenantid', { + logger: getVoidLogger(), + userFilter: 'accountEnabled eq true', + groupFilter: 'securityEnabled eq false', + }); + + expect(client.getUsers).toBeCalledTimes(1); + expect(client.getUsers).toBeCalledWith({ + filter: 'accountEnabled eq true', + }); + expect(client.getGroups).toBeCalledTimes(1); + expect(client.getGroups).toBeCalledWith({ + filter: 'securityEnabled eq false', + }); + }); + + it('should read users using userGroupMemberFilter', async () => { + client.getOrganization.mockResolvedValue({ + id: 'tenantid', + displayName: 'Organization Name', + }); + + client.getUsers.mockImplementation(getExampleUsers); + client.getUserPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + client.getGroups.mockImplementation(getExampleGroups); + client.getGroupMembers.mockImplementation(getExampleGroupMembers); + client.getGroupPhotoWithSizeLimit.mockResolvedValue( + 'data:image/jpeg;base64,...', + ); + + await readMicrosoftGraphOrg(client, 'tenantid', { + logger: getVoidLogger(), + userGroupMemberFilter: 'name eq backstage-group', + groupFilter: 'securityEnabled eq false', + }); + + expect(client.getUsers).toBeCalledTimes(0); + expect(client.getGroups).toBeCalledTimes(2); + expect(client.getGroups).toBeCalledWith({ + filter: 'name eq backstage-group', + }); + expect(client.getGroups).toBeCalledWith({ + filter: 'securityEnabled eq false', + }); + }); + }); }); diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts index 096cf8a479..fa229ca956 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/read.ts @@ -125,6 +125,89 @@ export async function readMicrosoftGraphUsers( return { users }; } +export async function readMicrosoftGraphUsersInGroups( + client: MicrosoftGraphClient, + options: { + userGroupMemberFilter?: string; + transformer?: UserTransformer; + logger: Logger; + }, +): Promise<{ + users: UserEntity[]; // With all relations empty +}> { + const users: UserEntity[] = []; + + const limiter = limiterFactory(10); + + const transformer = options?.transformer ?? defaultUserTransformer; + const userGroupMemberPromises: Promise[] = []; + const userPromises: Promise[] = []; + + const groupMemberUsers: Set = new Set(); + + for await (const group of client.getGroups({ + filter: options?.userGroupMemberFilter, + })) { + // Process all groups in parallel, otherwise it can take quite some time + userGroupMemberPromises.push( + limiter(async () => { + for await (const member of client.getGroupMembers(group.id!)) { + if (!member.id) { + continue; + } + + if (member['@odata.type'] === '#microsoft.graph.user') { + groupMemberUsers.add(member.id); + } + } + }), + ); + } + + // Wait for all group members + await Promise.all(userGroupMemberPromises); + + options.logger.info(`groupMemberUsers ${groupMemberUsers.size}`); + for (const userId of groupMemberUsers) { + // Process all users in parallel, otherwise it can take quite some time + userPromises.push( + limiter(async () => { + let user; + let userPhoto; + try { + user = await client.getUserProfile(userId); + } catch (e) { + options.logger.warn(`Unable to load user for ${userId}`); + } + if (user) { + try { + userPhoto = await client.getUserPhotoWithSizeLimit( + user.id!, + // We are limiting the photo size, as users with full resolution photos + // can make the Backstage API slow + 120, + ); + } catch (e) { + options.logger.warn(`Unable to load userphoto for ${userId}`); + } + + const entity = await transformer(user, userPhoto); + + if (!entity) { + return; + } + users.push(entity); + } + }), + ); + } + + // Wait for all users and photos to be downloaded + await Promise.all(userPromises); + + return { users }; +} + export async function defaultOrganizationTransformer( organization: MicrosoftGraph.Organization, ): Promise { @@ -387,6 +470,7 @@ export async function readMicrosoftGraphOrg( tenantId: string, options: { userFilter?: string; + userGroupMemberFilter?: string; groupFilter?: string; userTransformer?: UserTransformer; groupTransformer?: GroupTransformer; @@ -394,11 +478,26 @@ export async function readMicrosoftGraphOrg( logger: Logger; }, ): Promise<{ users: UserEntity[]; groups: GroupEntity[] }> { - const { users } = await readMicrosoftGraphUsers(client, { - userFilter: options.userFilter, - transformer: options.userTransformer, - logger: options.logger, - }); + const users: UserEntity[] = []; + + if (options.userGroupMemberFilter) { + const { users: usersInGroups } = await readMicrosoftGraphUsersInGroups( + client, + { + userGroupMemberFilter: options.userGroupMemberFilter, + transformer: options.userTransformer, + logger: options.logger, + }, + ); + users.push(...usersInGroups); + } else { + const { users: usersWithFilter } = await readMicrosoftGraphUsers(client, { + userFilter: options.userFilter, + transformer: options.userTransformer, + logger: options.logger, + }); + users.push(...usersWithFilter); + } const { groups, rootGroup, groupMember, groupMemberOf } = await readMicrosoftGraphGroups(client, tenantId, { groupFilter: options?.groupFilter, diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts index 161cc8799f..7f4e55c0cc 100644 --- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts +++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgReaderProcessor.ts @@ -90,6 +90,12 @@ export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor { ); } + if (provider.userFilter && provider.userGroupMemberFilter) { + throw new Error( + `userFilter and userGroupMemberFilter are mutually exclusive, only one can be specified.`, + ); + } + // Read out all of the raw data const startTimestamp = Date.now(); this.logger.info('Reading Microsoft Graph users and groups'); @@ -101,6 +107,7 @@ export class MicrosoftGraphOrgReaderProcessor implements CatalogProcessor { provider.tenantId, { userFilter: provider.userFilter, + userGroupMemberFilter: provider.userGroupMemberFilter, groupFilter: provider.groupFilter, userTransformer: this.userTransformer, groupTransformer: this.groupTransformer, diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index e3e6bbe786..846fba5d01 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,56 @@ # @backstage/plugin-catalog-backend +## 0.16.0 + +### Minor Changes + +- 2c5bab2f82: Errors emitted from processors are now considered a failure during entity processing and will prevent entities from being updated. The impact of this change is that when errors are emitted while for example reading a location, then ingestion effectively stops there. If you emit a number of entities along with just one error, then the error will be persisted on the current entity but the emitted entities will _not_ be stored. This fixes [a bug](https://github.com/backstage/backstage/issues/6973) where entities would get marked as orphaned rather than put in an error state when the catalog failed to read a location. + + In previous versions of the catalog, an emitted error was treated as a less severe problem than an exception thrown by the processor. We are now ensuring that the behavior is consistent for these two cases. Even though both thrown and emitted errors are treated the same, emitted errors stay around as they allow you to highlight multiple errors related to an entity at once. An emitted error will also only prevent the writing of the processing result, while a thrown error will skip the rest of the processing steps. + +### Patch Changes + +- 957e4b3351: Updated dependencies +- f66c38148a: Avoid duplicate logging of entity processing errors. +- 426d5031a6: A number of classes and types, that were part of the old catalog engine implementation, are now formally marked as deprecated. They will be removed entirely from the code base in a future release. + + After upgrading to this version, it is recommended that you take a look inside your `packages/backend/src/plugins/catalog.ts` file (using a code editor), to see if you are using any functionality that it marks as deprecated. If you do, please migrate away from it at your earliest convenience. + + Migrating to using the new engine implementation is typically a matter of calling `CatalogBuilder.create({ ... })` instead of `new CatalogBuilder({ ... })`. + + If you are seeing deprecation warnings for `createRouter`, you can either use the `router` field from the return value from updated catalog builder, or temporarily call `createNextRouter`. The latter will however also be deprecated at a later time. + +- 7b78dd17e6: Replace slash stripping regexp with trimEnd to remove CodeQL warning +- Updated dependencies + - @backstage/catalog-model@0.9.4 + - @backstage/backend-common@0.9.6 + - @backstage/catalog-client@0.5.0 + - @backstage/integration@0.6.7 + +## 0.15.0 + +### Minor Changes + +- 1572d02b63: Introduced a new `CatalogProcessorCache` that is available to catalog processors. It allows arbitrary values to be saved that will then be visible during the next run. The cache is scoped to each individual processor and entity, but is shared across processing steps in a single processor. + + The cache is available as a new argument to each of the processing steps, except for `validateEntityKind` and `handleError`. + + This also introduces an optional `getProcessorName` to the `CatalogProcessor` interface, which is used to provide a stable identifier for the processor. While it is currently optional it will move to be required in the future. + + The breaking part of this change is the modification of the `state` field in the `EntityProcessingRequest` and `EntityProcessingResult` types. This is unlikely to have any impact as the `state` field was previously unused, but could require some minor updates. + +- c1836728e0: Add `/entities/by-name/:kind/:namespace/:name/ancestry` to get the "processing parents" lineage of an entity. + + This involves a breaking change of adding the method `entityAncestry` to `EntitiesCatalog`. + +### Patch Changes + +- 3d10360c82: When issuing a `full` update from an entity provider, entities with updates are now properly persisted. +- 9ea4565b00: Fixed a bug where internal references within the catalog were broken when new entities where added through entity providers, such as registering a new location or adding one in configuration. These broken references then caused some entities to be incorrectly marked as orphaned and prevented refresh from working properly. +- Updated dependencies + - @backstage/backend-common@0.9.5 + - @backstage/integration@0.6.6 + ## 0.14.0 ### Minor Changes diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 133deb96fc..68d50911d6 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -35,7 +35,7 @@ import { Validators } from '@backstage/catalog-model'; // Warning: (ae-missing-release-tag) "AddLocationResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export type AddLocationResult = { location: Location_2; entities: Entity[]; @@ -91,7 +91,7 @@ export type AnalyzeLocationResponse = { // @public (undocumented) export class AnnotateLocationEntityProcessor implements CatalogProcessor { // Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts - constructor(options: Options_2); + constructor(options: Options); // (undocumented) preProcessEntity( entity: Entity, @@ -225,6 +225,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { // // @public export class CatalogBuilder { + // @deprecated constructor(env: CatalogEnvironment); // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen addEntityPolicy(...policies: EntityPolicy[]): CatalogBuilder; @@ -522,7 +523,7 @@ export function createRandomRefreshInterval(options: { // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export function createRouter(options: RouterOptions): Promise; // Warning: (ae-missing-release-tag) "Database" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -573,7 +574,7 @@ export type Database = { // Warning: (ae-missing-release-tag) "DatabaseEntitiesCatalog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export class DatabaseEntitiesCatalog implements EntitiesCatalog { constructor(database: Database, logger: Logger_2); // (undocumented) @@ -588,6 +589,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // (undocumented) entities(request?: EntitiesRequest): Promise; // (undocumented) + entityAncestry(): Promise; + // (undocumented) removeEntityByUid(uid: string): Promise; } @@ -805,8 +808,6 @@ export type DeferredEntity = { // @public export function durationText(startTimestamp: [number, number]): string; -// Warning: (ae-missing-release-tag) "EntitiesCatalog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type EntitiesCatalog = { entities(request?: EntitiesRequest): Promise; @@ -819,6 +820,7 @@ export type EntitiesCatalog = { outputEntities?: boolean; }, ): Promise; + entityAncestry(entityRef: string): Promise; }; // Warning: (ae-missing-release-tag) "EntitiesRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -855,6 +857,15 @@ function entity( newEntity: Entity, ): CatalogProcessorResult; +// @public (undocumented) +export type EntityAncestryResponse = { + rootEntityRef: string; + items: Array<{ + entity: Entity; + parentEntityRefs: string[]; + }>; +}; + // Warning: (ae-missing-release-tag) "EntityFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -1051,7 +1062,7 @@ export class GitLabDiscoveryProcessor implements CatalogProcessor { // Warning: (ae-missing-release-tag) "HigherOrderOperation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export type HigherOrderOperation = { addLocation( spec: LocationSpec, @@ -1064,7 +1075,7 @@ export type HigherOrderOperation = { // Warning: (ae-missing-release-tag) "HigherOrderOperations" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public +// @public @deprecated export class HigherOrderOperations implements HigherOrderOperation { constructor( entitiesCatalog: EntitiesCatalog, @@ -1129,17 +1140,17 @@ export type LocationEntityProcessorOptions = { // Warning: (ae-missing-release-tag) "LocationReader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export type LocationReader = { read(location: LocationSpec): Promise; }; // Warning: (ae-missing-release-tag) "LocationReaders" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public +// @public @deprecated export class LocationReaders implements LocationReader { // Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts - constructor(options: Options); + constructor(options: Options_3); // (undocumented) read(location: LocationSpec): Promise; } @@ -1364,7 +1375,7 @@ export type PlaceholderResolverResolveUrl = ( // Warning: (ae-missing-release-tag) "ReadLocationEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export type ReadLocationEntity = { location: LocationSpec; entity: Entity; @@ -1373,7 +1384,7 @@ export type ReadLocationEntity = { // Warning: (ae-missing-release-tag) "ReadLocationError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export type ReadLocationError = { location: LocationSpec; error: Error; @@ -1381,7 +1392,7 @@ export type ReadLocationError = { // Warning: (ae-missing-release-tag) "ReadLocationResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export type ReadLocationResult = { entities: ReadLocationEntity[]; errors: ReadLocationError[]; @@ -1432,7 +1443,7 @@ export { results }; // Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export interface RouterOptions { // (undocumented) config: Config; @@ -1486,7 +1497,7 @@ export type Transaction = { // @public (undocumented) export class UrlReaderProcessor implements CatalogProcessor { // Warning: (ae-forgotten-export) The symbol "Options" needs to be exported by the entry point index.d.ts - constructor(options: Options_3); + constructor(options: Options_2); // (undocumented) getProcessorName(): string; // (undocumented) @@ -1501,12 +1512,9 @@ export class UrlReaderProcessor implements CatalogProcessor { // Warnings were encountered during analysis: // -// src/catalog/types.d.ts:30:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/catalog/types.d.ts:36:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/catalog/types.d.ts:42:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/catalog/types.d.ts:43:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// src/catalog/types.d.ts:44:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters -// src/catalog/types.d.ts:45:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// src/catalog/types.d.ts:52:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// src/catalog/types.d.ts:53:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters +// src/catalog/types.d.ts:54:8 - (tsdoc-param-tag-with-invalid-name) The @param block should be followed by a valid parameter name: The identifier cannot non-word characters // src/database/types.d.ts:125:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/database/types.d.ts:131:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/database/types.d.ts:132:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen @@ -1518,6 +1526,6 @@ export class UrlReaderProcessor implements CatalogProcessor { // src/database/types.d.ts:164:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/database/types.d.ts:165:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen // src/ingestion/processors/GithubMultiOrgReaderProcessor.d.ts:23:9 - (ae-forgotten-export) The symbol "GithubMultiOrgConfig" needs to be exported by the entry point index.d.ts -// src/ingestion/types.d.ts:17:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen -// src/ingestion/types.d.ts:41:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/ingestion/types.d.ts:8:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen +// src/legacy/ingestion/types.d.ts:19:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen ``` diff --git a/plugins/catalog-backend/migrations/20210925102509_add_refresh_state_input_hash.js b/plugins/catalog-backend/migrations/20210925102509_add_refresh_state_input_hash.js new file mode 100644 index 0000000000..7d7a4ac578 --- /dev/null +++ b/plugins/catalog-backend/migrations/20210925102509_add_refresh_state_input_hash.js @@ -0,0 +1,38 @@ +/* + * 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. + */ + +// @ts-check + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.alterTable('refresh_state', table => { + table + .text('unprocessed_hash') + .nullable() + .comment('A hash of the unprocessed contents, used to detect changes'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('refresh_state', table => { + table.dropColumn('unprocessed_hash'); + }); +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index b9ccd40862..8a3e6dc432 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-catalog-backend", "description": "The Backstage backend plugin that provides the Backstage catalog", - "version": "0.14.0", + "version": "0.16.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,13 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.9.4", - "@backstage/catalog-client": "^0.4.0", - "@backstage/catalog-model": "^0.9.3", + "@backstage/backend-common": "^0.9.6", + "@backstage/catalog-client": "^0.5.0", + "@backstage/catalog-model": "^0.9.4", "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.2", - "@backstage/integration": "^0.6.5", - "@backstage/plugin-search-backend-node": "^0.4.2", + "@backstage/integration": "^0.6.7", "@backstage/search-common": "^0.2.0", "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", @@ -53,26 +52,24 @@ "knex": "^0.95.1", "lodash": "^4.17.21", "luxon": "^2.0.2", - "morgan": "^1.10.0", "p-limit": "^3.0.2", "prom-client": "^13.2.0", - "qs": "^6.9.4", "uuid": "^8.0.0", "winston": "^3.2.1", "yaml": "^1.9.2", "yn": "^4.0.0", - "yup": "^0.29.3" + "yup": "^0.32.9" }, "devDependencies": { "@backstage/backend-test-utils": "^0.1.7", - "@backstage/cli": "^0.7.13", - "@backstage/test-utils": "^0.1.17", + "@backstage/cli": "^0.7.15", + "@backstage/test-utils": "^0.1.18", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", "@types/lodash": "^4.14.151", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", - "@types/yup": "^0.29.8", + "@types/yup": "^0.29.13", "aws-sdk-mock": "^5.2.1", "msw": "^0.29.0", "sqlite3": "^5.0.1", diff --git a/plugins/catalog-backend/src/catalog/index.ts b/plugins/catalog-backend/src/catalog/index.ts index 04cc358568..f95d16a69f 100644 --- a/plugins/catalog-backend/src/catalog/index.ts +++ b/plugins/catalog-backend/src/catalog/index.ts @@ -14,17 +14,17 @@ * limitations under the License. */ -export { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; export { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog'; export type { EntitiesCatalog, - LocationsCatalog, - EntityUpsertRequest, - EntityUpsertResponse, EntitiesRequest, EntitiesResponse, + EntityAncestryResponse, + EntityUpsertRequest, + EntityUpsertResponse, LocationResponse, - PageInfo, + LocationsCatalog, LocationUpdateLogEvent, LocationUpdateStatus, + PageInfo, } from './types'; diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 2c08482a96..217e077434 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -51,28 +51,38 @@ export type EntityUpsertResponse = { entity?: Entity; }; +/** @public */ +export type EntityAncestryResponse = { + rootEntityRef: string; + items: Array<{ + entity: Entity; + parentEntityRefs: string[]; + }>; +}; + +/** @public */ export type EntitiesCatalog = { /** * Fetch entities. * - * @param request Request options + * @param request - Request options */ entities(request?: EntitiesRequest): Promise; /** * Removes a single entity. * - * @param uid The metadata.uid of the entity + * @param uid - The metadata.uid of the entity */ removeEntityByUid(uid: string): Promise; /** * Writes a number of entities efficiently to storage. * - * @param requests The entities and their relations - * @param options.locationId The location that they all belong to (default none) - * @param options.dryRun Whether to throw away the results (default false) - * @param options.outputEntities Whether to return the resulting entities (default false) + * @param requests - The entities and their relations + * @param options.locationId - The location that they all belong to (default none) + * @param options.dryRun - Whether to throw away the results (default false) + * @param options.outputEntities - Whether to return the resulting entities (default false) */ batchAddOrUpdateEntities( requests: EntityUpsertRequest[], @@ -82,6 +92,13 @@ export type EntitiesCatalog = { outputEntities?: boolean; }, ): Promise; + + /** + * Returns the full ancestry tree upward along reference edges. + * + * @param entityRef - An entity reference to the root of the tree + */ + entityAncestry(entityRef: string): Promise; }; // @@ -93,6 +110,7 @@ export type LocationUpdateStatus = { status: string | null; message: string | null; }; + export type LocationUpdateLogEvent = { id: string; status: 'fail' | 'success'; diff --git a/plugins/catalog-backend/src/index.ts b/plugins/catalog-backend/src/index.ts index c56fc4232a..af0c32f73b 100644 --- a/plugins/catalog-backend/src/index.ts +++ b/plugins/catalog-backend/src/index.ts @@ -23,7 +23,7 @@ export * from './catalog'; export * from './database'; export * from './ingestion'; +export * from './legacy'; export * from './search'; -export * from './service'; export * from './util'; export * from './next'; diff --git a/plugins/catalog-backend/src/ingestion/index.ts b/plugins/catalog-backend/src/ingestion/index.ts index 80fea962b9..33c81c56cc 100644 --- a/plugins/catalog-backend/src/ingestion/index.ts +++ b/plugins/catalog-backend/src/ingestion/index.ts @@ -16,18 +16,10 @@ export type { CatalogRule, CatalogRulesEnforcer } from './CatalogRules'; export { DefaultCatalogRulesEnforcer } from './CatalogRules'; -export { HigherOrderOperations } from './HigherOrderOperations'; -export { LocationReaders } from './LocationReaders'; export type { - AddLocationResult, AnalyzeLocationRequest, AnalyzeLocationResponse, - HigherOrderOperation, LocationAnalyzer, - LocationReader, - ReadLocationEntity, - ReadLocationError, - ReadLocationResult, AnalyzeLocationEntityField, AnalyzeLocationExistingEntity, AnalyzeLocationGenerateEntity, diff --git a/plugins/catalog-backend/src/ingestion/processors/github/config.ts b/plugins/catalog-backend/src/ingestion/processors/github/config.ts index df9e19ff10..27bec6eb0d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/config.ts @@ -15,6 +15,7 @@ */ import { Config } from '@backstage/config'; +import { trimEnd } from 'lodash'; /** * The configuration parameters for a single GitHub API provider. @@ -52,12 +53,12 @@ export function readGithubConfig(config: Config): ProviderConfig[] { // First read all the explicit providers for (const providerConfig of providerConfigs) { - const target = providerConfig.getString('target').replace(/\/+$/, ''); + const target = trimEnd(providerConfig.getString('target'), '/'); let apiBaseUrl = providerConfig.getOptionalString('apiBaseUrl'); const token = providerConfig.getOptionalString('token'); if (apiBaseUrl) { - apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + apiBaseUrl = trimEnd(apiBaseUrl, '/'); } else if (target === 'https://github.com') { apiBaseUrl = 'https://api.github.com'; } diff --git a/plugins/catalog-backend/src/ingestion/types.ts b/plugins/catalog-backend/src/ingestion/types.ts index eb7b84e95f..f353dd3818 100644 --- a/plugins/catalog-backend/src/ingestion/types.ts +++ b/plugins/catalog-backend/src/ingestion/types.ts @@ -14,62 +14,9 @@ * limitations under the License. */ -import { - Entity, - EntityRelationSpec, - Location, - LocationSpec, -} from '@backstage/catalog-model'; +import { Entity, LocationSpec } from '@backstage/catalog-model'; import { RecursivePartial } from '../util/RecursivePartial'; -// -// HigherOrderOperation -// - -export type HigherOrderOperation = { - addLocation( - spec: LocationSpec, - options?: { dryRun?: boolean }, - ): Promise; - refreshAllLocations(): Promise; -}; - -export type AddLocationResult = { - location: Location; - entities: Entity[]; -}; - -// -// LocationReader -// - -export type LocationReader = { - /** - * Reads the contents of a location. - * - * @param location The location to read - * @throws An error if the location was handled by this reader, but could not - * be read - */ - read(location: LocationSpec): Promise; -}; - -export type ReadLocationResult = { - entities: ReadLocationEntity[]; - errors: ReadLocationError[]; -}; - -export type ReadLocationEntity = { - location: LocationSpec; - entity: Entity; - relations: EntityRelationSpec[]; -}; - -export type ReadLocationError = { - location: LocationSpec; - error: Error; -}; - // // LocationAnalyzer // diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/legacy/catalog/DatabaseEntitiesCatalog.test.ts similarity index 98% rename from plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts rename to plugins/catalog-backend/src/legacy/catalog/DatabaseEntitiesCatalog.test.ts index 7c711d92e2..953e08d07a 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/legacy/catalog/DatabaseEntitiesCatalog.test.ts @@ -16,10 +16,10 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model'; -import { Database, DatabaseManager, Transaction } from '../database'; -import { basicEntityFilter } from '../service/request'; +import { Database, DatabaseManager, Transaction } from '../../database'; +import { basicEntityFilter } from '../../service/request'; import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; -import { EntityUpsertRequest } from './types'; +import { EntityUpsertRequest } from '../../catalog/types'; describe('DatabaseEntitiesCatalog', () => { let db: jest.Mocked; diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/legacy/catalog/DatabaseEntitiesCatalog.ts similarity index 97% rename from plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts rename to plugins/catalog-backend/src/legacy/catalog/DatabaseEntitiesCatalog.ts index 69b8b500d0..f4f8fd320e 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/legacy/catalog/DatabaseEntitiesCatalog.ts @@ -26,17 +26,17 @@ import { ConflictError } from '@backstage/errors'; import { chunk, groupBy } from 'lodash'; import limiterFactory from 'p-limit'; import { Logger } from 'winston'; -import type { Database, DbEntityResponse, Transaction } from '../database'; -import { DbEntitiesRequest } from '../database/types'; -import { basicEntityFilter } from '../service/request'; -import { durationText } from '../util/timing'; +import type { Database, DbEntityResponse, Transaction } from '../../database'; +import { DbEntitiesRequest } from '../../database/types'; +import { basicEntityFilter } from '../../service/request'; +import { durationText } from '../../util/timing'; import type { EntitiesCatalog, EntitiesRequest, EntitiesResponse, EntityUpsertRequest, EntityUpsertResponse, -} from './types'; +} from '../../catalog/types'; type BatchContext = { kind: string; @@ -57,6 +57,7 @@ const BATCH_ATTEMPTS = 3; // The number of batches that may be ongoing at the same time. const BATCH_CONCURRENCY = 3; +/** @deprecated This was part of the legacy catalog engine */ export class DatabaseEntitiesCatalog implements EntitiesCatalog { constructor( private readonly database: Database, @@ -369,4 +370,8 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { return response.entity; } + + async entityAncestry(): Promise { + throw new Error('Not implemented'); + } } diff --git a/packages/core-components/src/components/CheckboxTree/index.tsx b/plugins/catalog-backend/src/legacy/catalog/index.ts similarity index 89% rename from packages/core-components/src/components/CheckboxTree/index.tsx rename to plugins/catalog-backend/src/legacy/catalog/index.ts index e1ceb98ee4..237eb4c08e 100644 --- a/packages/core-components/src/components/CheckboxTree/index.tsx +++ b/plugins/catalog-backend/src/legacy/catalog/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { CheckboxTree } from './CheckboxTree'; +export { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; diff --git a/plugins/catalog-backend/src/legacy/index.ts b/plugins/catalog-backend/src/legacy/index.ts new file mode 100644 index 0000000000..74074aa319 --- /dev/null +++ b/plugins/catalog-backend/src/legacy/index.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export * from './catalog'; +export * from './ingestion'; +export * from './service'; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/legacy/ingestion/HigherOrderOperations.test.ts similarity index 98% rename from plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts rename to plugins/catalog-backend/src/legacy/ingestion/HigherOrderOperations.test.ts index e023e3ae9e..3f7fccf024 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/legacy/ingestion/HigherOrderOperations.test.ts @@ -16,9 +16,9 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity, Location, LocationSpec } from '@backstage/catalog-model'; -import { EntitiesCatalog, LocationsCatalog } from '../catalog'; -import { LocationUpdateStatus } from '../catalog/types'; -import { DatabaseLocationUpdateLogStatus } from '../database/types'; +import { EntitiesCatalog, LocationsCatalog } from '../../catalog'; +import { LocationUpdateStatus } from '../../catalog/types'; +import { DatabaseLocationUpdateLogStatus } from '../../database/types'; import { HigherOrderOperations } from './HigherOrderOperations'; import { LocationReader } from './types'; @@ -33,6 +33,7 @@ describe('HigherOrderOperations', () => { entities: jest.fn(), removeEntityByUid: jest.fn(), batchAddOrUpdateEntities: jest.fn(), + entityAncestry: jest.fn(), }; locationsCatalog = { addLocation: jest.fn(), diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/legacy/ingestion/HigherOrderOperations.ts similarity index 96% rename from plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts rename to plugins/catalog-backend/src/legacy/ingestion/HigherOrderOperations.ts index b12fe7fb41..8d7ab6ff7c 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/legacy/ingestion/HigherOrderOperations.ts @@ -21,8 +21,8 @@ import { } from '@backstage/catalog-model'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; -import { EntitiesCatalog, LocationsCatalog } from '../catalog'; -import { durationText } from '../util'; +import { EntitiesCatalog, LocationsCatalog } from '../../catalog'; +import { durationText } from '../../util'; import { AddLocationResult, HigherOrderOperation, @@ -33,8 +33,7 @@ import { * Placeholder for operations that span several catalogs and/or stretches out * in time. * - * TODO(freben): Find a better home for these, possibly refactoring to use the - * database more directly. + * @deprecated This was part of the legacy catalog engine */ export class HigherOrderOperations implements HigherOrderOperation { constructor( diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/legacy/ingestion/LocationReaders.ts similarity index 97% rename from plugins/catalog-backend/src/ingestion/LocationReaders.ts rename to plugins/catalog-backend/src/legacy/ingestion/LocationReaders.ts index b8c9125893..91902ce5be 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/legacy/ingestion/LocationReaders.ts @@ -26,8 +26,8 @@ import { } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { Logger } from 'winston'; -import { CatalogRulesEnforcer } from './CatalogRules'; -import * as result from './processors/results'; +import { CatalogRulesEnforcer } from '../../ingestion/CatalogRules'; +import * as result from '../../ingestion/processors/results'; import { CatalogProcessor, CatalogProcessorEmit, @@ -36,7 +36,7 @@ import { CatalogProcessorLocationResult, CatalogProcessorParser, CatalogProcessorResult, -} from './processors/types'; +} from '../../ingestion/processors/types'; import { LocationReader, ReadLocationResult } from './types'; // The max amount of nesting depth of generated work items @@ -61,6 +61,8 @@ const noopCache = { /** * Implements the reading of a location through a series of processor tasks. + * + * @deprecated This was part of the legacy catalog engine */ export class LocationReaders implements LocationReader { private readonly options: Options; diff --git a/plugins/catalog-backend/src/legacy/ingestion/index.ts b/plugins/catalog-backend/src/legacy/ingestion/index.ts new file mode 100644 index 0000000000..fa63f076d6 --- /dev/null +++ b/plugins/catalog-backend/src/legacy/ingestion/index.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +export { HigherOrderOperations } from './HigherOrderOperations'; +export { LocationReaders } from './LocationReaders'; +export type { + AddLocationResult, + HigherOrderOperation, + LocationReader, + ReadLocationEntity, + ReadLocationError, + ReadLocationResult, +} from './types'; diff --git a/plugins/catalog-backend/src/legacy/ingestion/types.ts b/plugins/catalog-backend/src/legacy/ingestion/types.ts new file mode 100644 index 0000000000..b2dbab4a3a --- /dev/null +++ b/plugins/catalog-backend/src/legacy/ingestion/types.ts @@ -0,0 +1,76 @@ +/* + * 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 { + Entity, + EntityRelationSpec, + Location, + LocationSpec, +} from '@backstage/catalog-model'; + +// +// LocationReader +// + +/** @deprecated This was part of the legacy catalog engine */ +export type HigherOrderOperation = { + addLocation( + spec: LocationSpec, + options?: { dryRun?: boolean }, + ): Promise; + refreshAllLocations(): Promise; +}; + +/** @deprecated This was part of the legacy catalog engine */ +export type AddLocationResult = { + location: Location; + entities: Entity[]; +}; + +// +// LocationReader +// + +/** @deprecated This was part of the legacy catalog engine */ +export type LocationReader = { + /** + * Reads the contents of a location. + * + * @param location The location to read + * @throws An error if the location was handled by this reader, but could not + * be read + */ + read(location: LocationSpec): Promise; +}; + +/** @deprecated This was part of the legacy catalog engine */ +export type ReadLocationResult = { + entities: ReadLocationEntity[]; + errors: ReadLocationError[]; +}; + +/** @deprecated This was part of the legacy catalog engine */ +export type ReadLocationEntity = { + location: LocationSpec; + entity: Entity; + relations: EntityRelationSpec[]; +}; + +/** @deprecated This was part of the legacy catalog engine */ +export type ReadLocationError = { + location: LocationSpec; + error: Error; +}; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/legacy/service/CatalogBuilder.test.ts similarity index 96% rename from plugins/catalog-backend/src/service/CatalogBuilder.test.ts rename to plugins/catalog-backend/src/legacy/service/CatalogBuilder.test.ts index 87a26ed3aa..5d3a4dd326 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/legacy/service/CatalogBuilder.test.ts @@ -19,10 +19,11 @@ import { Entity } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import { Knex } from 'knex'; import yaml from 'yaml'; -import { DatabaseManager } from '../database'; -import { CatalogProcessorParser } from '../ingestion'; -import * as result from '../ingestion/processors/results'; -import { CatalogBuilder, CatalogEnvironment } from './CatalogBuilder'; +import { DatabaseManager } from '../../database'; +import { CatalogProcessorParser } from '../../ingestion'; +import * as result from '../../ingestion/processors/results'; +import { CatalogBuilder } from './CatalogBuilder'; +import { CatalogEnvironment } from '../../next'; const dummyEntity = { apiVersion: 'backstage.io/v1alpha1', diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/legacy/service/CatalogBuilder.ts similarity index 93% rename from plugins/catalog-backend/src/service/CatalogBuilder.ts rename to plugins/catalog-backend/src/legacy/service/CatalogBuilder.ts index 40683bb4bf..c5b3281f9a 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/legacy/service/CatalogBuilder.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common'; import { DefaultNamespaceEntityPolicy, EntityPolicies, @@ -25,17 +24,15 @@ import { SchemaValidEntityPolicy, Validators, } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; import { ScmIntegrations } from '@backstage/integration'; import lodash from 'lodash'; -import { Logger } from 'winston'; import { - DatabaseEntitiesCatalog, DatabaseLocationsCatalog, EntitiesCatalog, LocationsCatalog, -} from '../catalog'; -import { DatabaseManager } from '../database'; +} from '../../catalog'; +import { DatabaseEntitiesCatalog } from '../catalog'; +import { DatabaseManager } from '../../database'; import { AnnotateLocationEntityProcessor, BitbucketDiscoveryProcessor, @@ -47,32 +44,27 @@ import { GithubDiscoveryProcessor, GithubOrgReaderProcessor, GitLabDiscoveryProcessor, - HigherOrderOperation, - HigherOrderOperations, LocationEntityProcessor, - LocationReaders, PlaceholderProcessor, PlaceholderResolver, StaticLocationProcessor, UrlReaderProcessor, +} from '../../ingestion'; +import { + HigherOrderOperation, + HigherOrderOperations, + LocationReaders, } from '../ingestion'; -import { DefaultCatalogRulesEnforcer } from '../ingestion/CatalogRules'; -import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer'; +import { DefaultCatalogRulesEnforcer } from '../../ingestion/CatalogRules'; +import { RepoLocationAnalyzer } from '../../ingestion/LocationAnalyzer'; import { jsonPlaceholderResolver, textPlaceholderResolver, yamlPlaceholderResolver, -} from '../ingestion/processors/PlaceholderProcessor'; -import { defaultEntityDataParser } from '../ingestion/processors/util/parse'; -import { LocationAnalyzer } from '../ingestion/types'; -import { NextCatalogBuilder } from '../next'; - -export type CatalogEnvironment = { - logger: Logger; - database: PluginDatabaseManager; - config: Config; - reader: UrlReader; -}; +} from '../../ingestion/processors/PlaceholderProcessor'; +import { defaultEntityDataParser } from '../../ingestion/processors/util/parse'; +import { LocationAnalyzer } from '../../ingestion/types'; +import { CatalogEnvironment, NextCatalogBuilder } from '../../next'; /** * A builder that helps wire up all of the component parts of the catalog. @@ -92,6 +84,10 @@ export type CatalogEnvironment = { * - Processors can be added or replaced. These implement the functionality of * reading, parsing, validating, and processing the entity data before it is * persisted in the catalog. + * + * NOTE(freben): Not actually marking the class as deprecated formally, since + * it would appear to end users that even using `create` is deprecated. We will + * instead hot-swap the entire exported class when we are ready. */ export class CatalogBuilder { private readonly env: CatalogEnvironment; @@ -107,6 +103,7 @@ export class CatalogBuilder { return new NextCatalogBuilder(env); } + /** @deprecated Please use CatalogBuilder.create() instead */ constructor(env: CatalogEnvironment) { this.env = env; this.entityPolicies = []; diff --git a/plugins/catalog-backend/src/service/index.ts b/plugins/catalog-backend/src/legacy/service/index.ts similarity index 87% rename from plugins/catalog-backend/src/service/index.ts rename to plugins/catalog-backend/src/legacy/service/index.ts index 865e611de4..18d3355004 100644 --- a/plugins/catalog-backend/src/service/index.ts +++ b/plugins/catalog-backend/src/legacy/service/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * 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. @@ -15,6 +15,5 @@ */ export { CatalogBuilder } from './CatalogBuilder'; -export type { CatalogEnvironment } from './CatalogBuilder'; export { createRouter } from './router'; export type { RouterOptions } from './router'; diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/legacy/service/router.test.ts similarity index 98% rename from plugins/catalog-backend/src/service/router.test.ts rename to plugins/catalog-backend/src/legacy/service/router.test.ts index fe23e15dd3..3fc655d761 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/legacy/service/router.test.ts @@ -20,12 +20,12 @@ import { NotFoundError } from '@backstage/errors'; import type { Entity, LocationSpec } from '@backstage/catalog-model'; import express from 'express'; import request from 'supertest'; -import { EntitiesCatalog, LocationsCatalog } from '../catalog'; -import { LocationResponse } from '../catalog/types'; +import { EntitiesCatalog, LocationsCatalog } from '../../catalog'; +import { LocationResponse } from '../../catalog/types'; import { HigherOrderOperation } from '../ingestion/types'; import { createRouter } from './router'; -import { basicEntityFilter } from './request'; -import { RefreshService } from '../next'; +import { basicEntityFilter } from '../../service/request'; +import { RefreshService } from '../../next'; describe('createRouter readonly disabled', () => { let entitiesCatalog: jest.Mocked; @@ -39,6 +39,7 @@ describe('createRouter readonly disabled', () => { entities: jest.fn(), removeEntityByUid: jest.fn(), batchAddOrUpdateEntities: jest.fn(), + entityAncestry: jest.fn(), }; locationsCatalog = { addLocation: jest.fn(), @@ -383,6 +384,7 @@ describe('createRouter readonly enabled', () => { entities: jest.fn(), removeEntityByUid: jest.fn(), batchAddOrUpdateEntities: jest.fn(), + entityAncestry: jest.fn(), }; locationsCatalog = { addLocation: jest.fn(), diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/legacy/service/router.ts similarity index 94% rename from plugins/catalog-backend/src/service/router.ts rename to plugins/catalog-backend/src/legacy/service/router.ts index f1bb18c825..651880720e 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/legacy/service/router.ts @@ -26,21 +26,27 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import yn from 'yn'; -import { EntitiesCatalog, LocationsCatalog } from '../catalog'; -import { HigherOrderOperation, LocationAnalyzer } from '../ingestion/types'; -import { RefreshService, LocationService, RefreshOptions } from '../next/types'; +import { EntitiesCatalog, LocationsCatalog } from '../../catalog'; +import { LocationAnalyzer } from '../../ingestion/types'; +import { HigherOrderOperation } from '../ingestion/types'; +import { + RefreshService, + LocationService, + RefreshOptions, +} from '../../next/types'; import { basicEntityFilter, parseEntityFilterParams, parseEntityPaginationParams, parseEntityTransformParams, -} from './request'; +} from '../../service/request'; import { disallowReadonlyMode, requireRequestBody, validateRequestBody, -} from './util'; +} from '../../service/util'; +/** @deprecated This was part of the legacy catalog engine */ export interface RouterOptions { entitiesCatalog?: EntitiesCatalog; locationsCatalog?: LocationsCatalog; @@ -52,6 +58,7 @@ export interface RouterOptions { config: Config; } +/** @deprecated This was part of the legacy catalog engine */ export async function createRouter( options: RouterOptions, ): Promise { diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index 6caab23545..615f3c55e1 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { resolvePackagePath } from '@backstage/backend-common'; +import { + PluginDatabaseManager, + resolvePackagePath, + UrlReader, +} from '@backstage/backend-common'; import { DefaultNamespaceEntityPolicy, EntityPolicies, @@ -75,10 +79,18 @@ import { createRandomRefreshInterval, RefreshIntervalFunction, } from './refresh'; -import { CatalogEnvironment } from '../service/CatalogBuilder'; import { createNextRouter } from './NextRouter'; import { DefaultRefreshService } from './DefaultRefreshService'; import { DefaultCatalogRulesEnforcer } from '../ingestion/CatalogRules'; +import { Config } from '@backstage/config'; +import { Logger } from 'winston'; + +export type CatalogEnvironment = { + logger: Logger; + database: PluginDatabaseManager; + config: Config; + reader: UrlReader; +}; /** * A builder that helps wire up all of the component parts of the catalog. diff --git a/plugins/catalog-backend/src/next/NextEntitiesCatalog.test.ts b/plugins/catalog-backend/src/next/NextEntitiesCatalog.test.ts new file mode 100644 index 0000000000..c173c926e8 --- /dev/null +++ b/plugins/catalog-backend/src/next/NextEntitiesCatalog.test.ts @@ -0,0 +1,212 @@ +/* + * 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 { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { Knex } from 'knex'; +import { v4 as uuid } from 'uuid'; +import { DatabaseManager } from './database/DatabaseManager'; +import { + DbFinalEntitiesRow, + DbRefreshStateReferencesRow, + DbRefreshStateRow, +} from './database/tables'; +import { NextEntitiesCatalog } from './NextEntitiesCatalog'; + +describe('NextEntitiesCatalog', () => { + const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + async function createDatabase(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + await DatabaseManager.createDatabase(knex); + return { knex }; + } + + async function addEntity( + knex: Knex, + entity: Entity, + parents: { source?: string; entity?: Entity }[], + ) { + const id = uuid(); + const entityRef = stringifyEntityRef(entity); + const entityJson = JSON.stringify(entity); + + await knex('refresh_state').insert({ + entity_id: id, + entity_ref: entityRef, + unprocessed_entity: entityJson, + errors: '[]', + next_update_at: '2031-01-01 23:00:00', + last_discovery_at: '2021-04-01 13:37:00', + }); + + await knex('final_entities').insert({ + entity_id: id, + final_entity: entityJson, + hash: 'h', + stitch_ticket: '', + }); + + for (const parent of parents) { + await knex( + 'refresh_state_references', + ).insert({ + source_key: parent.source, + source_entity_ref: parent.entity && stringifyEntityRef(parent.entity), + target_entity_ref: stringifyEntityRef(entity), + }); + } + } + + describe('entityAncestry', () => { + it.each(databases.eachSupportedId())( + 'should return the ancestry with one parent, %p', + async databaseId => { + const { knex } = await createDatabase(databaseId); + + const grandparent: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'grandparent' }, + spec: {}, + }; + const parent: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'parent' }, + spec: {}, + }; + const root: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'root' }, + spec: {}, + }; + + await addEntity(knex, grandparent, [{ source: 's' }]); + await addEntity(knex, parent, [{ entity: grandparent }]); + await addEntity(knex, root, [{ entity: parent }]); + + const catalog = new NextEntitiesCatalog(knex); + const result = await catalog.entityAncestry('k:default/root'); + expect(result.rootEntityRef).toEqual('k:default/root'); + + expect(result.items).toEqual( + expect.arrayContaining([ + { + entity: expect.objectContaining({ metadata: { name: 'root' } }), + parentEntityRefs: ['k:default/parent'], + }, + { + entity: expect.objectContaining({ metadata: { name: 'parent' } }), + parentEntityRefs: ['k:default/grandparent'], + }, + { + entity: expect.objectContaining({ + metadata: { name: 'grandparent' }, + }), + parentEntityRefs: [], + }, + ]), + ); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'should throw error if the entity does not exist, %p', + async databaseId => { + const { knex } = await createDatabase(databaseId); + const catalog = new NextEntitiesCatalog(knex); + await expect(() => + catalog.entityAncestry('k:default/root'), + ).rejects.toThrow('No such entity k:default/root'); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'should return the ancestry with multiple parents, %p', + async databaseId => { + const { knex } = await createDatabase(databaseId); + + const grandparent: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'grandparent' }, + spec: {}, + }; + const parent1: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'parent1' }, + spec: {}, + }; + const parent2: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'parent2' }, + spec: {}, + }; + const root: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'root' }, + spec: {}, + }; + + await addEntity(knex, grandparent, [{ source: 's' }]); + await addEntity(knex, parent1, [{ entity: grandparent }]); + await addEntity(knex, parent2, [{ entity: grandparent }]); + await addEntity(knex, root, [{ entity: parent1 }, { entity: parent2 }]); + + const catalog = new NextEntitiesCatalog(knex); + const result = await catalog.entityAncestry('k:default/root'); + expect(result.rootEntityRef).toEqual('k:default/root'); + + expect(result.items).toEqual( + expect.arrayContaining([ + { + entity: expect.objectContaining({ metadata: { name: 'root' } }), + parentEntityRefs: ['k:default/parent1', 'k:default/parent2'], + }, + { + entity: expect.objectContaining({ + metadata: { name: 'parent1' }, + }), + parentEntityRefs: ['k:default/grandparent'], + }, + { + entity: expect.objectContaining({ + metadata: { name: 'parent2' }, + }), + parentEntityRefs: ['k:default/grandparent'], + }, + { + entity: expect.objectContaining({ + metadata: { name: 'grandparent' }, + }), + parentEntityRefs: [], + }, + ]), + ); + }, + 60_000, + ); + }); +}); diff --git a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts index e9772b4aa3..14fda4ea24 100644 --- a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts @@ -14,16 +14,19 @@ * limitations under the License. */ -import { InputError } from '@backstage/errors'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { InputError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import { EntitiesCatalog, EntitiesRequest, EntitiesResponse, + EntityAncestryResponse, } from '../catalog/types'; import { DbPageInfo, EntityPagination } from '../database/types'; import { DbFinalEntitiesRow, + DbRefreshStateReferencesRow, DbRefreshStateRow, DbSearchRow, } from './database/tables'; @@ -161,6 +164,70 @@ export class NextEntitiesCatalog implements EntitiesCatalog { .delete(); } + async entityAncestry(rootRef: string): Promise { + const [rootRow] = await this.database('refresh_state') + .leftJoin('final_entities', { + 'refresh_state.entity_id': 'final_entities.entity_id', + }) + .where('refresh_state.entity_ref', '=', rootRef) + .select({ + entityJson: 'final_entities.final_entity', + }); + + if (!rootRow) { + throw new NotFoundError(`No such entity ${rootRef}`); + } + + const rootEntity = JSON.parse(rootRow.entityJson) as Entity; + const seenEntityRefs = new Set(); + const todo = new Array(); + const items = new Array<{ entity: Entity; parentEntityRefs: string[] }>(); + + for ( + let current: Entity | undefined = rootEntity; + current; + current = todo.pop() + ) { + const currentRef = stringifyEntityRef(current); + seenEntityRefs.add(currentRef); + + const parentRows = await this.database( + 'refresh_state_references', + ) + .innerJoin('refresh_state', { + 'refresh_state_references.source_entity_ref': + 'refresh_state.entity_ref', + }) + .innerJoin('final_entities', { + 'refresh_state.entity_id': 'final_entities.entity_id', + }) + .where('refresh_state_references.target_entity_ref', '=', currentRef) + .select({ + parentEntityRef: 'refresh_state.entity_ref', + parentEntityJson: 'final_entities.final_entity', + }); + + const parentRefs: string[] = []; + for (const { parentEntityRef, parentEntityJson } of parentRows) { + parentRefs.push(parentEntityRef); + if (!seenEntityRefs.has(parentEntityRef)) { + seenEntityRefs.add(parentEntityRef); + todo.push(JSON.parse(parentEntityJson)); + } + } + + items.push({ + entity: current, + parentEntityRefs: parentRefs, + }); + } + + return { + rootEntityRef: stringifyEntityRef(rootEntity), + items, + }; + } + async batchAddOrUpdateEntities(): Promise { throw new Error('Not implemented'); } diff --git a/plugins/catalog-backend/src/next/NextRouter.test.ts b/plugins/catalog-backend/src/next/NextRouter.test.ts index 15c8716004..489e7efdb5 100644 --- a/plugins/catalog-backend/src/next/NextRouter.test.ts +++ b/plugins/catalog-backend/src/next/NextRouter.test.ts @@ -36,6 +36,7 @@ describe('createNextRouter readonly disabled', () => { entities: jest.fn(), removeEntityByUid: jest.fn(), batchAddOrUpdateEntities: jest.fn(), + entityAncestry: jest.fn(), }; locationService = { getLocation: jest.fn(), @@ -318,6 +319,7 @@ describe('createNextRouter readonly enabled', () => { entities: jest.fn(), removeEntityByUid: jest.fn(), batchAddOrUpdateEntities: jest.fn(), + entityAncestry: jest.fn(), }; locationService = { getLocation: jest.fn(), diff --git a/plugins/catalog-backend/src/next/NextRouter.ts b/plugins/catalog-backend/src/next/NextRouter.ts index b9da91b30a..6352f96c59 100644 --- a/plugins/catalog-backend/src/next/NextRouter.ts +++ b/plugins/catalog-backend/src/next/NextRouter.ts @@ -18,6 +18,7 @@ import { errorHandler } from '@backstage/backend-common'; import { analyzeLocationSchema, locationSpecSchema, + stringifyEntityRef, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { NotFoundError } from '@backstage/errors'; @@ -124,7 +125,16 @@ export async function createNextRouter( ); } res.status(200).json(entities[0]); - }); + }) + .get( + '/entities/by-name/:kind/:namespace/:name/ancestry', + async (req, res) => { + const { kind, namespace, name } = req.params; + const entityRef = stringifyEntityRef({ kind, namespace, name }); + const response = await entitiesCatalog.entityAncestry(entityRef); + res.status(200).json(response); + }, + ); } if (locationService) { diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts index 2a28951e6b..6148325581 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.test.ts @@ -30,6 +30,7 @@ import { } from './tables'; import { createRandomRefreshInterval } from '../refresh'; import { timestampToDateTime } from './conversion'; +import { generateStableHash } from './util'; describe('Default Processing Database', () => { const defaultLogger = getVoidLogger(); @@ -309,6 +310,7 @@ describe('Default Processing Database', () => { entity_id: id, entity_ref: 'location:default/fakelocation', unprocessed_entity: '{}', + unprocessed_hash: generateStableHash({} as any), processed_entity: '{}', errors: '[]', next_update_at: '2021-04-01 13:37:00', @@ -996,6 +998,100 @@ describe('Default Processing Database', () => { }, 60_000, ); + + it.each(databases.eachSupportedId())( + 'should support replacing modified entities during a full update, %p', + async databaseId => { + const { knex, db } = await createDatabase(databaseId); + + await db.transaction(async tx => { + await db.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'lols', + items: [ + { + entity: { + apiVersion: '1', + kind: 'Component', + metadata: { name: 'a' }, + spec: { marker: 'WILL_CHANGE' }, + } as Entity, + locationKey: 'file:///tmp/a', + }, + { + entity: { + apiVersion: '1', + kind: 'Component', + metadata: { name: 'b' }, + spec: { marker: 'NEVER_CHANGES' }, + } as Entity, + locationKey: 'file:///tmp/b', + }, + ], + }); + }); + + let state = await knex('refresh_state').select(); + expect(state).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entity_ref: 'component:default/a', + location_key: 'file:///tmp/a', + unprocessed_entity: expect.stringContaining('WILL_CHANGE'), + }), + expect.objectContaining({ + entity_ref: 'component:default/b', + location_key: 'file:///tmp/b', + unprocessed_entity: expect.stringContaining('NEVER_CHANGES'), + }), + ]), + ); + + await db.transaction(async tx => { + await db.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'lols', + items: [ + { + entity: { + apiVersion: '1', + kind: 'Component', + metadata: { name: 'a' }, + spec: { marker: 'HAS_CHANGED' }, + } as Entity, + locationKey: 'file:///tmp/a', + }, + { + entity: { + apiVersion: '1', + kind: 'Component', + metadata: { name: 'b' }, + spec: { marker: 'NEVER_CHANGES' }, + } as Entity, + locationKey: 'file:///tmp/b', + }, + ], + }); + }); + + state = await knex('refresh_state').select(); + expect(state).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entity_ref: 'component:default/a', + location_key: 'file:///tmp/a', + unprocessed_entity: expect.stringContaining('HAS_CHANGED'), + }), + expect.objectContaining({ + entity_ref: 'component:default/b', + location_key: 'file:///tmp/b', + unprocessed_entity: expect.stringContaining('NEVER_CHANGES'), + }), + ]), + ); + }, + 60_000, + ); }); describe('getProcessableEntities', () => { diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index 3dfb4beb80..7b32bec5e9 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -41,6 +41,7 @@ import { ListAncestorsResult, UpdateEntityCacheOptions, } from './types'; +import { generateStableHash } from './util'; // The number of items that are sent per batch to the database layer, when // doing .batchInsert calls to knex. This needs to be low enough to not cause @@ -156,7 +157,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ): Promise { const tx = txOpaque as Knex.Transaction; - const { toAdd, toRemove } = await this.createDelta(tx, options); + const { toUpsert, toRemove } = await this.createDelta(tx, options); if (toRemove.length) { // TODO(freben): Batch split, to not hit variable limits? @@ -273,14 +274,27 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); } - if (toAdd.length) { - for (const { entity, locationKey } of toAdd) { + if (toUpsert.length) { + for (const { + deferred: { entity, locationKey }, + hash, + } of toUpsert) { const entityRef = stringifyEntityRef(entity); try { - let ok = await this.insertUnprocessedEntity(tx, entity, locationKey); + let ok = await this.updateUnprocessedEntity( + tx, + entity, + hash, + locationKey, + ); if (!ok) { - ok = await this.updateUnprocessedEntity(tx, entity, locationKey); + ok = await this.insertUnprocessedEntity( + tx, + entity, + hash, + locationKey, + ); } if (ok) { @@ -448,6 +462,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { private async updateUnprocessedEntity( tx: Knex.Transaction, entity: Entity, + hash: string, locationKey?: string, ): Promise { const entityRef = stringifyEntityRef(entity); @@ -456,6 +471,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { const refreshResult = await tx('refresh_state') .update({ unprocessed_entity: serializedEntity, + unprocessed_hash: hash, location_key: locationKey, last_discovery_at: tx.fn.now(), // We only get to this point if a processed entity actually had any changes, or @@ -483,6 +499,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { private async insertUnprocessedEntity( tx: Knex.Transaction, entity: Entity, + hash: string, locationKey?: string, ): Promise { const entityRef = stringifyEntityRef(entity); @@ -493,6 +510,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { entity_id: uuid(), entity_ref: entityRef, unprocessed_entity: serializedEntity, + unprocessed_hash: hash, errors: '', location_key: locationKey, next_update_at: tx.fn.now(), @@ -558,10 +576,16 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { private async createDelta( tx: Knex.Transaction, options: ReplaceUnprocessedEntitiesOptions, - ): Promise<{ toAdd: DeferredEntity[]; toRemove: string[] }> { + ): Promise<{ + toUpsert: { deferred: DeferredEntity; hash: string }[]; + toRemove: string[]; + }> { if (options.type === 'delta') { return { - toAdd: options.added, + toUpsert: options.added.map(e => ({ + deferred: e, + hash: generateStableHash(e.entity), + })), toRemove: options.removed.map(e => stringifyEntityRef(e.entity)), }; } @@ -570,39 +594,55 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { const oldRefs = await tx( 'refresh_state_references', ) - .where({ source_key: options.sourceKey }) .leftJoin('refresh_state', { target_entity_ref: 'entity_ref', }) - .select(['target_entity_ref', 'location_key']); + .where({ source_key: options.sourceKey }) + .select({ + target_entity_ref: 'refresh_state_references.target_entity_ref', + location_key: 'refresh_state.location_key', + unprocessed_hash: 'refresh_state.unprocessed_hash', + }); const items = options.items.map(deferred => ({ deferred, ref: stringifyEntityRef(deferred.entity), + hash: generateStableHash(deferred.entity), })); const oldRefsSet = new Map( - oldRefs.map(r => [r.target_entity_ref, r.location_key]), + oldRefs.map(r => [ + r.target_entity_ref, + { + locationKey: r.location_key, + oldEntityHash: r.unprocessed_hash, + }, + ]), ); const newRefsSet = new Set(items.map(item => item.ref)); - const toAdd = new Array(); + const toUpsert = new Array<{ deferred: DeferredEntity; hash: string }>(); const toRemove = oldRefs .map(row => row.target_entity_ref) .filter(ref => !newRefsSet.has(ref)); for (const item of items) { - if (!oldRefsSet.has(item.ref)) { + const oldRef = oldRefsSet.get(item.ref); + const upsertItem = { deferred: item.deferred, hash: item.hash }; + if (!oldRef) { // Add any entity that does not exist in the database - toAdd.push(item.deferred); - } else if (oldRefsSet.get(item.ref) !== item.deferred.locationKey) { + toUpsert.push(upsertItem); + } else if (oldRef.locationKey !== item.deferred.locationKey) { // Remove and then re-add any entity that exists, but with a different location key toRemove.push(item.ref); - toAdd.push(item.deferred); + toUpsert.push(upsertItem); + } else if (oldRef.oldEntityHash !== item.hash) { + // Entities with modifications should be pushed through too + toUpsert.push(upsertItem); } } - return { toAdd, toRemove }; + return { toUpsert, toRemove }; } /** @@ -626,10 +666,12 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { // their entity ref. for (const { entity, locationKey } of options.entities) { const entityRef = stringifyEntityRef(entity); + const hash = generateStableHash(entity); const updated = await this.updateUnprocessedEntity( tx, entity, + hash, locationKey, ); if (updated) { @@ -640,6 +682,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { const inserted = await this.insertUnprocessedEntity( tx, entity, + hash, locationKey, ); if (inserted) { diff --git a/plugins/catalog-backend/src/next/database/tables.ts b/plugins/catalog-backend/src/next/database/tables.ts index b40964d225..e064b17d7b 100644 --- a/plugins/catalog-backend/src/next/database/tables.ts +++ b/plugins/catalog-backend/src/next/database/tables.ts @@ -24,6 +24,7 @@ export type DbRefreshStateRow = { entity_id: string; entity_ref: string; unprocessed_entity: string; + unprocessed_hash?: string; processed_entity?: string; result_hash?: string; cache?: string; diff --git a/plugins/catalog-backend/src/next/database/util.ts b/plugins/catalog-backend/src/next/database/util.ts new file mode 100644 index 0000000000..771fab7fba --- /dev/null +++ b/plugins/catalog-backend/src/next/database/util.ts @@ -0,0 +1,25 @@ +/* + * 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 { createHash } from 'crypto'; +import stableStringify from 'fast-json-stable-stringify'; + +export function generateStableHash(entity: Entity) { + return createHash('sha1') + .update(stableStringify({ ...entity })) + .digest('hex'); +} diff --git a/plugins/catalog-backend/src/next/index.ts b/plugins/catalog-backend/src/next/index.ts index 5f70dd2125..bbc68651ea 100644 --- a/plugins/catalog-backend/src/next/index.ts +++ b/plugins/catalog-backend/src/next/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export type { CatalogEnvironment } from './NextCatalogBuilder'; export { NextCatalogBuilder } from './NextCatalogBuilder'; export { createNextRouter } from './NextRouter'; export type { NextRouterOptions } from './NextRouter'; diff --git a/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts b/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts index 6261c0e2ff..7a3977bcca 100644 --- a/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts +++ b/plugins/catalog-backend/src/next/processing/DefaultCatalogProcessingOrchestrator.ts @@ -157,10 +157,9 @@ export class DefaultCatalogProcessingOrchestrator ...collectorResults, completedEntity: entity, state: { cache: cache.collect() }, - ok: true, + ok: collectorResults.errors.length === 0, }; } catch (error) { - this.options.logger.warn(error.message); return { ok: false, errors: collector.results().errors.concat(error), diff --git a/plugins/catalog-backend/src/service/standaloneServer.ts b/plugins/catalog-backend/src/service/standaloneServer.ts index 7e0bacde32..4a3409802f 100644 --- a/plugins/catalog-backend/src/service/standaloneServer.ts +++ b/plugins/catalog-backend/src/service/standaloneServer.ts @@ -23,8 +23,8 @@ import { import { Server } from 'http'; import { Logger } from 'winston'; import { DatabaseManager } from '../database'; -import { CatalogBuilder } from './CatalogBuilder'; -import { createRouter } from './router'; +import { CatalogBuilder } from '../legacy/service/CatalogBuilder'; +import { createRouter } from '../legacy/service'; export interface ServerOptions { port: number; @@ -32,6 +32,7 @@ export interface ServerOptions { logger: Logger; } +// TODO(freben): Migrate to the next catalog when it's in place export async function startStandaloneServer( options: ServerOptions, ): Promise { diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index d2f9fa0a4c..7cc2d6618a 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -42,7 +42,7 @@ export async function requireRequestBody(req: Request): Promise { export async function validateRequestBody( req: Request, - schema: yup.Schema, + schema: yup.AnySchema, ): Promise { const body = await requireRequestBody(req); diff --git a/plugins/catalog-graph/CHANGELOG.md b/plugins/catalog-graph/CHANGELOG.md index 3a646aba26..20eb85af21 100644 --- a/plugins/catalog-graph/CHANGELOG.md +++ b/plugins/catalog-graph/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/plugin-catalog-graph +## 0.1.3 + +### Patch Changes + +- 81a41ec249: Added a `name` key to all extensions in order to improve Analytics API metadata. +- Updated dependencies + - @backstage/core-components@0.6.1 + - @backstage/core-plugin-api@0.1.10 + - @backstage/plugin-catalog-react@0.5.2 + - @backstage/catalog-model@0.9.4 + - @backstage/catalog-client@0.5.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@0.1.9 + - @backstage/core-components@0.6.0 + - @backstage/plugin-catalog-react@0.5.1 + ## 0.1.1 ### Patch Changes diff --git a/plugins/catalog-graph/package.json b/plugins/catalog-graph/package.json index 243c046ab5..e2de70e510 100644 --- a/plugins/catalog-graph/package.json +++ b/plugins/catalog-graph/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graph", - "version": "0.1.1", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,11 +21,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.4.0", - "@backstage/catalog-model": "^0.9.3", - "@backstage/core-components": "^0.5.0", - "@backstage/core-plugin-api": "^0.1.8", - "@backstage/plugin-catalog-react": "^0.5.0", + "@backstage/catalog-client": "^0.5.0", + "@backstage/catalog-model": "^0.9.4", + "@backstage/core-components": "^0.6.1", + "@backstage/core-plugin-api": "^0.1.10", + "@backstage/plugin-catalog-react": "^0.5.2", "@backstage/theme": "^0.2.10", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", @@ -41,18 +41,15 @@ "p-limit": "^3.1.0" }, "devDependencies": { - "@backstage/cli": "^0.7.13", - "@backstage/dev-utils": "^0.2.10", - "@backstage/test-utils": "^0.1.17", - "@backstage/core-app-api": "^0.1.14", + "@backstage/cli": "^0.7.15", + "@backstage/dev-utils": "^0.2.11", + "@backstage/test-utils": "^0.1.18", + "@backstage/core-app-api": "^0.1.16", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", - "@testing-library/react-hooks": "^3.4.2", - "@types/jest": "^26.0.7", - "@types/node": "^14.14.32", - "cross-fetch": "^3.0.6", - "msw": "^0.29.0" + "@testing-library/react-hooks": "^7.0.2", + "@types/jest": "^26.0.7" }, "files": [ "dist" diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index 8207517e03..7e3149fef7 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -57,6 +57,7 @@ describe('', () => { addLocation: jest.fn(), removeLocationById: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; apis = ApiRegistry.with(catalogApiRef, catalog); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx index c291faadbd..8b467a017e 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -87,6 +87,7 @@ describe('', () => { addLocation: jest.fn(), removeLocationById: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const apis = ApiRegistry.with(catalogApiRef, catalog); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx index fe3bef3e84..2d6c5cc93d 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx @@ -156,6 +156,7 @@ describe('', () => { addLocation: jest.fn(), removeLocationById: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; const apis = ApiRegistry.with(catalogApiRef, catalog); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts index 2675469c15..a60750bab5 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts @@ -37,6 +37,7 @@ describe('useEntityStore', () => { addLocation: jest.fn(), removeLocationById: jest.fn(), refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), }; useApi.mockReturnValue(catalogApi); diff --git a/plugins/catalog-graph/src/extensions.tsx b/plugins/catalog-graph/src/extensions.tsx index 5dbd7c50ea..4b678c7892 100644 --- a/plugins/catalog-graph/src/extensions.tsx +++ b/plugins/catalog-graph/src/extensions.tsx @@ -27,6 +27,7 @@ import { catalogGraphRouteRef } from './routes'; */ export const EntityCatalogGraphCard = catalogGraphPlugin.provide( createComponentExtension({ + name: 'EntityCatalogGraphCard', component: { lazy: () => import('./components/CatalogGraphCard').then(m => m.CatalogGraphCard), @@ -42,6 +43,7 @@ export const EntityCatalogGraphCard = catalogGraphPlugin.provide( */ export const CatalogGraphPage = catalogGraphPlugin.provide( createRoutableExtension({ + name: 'CatalogGraphPage', component: () => import('./components/CatalogGraphPage').then(m => m.CatalogGraphPage), mountPoint: catalogGraphRouteRef, diff --git a/plugins/catalog-graphql/README.md b/plugins/catalog-graphql/README.md index 911d4a401c..e05672e3a5 100644 --- a/plugins/catalog-graphql/README.md +++ b/plugins/catalog-graphql/README.md @@ -6,6 +6,6 @@ This is the Catalog GraphQL plugin. It provides the `catalog` part of the GraphQL schema. -To register it with the GraphQL backend, be sure to follow the [Getting Started](../graphql/README.md#getting-started) guide of the GraphQL plugin. +To register it with the GraphQL backend, be sure to follow the [Getting Started](../graphql-backend/README.md#getting-started) guide of the GraphQL plugin.