diff --git a/.changeset/catalog-import-export-api-snow-fight.md b/.changeset/catalog-import-export-api-snow-fight.md new file mode 100644 index 0000000000..dce9811f10 --- /dev/null +++ b/.changeset/catalog-import-export-api-snow-fight.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Export _api_ (Client, API, ref) from the catalog import plugin. diff --git a/.changeset/chatty-cooks-begin.md b/.changeset/chatty-cooks-begin.md deleted file mode 100644 index e32d22d532..0000000000 --- a/.changeset/chatty-cooks-begin.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-kubernetes': patch -'@backstage/plugin-kubernetes-backend': patch ---- - -Add AWS auth provider for Kubernetes diff --git a/.changeset/chilled-toys-raise.md b/.changeset/chilled-toys-raise.md new file mode 100644 index 0000000000..282d5492ec --- /dev/null +++ b/.changeset/chilled-toys-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Migrated to new composability API, exporting the plugin as `scaffolderPlugin`. The template list page (`/create`) is exported as the `TemplateIndexPage` extension, and the templating page itself is exported as `TemplatePage`. diff --git a/.changeset/chilly-dodos-drop.md b/.changeset/chilly-dodos-drop.md new file mode 100644 index 0000000000..2af5255e70 --- /dev/null +++ b/.changeset/chilly-dodos-drop.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-techdocs-backend': patch +--- + +1. Added option to use Azure Blob Storage as a choice to store the static generated files for TechDocs. diff --git a/.changeset/clean-rocks-ring.md b/.changeset/clean-rocks-ring.md deleted file mode 100644 index 706f3640c2..0000000000 --- a/.changeset/clean-rocks-ring.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@backstage/cli': minor ---- - -We've bumped the `@eslint-typescript` packages to the latest, which now add some additional rules that might cause lint failures. -The main one which could become an issue is the [no-use-before-define](https://eslint.org/docs/rules/no-use-before-define) rule. - -Every plugin and app has the ability to override these rules if you want to ignore them for now. - -You can reset back to the default behaviour by using the following in your own `.eslint.js` - -```js -rules: { - 'no-use-before-define': 'off' -} -``` - -Because of the nature of this change, we're unable to provide a grace period for the update :( diff --git a/.changeset/cost-insights-curvy-dingos-live.md b/.changeset/cost-insights-curvy-dingos-live.md deleted file mode 100644 index f15d3b0492..0000000000 --- a/.changeset/cost-insights-curvy-dingos-live.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': patch ---- - -Allow expand functionality to top panel product chart tooltip. diff --git a/.changeset/cost-insights-tricky-moles-grin.md b/.changeset/cost-insights-tricky-moles-grin.md new file mode 100644 index 0000000000..c35769e03c --- /dev/null +++ b/.changeset/cost-insights-tricky-moles-grin.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +add alert hooks diff --git a/.changeset/cost-insights-wise-moons-crash.md b/.changeset/cost-insights-wise-moons-crash.md new file mode 100644 index 0000000000..1277911059 --- /dev/null +++ b/.changeset/cost-insights-wise-moons-crash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Fixed date calculations incorrectly converting to UTC in some cases. This should be a transparent change. diff --git a/.changeset/cost-insights-yellow-trees-love.md b/.changeset/cost-insights-yellow-trees-love.md deleted file mode 100644 index 19eea0dfe2..0000000000 --- a/.changeset/cost-insights-yellow-trees-love.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-cost-insights': minor ---- - -Add support for additional breakdowns of daily cost data. -This changes the type of Cost.groupedCosts returned by CostInsightsApi.getGroupDailyCost. diff --git a/.changeset/curly-ghosts-laugh.md b/.changeset/curly-ghosts-laugh.md new file mode 100644 index 0000000000..8c55e93cfc --- /dev/null +++ b/.changeset/curly-ghosts-laugh.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Remove techdocs.requestUrl and techdocs.storageUrl from app-config.yaml diff --git a/.changeset/cyan-kiwis-suffer.md b/.changeset/cyan-kiwis-suffer.md new file mode 100644 index 0000000000..b59b0e9a32 --- /dev/null +++ b/.changeset/cyan-kiwis-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Update `create-plugin` template to use the new composability API, by switching to exporting a single routable extension component. diff --git a/.changeset/cyan-lions-float.md b/.changeset/cyan-lions-float.md new file mode 100644 index 0000000000..f5fa064b71 --- /dev/null +++ b/.changeset/cyan-lions-float.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-graphiql': patch +--- + +Finalized composability API migration, now exporting the plugin as `graphiqlPlugin`. diff --git a/.changeset/dirty-carrots-invent.md b/.changeset/dirty-carrots-invent.md new file mode 100644 index 0000000000..ceb4e4d2bb --- /dev/null +++ b/.changeset/dirty-carrots-invent.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Add className to the SidebarItem diff --git a/.changeset/dry-kings-hear.md b/.changeset/dry-kings-hear.md new file mode 100644 index 0000000000..06ac1ef877 --- /dev/null +++ b/.changeset/dry-kings-hear.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Fix GitLab handling of paths with spaces diff --git a/.changeset/eight-carrots-talk.md b/.changeset/eight-carrots-talk.md new file mode 100644 index 0000000000..01e38b845d --- /dev/null +++ b/.changeset/eight-carrots-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Update `WarningPanel` component to use accordion-style expansion diff --git a/.changeset/empty-coats-film.md b/.changeset/empty-coats-film.md deleted file mode 100644 index 02b0225102..0000000000 --- a/.changeset/empty-coats-film.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/integration': patch -'@backstage/techdocs-common': patch -'@backstage/plugin-catalog': patch -'@backstage/plugin-catalog-backend': patch -'@backstage/plugin-catalog-import': patch -'@backstage/plugin-scaffolder': patch -'@backstage/plugin-scaffolder-backend': patch ---- - -Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. diff --git a/.changeset/empty-hairs-fetch.md b/.changeset/empty-hairs-fetch.md new file mode 100644 index 0000000000..aedeb8a853 --- /dev/null +++ b/.changeset/empty-hairs-fetch.md @@ -0,0 +1,13 @@ +--- +'@backstage/plugin-api-docs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-org': patch +--- + +Display owner and system as entity page links in the tables of the `api-docs` +plugin. + +Move `isOwnerOf` and `getEntityRelations` from `@backstage/plugin-catalog` to +`@backstage/plugin-catalog-react` and export it from there to use it by other +plugins. diff --git a/.changeset/fair-kids-laugh.md b/.changeset/fair-kids-laugh.md new file mode 100644 index 0000000000..d478508ed9 --- /dev/null +++ b/.changeset/fair-kids-laugh.md @@ -0,0 +1,5 @@ +--- +'@backstage/theme': patch +--- + +Updates warning text color to align to updated `WarningPanel` styling diff --git a/.changeset/fast-dots-camp.md b/.changeset/fast-dots-camp.md new file mode 100644 index 0000000000..7a1f510f47 --- /dev/null +++ b/.changeset/fast-dots-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Migrated to new composability API, exporting the plugin instance as `catalogImportPlugin`, and the page as `CatalogImportPage`. diff --git a/.changeset/fast-flowers-tickle.md b/.changeset/fast-flowers-tickle.md deleted file mode 100644 index 3eacb6f1a0..0000000000 --- a/.changeset/fast-flowers-tickle.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@backstage/plugin-kafka': minor -'@backstage/plugin-kafka-backend': minor ---- - -Added support for multiple Kafka clusters and multiple consumers per component. -Note that this introduces several breaking changes. - -1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way: - -```diff -kafka: - clientId: backstage -- brokers: -- - localhost:9092 -+ clusters: -+ - name: prod -+ brokers: -+ - localhost:9092 -``` - -2. Configuration of services has changed as well to support multiple clusters: - -```diff - annotations: -- kafka.apache.org/consumer-groups: consumer -+ kafka.apache.org/consumer-groups: prod/consumer -``` - -3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path: - `/consumers/${clusterId}/${consumerGroup}/offsets` diff --git a/.changeset/fifty-dolls-doubt.md b/.changeset/fifty-dolls-doubt.md new file mode 100644 index 0000000000..1abd7a16b9 --- /dev/null +++ b/.changeset/fifty-dolls-doubt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-lighthouse': patch +--- + +Migrate to new composability API, exporting the plugin instance as `lighthousePlugin`, the top-level page as `LighthousePage`, the entity card as `EntityLastLighthouseAuditCard`, and the entity content as `EntityLighthouseContent`. diff --git a/.changeset/five-games-grin.md b/.changeset/five-games-grin.md deleted file mode 100644 index 492c92880c..0000000000 --- a/.changeset/five-games-grin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Add `--lax` option to `config:print` and `config:check`, which causes all environment variables to be assumed to be set. diff --git a/.changeset/fluffy-nails-sort.md b/.changeset/fluffy-nails-sort.md new file mode 100644 index 0000000000..0aa87b532f --- /dev/null +++ b/.changeset/fluffy-nails-sort.md @@ -0,0 +1,24 @@ +--- +'@backstage/backend-common': patch +--- + +Updated the `rootLogger` in `@backstage/backend-common` to support custom logging options. This is useful when you want to make some changes without re-implementing the entire logger and calling `setRootLogger` or `logger.configure`. For example you can add additional `defaultMeta` tags to each log entry. The following changes are included: + +- Added `createRootLogger` which accepts winston `LoggerOptions`. These options allow overriding the default keys. + +Example Usage: + +```ts +// Create the logger +const logger = createRootLogger({ + defaultMeta: { appName: 'backstage', appEnv: 'prod' }, +}); + +// Add a custom logger transport +logger.add(new MyCustomTransport()); + +const config = await loadBackendConfig({ + argv: process.argv, + logger: getRootLogger(), // already set to new logger instance +}); +``` diff --git a/.changeset/forty-jobs-occur.md b/.changeset/forty-jobs-occur.md deleted file mode 100644 index b1620a7535..0000000000 --- a/.changeset/forty-jobs-occur.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Add `EntityRefLinks` that shows one or multiple entity ref links. - -Change the about card and catalog table to use `EntityRefLinks` due to the -nature of relations to support multiple relations per type. diff --git a/.changeset/four-paws-pull.md b/.changeset/four-paws-pull.md new file mode 100644 index 0000000000..183b3dc5d7 --- /dev/null +++ b/.changeset/four-paws-pull.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Make use of the `resolveUrl` facility of the `integration` package. + +Also rename the `LocationRefProcessor` to `LocationEntityProcessor`, to match the file name. This constitutes an interface change since the class is exported, but it is unlikely to be consumed outside of the package since it sits comfortably with the other default processors inside the catalog builder. diff --git a/.changeset/four-shrimps-flash.md b/.changeset/four-shrimps-flash.md new file mode 100644 index 0000000000..fb155bafd7 --- /dev/null +++ b/.changeset/four-shrimps-flash.md @@ -0,0 +1,7 @@ +--- +'@backstage/core': patch +'@backstage/plugin-techdocs': patch +--- + +Add `href` in addition to `onClick` to `ItemCard`. Ensure that the height of a +`ItemCard` with and without tags is equal. diff --git a/.changeset/fresh-eels-compare.md b/.changeset/fresh-eels-compare.md new file mode 100644 index 0000000000..5d17556f7f --- /dev/null +++ b/.changeset/fresh-eels-compare.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Migrate to new composability API, exporting the plugin as `userSettingsPlugin` and the page as `UserSettingsPage`. diff --git a/.changeset/fuzzy-points-whisper.md b/.changeset/fuzzy-points-whisper.md deleted file mode 100644 index d1af2b2dcf..0000000000 --- a/.changeset/fuzzy-points-whisper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Fixed serialization issue with caching of public keys in AWS ALB auth provider diff --git a/.changeset/fuzzy-pumpkins-tie.md b/.changeset/fuzzy-pumpkins-tie.md new file mode 100644 index 0000000000..1e87972ce1 --- /dev/null +++ b/.changeset/fuzzy-pumpkins-tie.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Adds a new optional `links` metadata field to the Entity class within the `catalog-model` package (as discussed in [[RFC] Entity Links](https://github.com/backstage/backstage/issues/3787)). This PR adds support for the entity links only. Follow up PR's will introduce the UI component to display them. diff --git a/.changeset/gentle-scissors-compare.md b/.changeset/gentle-scissors-compare.md new file mode 100644 index 0000000000..bb615987b2 --- /dev/null +++ b/.changeset/gentle-scissors-compare.md @@ -0,0 +1,7 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-techdocs-backend': patch +--- + +`techdocs.requestUrl` and `techdocs.storageUrl` are now optional configs and the discovery API will be used to get the URL where techdocs plugin is hosted. diff --git a/.changeset/green-boats-attend.md b/.changeset/green-boats-attend.md deleted file mode 100644 index ae2324291e..0000000000 --- a/.changeset/green-boats-attend.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Support supplying a custom catalog descriptor file parser diff --git a/.changeset/happy-crabs-punch.md b/.changeset/happy-crabs-punch.md deleted file mode 100644 index 4c68dbea1a..0000000000 --- a/.changeset/happy-crabs-punch.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@backstage/config-loader': patch ---- - -Added support for environment variable substitutions in string configuration values using a `${VAR}` placeholder. All environment variables must be available, or the entire expression will be evaluated to `undefined`. To escape a substitution, use `$${...}`, which will end up as `${...}`. - -For example: - -```yaml -app: - baseUrl: https://${BASE_HOST} -``` diff --git a/.changeset/healthy-cameras-suffer.md b/.changeset/healthy-cameras-suffer.md deleted file mode 100644 index 13d74011dc..0000000000 --- a/.changeset/healthy-cameras-suffer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kubernetes': patch ---- - -Improve error reporting for plugin misconfiguration. diff --git a/.changeset/honest-jokes-rush.md b/.changeset/honest-jokes-rush.md deleted file mode 100644 index 31ec6202da..0000000000 --- a/.changeset/honest-jokes-rush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Use .text instead of .json for ALB key response diff --git a/.changeset/hot-rules-shout.md b/.changeset/hot-rules-shout.md new file mode 100644 index 0000000000..c88f4b01dc --- /dev/null +++ b/.changeset/hot-rules-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Add `children` option to `addPage`, which will be rendered as the children of the `Route`. diff --git a/.changeset/late-crews-train.md b/.changeset/late-crews-train.md new file mode 100644 index 0000000000..00f8f8b6ee --- /dev/null +++ b/.changeset/late-crews-train.md @@ -0,0 +1,6 @@ +--- +'example-app': patch +'@backstage/plugin-rollbar': patch +--- + +Migrated to new composability API, exporting the plugin instance as `rollbarPlugin`, the entity page content as `EntityRollbarContent`, and entity conditional as `isRollbarAvailable`. Updated the `EntityPage` for the `example-app` to include a composite `ErrorsSwitcher` component that works with both `Sentry` & `Rollbar`. Also removed the unused and undocumented `RollbarHome` related components. diff --git a/.changeset/little-cherries-hug.md b/.changeset/little-cherries-hug.md new file mode 100644 index 0000000000..5fb42cbdad --- /dev/null +++ b/.changeset/little-cherries-hug.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Attempt to fix windows test errors in master diff --git a/.changeset/little-pets-cross.md b/.changeset/little-pets-cross.md new file mode 100644 index 0000000000..0f4e2605cd --- /dev/null +++ b/.changeset/little-pets-cross.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Replace `yup` with `ajv`, for validation of catalog entities. diff --git a/.changeset/loud-terms-kiss.md b/.changeset/loud-terms-kiss.md deleted file mode 100644 index da50e56e3a..0000000000 --- a/.changeset/loud-terms-kiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-sonarqube': patch ---- - -Ask the SonarQube server for all support metrics prior to querying them for a project. diff --git a/.changeset/loud-walls-collect.md b/.changeset/loud-walls-collect.md new file mode 100644 index 0000000000..78356eef38 --- /dev/null +++ b/.changeset/loud-walls-collect.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixed module resolution of external libraries during backend development. Modules used to be resolved relative to the backend entrypoint, but are now resolved relative to each individual module. diff --git a/.changeset/lovely-panthers-peel.md b/.changeset/lovely-panthers-peel.md new file mode 100644 index 0000000000..512f0ebd3f --- /dev/null +++ b/.changeset/lovely-panthers-peel.md @@ -0,0 +1,8 @@ +--- +'@backstage/core': minor +--- + +Closes #3556 +The scroll bar of collapsed sidebar is now hidden without full screen. + +![image](https://user-images.githubusercontent.com/46953622/105390193-0bfd0080-5c19-11eb-8e86-2161bbe6e8d9.png) diff --git a/.changeset/lucky-guests-mate.md b/.changeset/lucky-guests-mate.md deleted file mode 100644 index fd733aac75..0000000000 --- a/.changeset/lucky-guests-mate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/config-loader': minor ---- - -Removed support for the deprecated `$data` placeholder. diff --git a/.changeset/metal-insects-compete.md b/.changeset/metal-insects-compete.md new file mode 100644 index 0000000000..ead779d6a9 --- /dev/null +++ b/.changeset/metal-insects-compete.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Migrate to new composability API, exporting the plugin instance as `orgPlugin`, and the entity cards as `EntityGroupProfileCard`, `EntityMembersListCard`, `EntityOwnershipCard`, and `EntityUserProfileCard`. diff --git a/.changeset/metal-pans-leave.md b/.changeset/metal-pans-leave.md new file mode 100644 index 0000000000..16d5478e43 --- /dev/null +++ b/.changeset/metal-pans-leave.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': patch +--- + +Bump `config-loader` to `ajv` 7, to enable v7 feature use elsewhere diff --git a/.changeset/moody-apricots-warn.md b/.changeset/moody-apricots-warn.md new file mode 100644 index 0000000000..c4d9be0ba3 --- /dev/null +++ b/.changeset/moody-apricots-warn.md @@ -0,0 +1,5 @@ +--- +'@backstage/dev-utils': patch +--- + +Added `path` option to `addPage` that can be used to set a specific path for the page rather than a generated one. Also omit sidebar item altogether if `title` option is not set. diff --git a/.changeset/moody-buckets-visit.md b/.changeset/moody-buckets-visit.md new file mode 100644 index 0000000000..07a960620b --- /dev/null +++ b/.changeset/moody-buckets-visit.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Properly forward errors that occur when looking up GitLab project IDs. diff --git a/.changeset/neat-brooms-allow.md b/.changeset/neat-brooms-allow.md new file mode 100644 index 0000000000..a4c69b0945 --- /dev/null +++ b/.changeset/neat-brooms-allow.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': patch +--- + +Each piece of the configuration schema is now validated upfront, in order to produce more informative errors. diff --git a/.changeset/nervous-pens-destroy.md b/.changeset/nervous-pens-destroy.md new file mode 100644 index 0000000000..da8ebc162d --- /dev/null +++ b/.changeset/nervous-pens-destroy.md @@ -0,0 +1,33 @@ +--- +'@backstage/cli': minor +--- + +We have updated the default `eslint` rules in the `@backstage/cli` package. + +```diff +-'@typescript-eslint/no-shadow': 'off', +-'@typescript-eslint/no-redeclare': 'off', ++'no-shadow': 'off', ++'no-redeclare': 'off', ++'@typescript-eslint/no-shadow': 'error', ++'@typescript-eslint/no-redeclare': 'error', +``` + +The rules are documented [here](https://eslint.org/docs/rules/no-shadow) and [here](https://eslint.org/docs/rules/no-redeclare). + +This involved a large number of small changes to the code base. When you compile your own code using the CLI, you may also be +affected. We consider these rules important, and the primary recommendation is to try to update your code according to the +documentation above. But those that prefer to not enable the rules, or need time to perform the updates, may update their +local `.eslintrc.js` file(s) in the repo root and/or in individual plugins as they see fit: + +```js +module.exports = { + // ... other declarations + rules: { + '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-redeclare': 'off', + }, +}; +``` + +Because of the nature of this change, we're unable to provide a grace period for the update :( diff --git a/.changeset/nice-bottles-battle.md b/.changeset/nice-bottles-battle.md new file mode 100644 index 0000000000..a688241caa --- /dev/null +++ b/.changeset/nice-bottles-battle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-backend': patch +--- + +Failures to load the frontend configuration schema now throws an error that includes more context and instructions for how to fix the issue. diff --git a/.changeset/ninety-keys-serve.md b/.changeset/ninety-keys-serve.md new file mode 100644 index 0000000000..d15884b1c4 --- /dev/null +++ b/.changeset/ninety-keys-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Add a `prop` union for `SignInPage` that allows it to be used for just a single provider, with inline errors, and optionally with automatic sign-in. diff --git a/.changeset/orange-pets-whisper.md b/.changeset/orange-pets-whisper.md new file mode 100644 index 0000000000..e96c210e43 --- /dev/null +++ b/.changeset/orange-pets-whisper.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Add check for outdated/duplicate packages to yarn start diff --git a/.changeset/plenty-steaks-confess.md b/.changeset/plenty-steaks-confess.md new file mode 100644 index 0000000000..f9ffe0f539 --- /dev/null +++ b/.changeset/plenty-steaks-confess.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-gcp-projects': patch +--- + +Migrate to new composability API, exporting the plugin as `gcpProjectsPlugin` and page as `GcpProjectsPage`. diff --git a/.changeset/poor-ligers-flow.md b/.changeset/poor-ligers-flow.md new file mode 100644 index 0000000000..1c3a8a742b --- /dev/null +++ b/.changeset/poor-ligers-flow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Throw `NotAllowedError` when registering locations with entities of disallowed kinds diff --git a/.changeset/pretty-melons-prove.md b/.changeset/pretty-melons-prove.md new file mode 100644 index 0000000000..7048757e5a --- /dev/null +++ b/.changeset/pretty-melons-prove.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-fossa': minor +--- + +Port FOSSA plugin to new extension model. + +If you are using the FOSSA plugin adjust the plugin import from `plugin` to +`fossaPlugin` and replace `` with ``. diff --git a/.changeset/quick-apes-shop.md b/.changeset/quick-apes-shop.md new file mode 100644 index 0000000000..4c87b9253d --- /dev/null +++ b/.changeset/quick-apes-shop.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Migrate to new composability API, exporting the plugin as `apiDocsPlugin`, index page as `ApiExplorerPage`, and entity page cards as `EntityApiDefinitionCard`, `EntityConsumedApisCard`, `EntityConsumingComponentsCard`, `EntityProvidedApisCard`, and `EntityProvidingComponentsCard`. diff --git a/.changeset/selfish-kids-know.md b/.changeset/selfish-kids-know.md new file mode 100644 index 0000000000..f434559135 --- /dev/null +++ b/.changeset/selfish-kids-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Finalize migration to new composability API, with the plugin instance now exported `catalogPlugin`. diff --git a/.changeset/shaggy-dingos-suffer.md b/.changeset/shaggy-dingos-suffer.md new file mode 100644 index 0000000000..c1e200b701 --- /dev/null +++ b/.changeset/shaggy-dingos-suffer.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-tech-radar': patch +--- + +Fix mapping RadarEntry and Entry for moved and url attributes +Fix clicking of links in the radar legend diff --git a/.changeset/shiny-rabbits-unite.md b/.changeset/shiny-rabbits-unite.md new file mode 100644 index 0000000000..dca9ee0dd9 --- /dev/null +++ b/.changeset/shiny-rabbits-unite.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Fix check that determines whether popup was closed or the messaging was misconfigured. diff --git a/.changeset/silent-readers-worry.md b/.changeset/silent-readers-worry.md new file mode 100644 index 0000000000..866485ecc0 --- /dev/null +++ b/.changeset/silent-readers-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube': patch +--- + +Migrate to new composability API, exporting the plugin as `sonarQubePlugin` and card as `EntitySonarQubeCard`. diff --git a/.changeset/six-ravens-heal.md b/.changeset/six-ravens-heal.md new file mode 100644 index 0000000000..960c482be9 --- /dev/null +++ b/.changeset/six-ravens-heal.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Introduce json schema variants of the `yup` validation schemas diff --git a/.changeset/sour-gorillas-fail.md b/.changeset/sour-gorillas-fail.md new file mode 100644 index 0000000000..c6394ed76e --- /dev/null +++ b/.changeset/sour-gorillas-fail.md @@ -0,0 +1,23 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +# Repo visibility for GitLab and BitBucket repos + +**NOTE: This changes default repo visibility from `private` to `public` for GitLab and BitBucket** which +is consistent with the GitHub default. If you were counting on `private` visibility, you'll need to update +your scaffolder config to use `private`. + +This adds repo visibility feature parity with GitHub for GitLab and BitBucket. + +To configure the repo visibility, set scaffolder._type_.visibility as in this example: + +```yaml +scaffolder: + github: + visibility: private # 'public' or 'internal' or 'private' (default is 'public') + gitlab: + visibility: public # 'public' or 'internal' or 'private' (default is 'public') + bitbucket: + visibility: public # 'public' or 'private' (default is 'public') +``` diff --git a/.changeset/stale-tools-confess.md b/.changeset/stale-tools-confess.md deleted file mode 100644 index 10f2203010..0000000000 --- a/.changeset/stale-tools-confess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Fix AWS ALB issuer check diff --git a/.changeset/stale-zebras-warn.md b/.changeset/stale-zebras-warn.md new file mode 100644 index 0000000000..33b53d3f75 --- /dev/null +++ b/.changeset/stale-zebras-warn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-welcome': patch +--- + +Migrated to new composability API, exporting the plugin as `welcomePlugin` and the page as `WelcomePage`. diff --git a/.changeset/tame-points-search.md b/.changeset/tame-points-search.md new file mode 100644 index 0000000000..7fffafc1cf --- /dev/null +++ b/.changeset/tame-points-search.md @@ -0,0 +1,10 @@ +--- +'@backstage/integration': patch +--- + +Add a `resolveUrl` method to integrations, that works like the two-argument URL +constructor. The reason for using this is that Azure have their paths in a +query parameter, rather than the pathname of the URL. + +The implementation is optional (when not present, the URL constructor is used), +so this does not imply a breaking change. diff --git a/.changeset/techdocs-chilly-steaks-brush.md b/.changeset/techdocs-chilly-steaks-brush.md new file mode 100644 index 0000000000..7fd4763c63 --- /dev/null +++ b/.changeset/techdocs-chilly-steaks-brush.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +dir preparer will use URL Reader in its implementation. diff --git a/.changeset/techdocs-dirty-rivers-hope.md b/.changeset/techdocs-dirty-rivers-hope.md deleted file mode 100644 index 3f49afa529..0000000000 --- a/.changeset/techdocs-dirty-rivers-hope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/techdocs-common': patch ---- - -Add rate limiter for concurrent execution of file uploads in AWS and Google publishers diff --git a/.changeset/tender-parrots-itch.md b/.changeset/tender-parrots-itch.md deleted file mode 100644 index 00757b9b22..0000000000 --- a/.changeset/tender-parrots-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/config-loader': minor ---- - -Enable further processing of configuration files included using the `$include` placeholder. Meaning that for example for example `$env` includes will be processed as usual in included files. diff --git a/.changeset/thirty-cobras-tickle.md b/.changeset/thirty-cobras-tickle.md new file mode 100644 index 0000000000..cf43318658 --- /dev/null +++ b/.changeset/thirty-cobras-tickle.md @@ -0,0 +1,15 @@ +--- +'@backstage/core': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-explore': patch +--- + +Introduce `TabbedLayout` for creating tabs that are routed. + +```typescript + + +
This is rendered under /example/anything-here route
+
+
+``` diff --git a/.changeset/tidy-news-perform.md b/.changeset/tidy-news-perform.md new file mode 100644 index 0000000000..055f051da7 --- /dev/null +++ b/.changeset/tidy-news-perform.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Keep the Pin Sidebar setting visible on small screens. diff --git a/.changeset/two-foxes-exercise.md b/.changeset/two-foxes-exercise.md new file mode 100644 index 0000000000..953eb83138 --- /dev/null +++ b/.changeset/two-foxes-exercise.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-explore': patch +--- + +Rework the explore plugin to allow the user to explore things in the ecosystem, +including tools and domains. diff --git a/.changeset/wet-suits-live.md b/.changeset/wet-suits-live.md new file mode 100644 index 0000000000..27b8df8a84 --- /dev/null +++ b/.changeset/wet-suits-live.md @@ -0,0 +1,20 @@ +--- +'@backstage/plugin-api-docs': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-circleci': patch +'@backstage/plugin-cloudbuild': patch +'@backstage/plugin-github-actions': patch +'@backstage/plugin-jenkins': patch +'@backstage/plugin-kafka': patch +'@backstage/plugin-lighthouse': patch +'@backstage/plugin-org': patch +'@backstage/plugin-register-component': patch +'@backstage/plugin-rollbar': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-search': patch +'@backstage/plugin-sentry': patch +'@backstage/plugin-techdocs': patch +'@backstage/dev-utils': patch +--- + +Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. diff --git a/.changeset/wicked-beds-buy.md b/.changeset/wicked-beds-buy.md new file mode 100644 index 0000000000..ddcf4f880e --- /dev/null +++ b/.changeset/wicked-beds-buy.md @@ -0,0 +1,61 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +The scaffolder is updated to generate a unique workspace directory inside the temp folder. This directory is cleaned up by the job processor after each run. + +The prepare/template/publish steps have been refactored to operate on known directories, `template/` and `result/`, inside the temporary workspace path. + +Updated preparers to accept the template url instead of the entire template. This is done primarily to allow for backwards compatibility between v1 and v2 scaffolder templates. + +Fixes broken GitHub actions templating in the Create React App template. + +#### For those with **custom** preparers, templates, or publishers + +The preparer interface has changed, the prepare method now only takes a single argument, and doesn't return anything. As part of this change the preparers were refactored to accept a URL pointing to the target directory, rather than computing that from the template entity. + +The `workingDirectory` option was also removed, and replaced with a `workspacePath` option. The difference between the two is that `workingDirectory` was a place for the preparer to create temporary directories, while the `workspacePath` is the specific folder were the entire templating process for a single template job takes place. Instead of returning a path to the folder were the prepared contents were placed, the contents are put at the `/template` path. + +```diff +type PreparerOptions = { +- workingDirectory?: string; ++ /** ++ * Full URL to the directory containg template data ++ */ ++ url: string; ++ /** ++ * The workspace path that will eventually be the the root of the new repo ++ */ ++ workspacePath: string; + logger: Logger; +}; + +-prepare(template: TemplateEntityV1alpha1, opts?: PreparerOptions): Promise ++prepare(opts: PreparerOptions): Promise; +``` + +Instead of returning a path to the folder were the templaters contents were placed, the contents are put at the `/result` path. All templaters now also expect the source template to be present in the `template` directory within the `workspacePath`. + +```diff +export type TemplaterRunOptions = { +- directory: string; ++ workspacePath: string; + values: TemplaterValues; + logStream?: Writable; + dockerClient: Docker; +}; + +-public async run(options: TemplaterRunOptions): Promise ++public async run(options: TemplaterRunOptions): Promise +``` + +Just like the preparer and templaters, the publishers have also switched to using `workspacePath`. The root of the new repo is expected to be located at `/result`. + +```diff +export type PublisherOptions = { + values: TemplaterValues; +- directory: string; ++ workspacePath: string; + logger: Logger; +}; +``` diff --git a/.changeset/wicked-boxes-taste.md b/.changeset/wicked-boxes-taste.md new file mode 100644 index 0000000000..3eef04825c --- /dev/null +++ b/.changeset/wicked-boxes-taste.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': major +--- + +#4322 Bitbucket own hosted v5.11.1 branchUrl fix and enabled error tracing… #4347 diff --git a/.changeset/wild-cows-exercise.md b/.changeset/wild-cows-exercise.md new file mode 100644 index 0000000000..cbc347888f --- /dev/null +++ b/.changeset/wild-cows-exercise.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog': minor +'@backstage/create-app': minor +--- + +`@backstage/plugin-catalog` stopped exporting hooks and helpers for other +plugins. They are migrated to `@backstage/plugin-catalog-react`. +Change both your dependencies and imports to the new package. diff --git a/.changeset/wise-lies-listen.md b/.changeset/wise-lies-listen.md new file mode 100644 index 0000000000..53f1edea02 --- /dev/null +++ b/.changeset/wise-lies-listen.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Implement matchEntityWithRef for client side filtering of entities by ref matching diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 425e6eea6f..2991a6babd 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -130,6 +130,7 @@ middleware minikube Minikube misconfiguration +misconfigured misgendering mkdocs Mkdocs @@ -217,6 +218,7 @@ Spotify sqlite squidfunk src +stdout stefanalund subkey subtree @@ -257,6 +259,7 @@ Voi Wealthsimple Weaveworks Webpack +winston www WWW xyz diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35e2a88ff..aa2feebb3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} + INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} + INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} + INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }} steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 62be0a6cea..0fe1642394 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -3,6 +3,7 @@ name: E2E Test Linux on: pull_request: paths-ignore: + - '.changeset/**' - 'contrib/**' - 'docs/**' - 'microsite/**' diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index ca402c48d5..a9b5bfdf0e 100644 --- a/.github/workflows/master-win.yml +++ b/.github/workflows/master-win.yml @@ -1,6 +1,7 @@ name: Master Build Windows on: + workflow_dispatch: push: branches: [master] @@ -15,6 +16,10 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} + INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} + INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} + INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }} steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 68b08224ac..425e1172a4 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -1,6 +1,7 @@ name: Main Master Build on: + workflow_dispatch: push: branches: [master] @@ -18,6 +19,10 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} + INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} + INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} + INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }} steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml new file mode 100644 index 0000000000..fdb2e1ddc4 --- /dev/null +++ b/.github/workflows/tugboat.yml @@ -0,0 +1,110 @@ +name: Tugboat E2E Tests +on: deployment_status +jobs: + set-pending: + if: github.event.deployment_status.state != 'success' && github.event.deployment_status.state != 'failed' + name: Set pending waiting for Tugboat + runs-on: ubuntu-latest + steps: + # Set an initial commit status message to indicate that the tests are + # running. + - name: set pending status + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + debug: true + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: 'pending', + context: 'Backstage Tugboat E2E Tests', + description: 'Waiting for Tugboat to complete deployment', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); + + run-tests: + # Only run after a successful Tugboat deployment. + if: github.event.deployment_status.state == 'success' + name: Run tests against Tugboat deployment + runs-on: ubuntu-latest + steps: + # Set an initial commit status message to indicate that the tests are + # running. + - name: set pending status + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + debug: true + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: 'pending', + context: 'Backstage Tugboat E2E Tests', + description: 'Running against tugboat preview', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); + + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: '14' + + # This is required because the environment_url param that Tugboat uses + # to tell us where the preview is located isn't supported unless you + # specify the custom Accept header when getting the deployment_status, + # and GitHub actions doesn't do that by default. So instead we have to + # load the status object manually and get the data we need. + # https://developer.github.com/changes/2016-04-06-deployment-and-deployment-status-enhancements/ + - name: get deployment status + id: get-status-env + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + result-encoding: string + script: | + const result = await github.repos.getDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: context.payload.deployment.id, + status_id: context.payload.deployment_status.id, + headers: { + 'Accept': 'application/vnd.github.ant-man-preview+json' + }, + }); + console.log(result); + return result.data.environment_url; + - name: echo tugboat preview url + run: | + curl ${{steps.get-status-env.outputs.result}} + - name: set status + if: ${{ failure() }} + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: "error", + context: 'Backstage Tugboat E2E Tests', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); + - name: set status + if: ${{ success() }} + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: "success", + context: 'Backstage Tugboat E2E Tests', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); diff --git a/.tugboat/config.yml b/.tugboat/config.yml new file mode 100644 index 0000000000..b8c3c127c8 --- /dev/null +++ b/.tugboat/config.yml @@ -0,0 +1,17 @@ +services: + backstage: + image: tugboatqa/node:lts + expose: 7000 + default: true + commands: + init: + - mkdir -p /etc/service/node + - echo "#!/bin/sh" > /etc/service/node/run + - echo "yarn --cwd ${TUGBOAT_ROOT} start-backend --config ${TUGBOAT_ROOT}/app-config.yaml --config ${TUGBOAT_ROOT}/.tugboat/tugboat.app-config.production.yaml" >> /etc/service/node/run + - chmod +x /etc/service/node/run + build: + - yarn install --frozen-lockfile + - yarn workspace example-app build + start: + # wget the endpoint. Will retry every 2 seconds. 30 retries = 1m for service to come up. Plenty. + - wget -O /dev/null -o /dev/null --tries=30 --timeout=5 --retry-connrefused http://localhost:7000 diff --git a/.tugboat/tugboat.app-config.production.yaml b/.tugboat/tugboat.app-config.production.yaml new file mode 100644 index 0000000000..f606574d89 --- /dev/null +++ b/.tugboat/tugboat.app-config.production.yaml @@ -0,0 +1,13 @@ +app: + title: Backstage Tugboat Preview + baseUrl: + $env: TUGBOAT_DEFAULT_SERVICE_URL + +backend: + baseUrl: + $env: TUGBOAT_DEFAULT_SERVICE_URL + cors: + origin: + $env: TUGBOAT_DEFAULT_SERVICE_URL + methods: [GET, POST, PUT, DELETE] + credentials: true diff --git a/app-config.yaml b/app-config.yaml index c150ef5902..5240a050a8 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -74,13 +74,11 @@ organization: # Reference documentation http://backstage.io/docs/features/techdocs/configuration techdocs: - requestUrl: http://localhost:7000/api/techdocs - storageUrl: http://localhost:7000/api/techdocs/static/docs builder: 'local' # Alternatives - 'external' generators: techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. + type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage'. Read documentation for using alternatives. sentry: organization: my-company @@ -208,7 +206,7 @@ catalog: # Backstage example components - type: file target: ../catalog-model/examples/all-components.yaml - # Example component for github-actions + # Example component for github-actions and TechDocs - type: file target: ../../plugins/github-actions/examples/sample.yaml # Example component for TechDocs @@ -243,6 +241,7 @@ scaffolder: baseUrl: https://gitlab.com token: $env: GITLAB_TOKEN + visibility: public # or 'internal' or 'private' azure: baseUrl: https://dev.azure.com/{your-organization} api: @@ -255,6 +254,7 @@ scaffolder: $env: BITBUCKET_USERNAME token: $env: BITBUCKET_TOKEN + visibility: public # or or 'private' auth: environment: development diff --git a/docs/architecture.drawio b/docs/architecture.drawio new file mode 100644 index 0000000000..dbe2a5f6fa --- /dev/null +++ b/docs/architecture.drawio @@ -0,0 +1 @@ +7VpLc6M4EP41PiaFBLbh6LxmLlubSqZ2Z+amBQVUBYgScmzPrx9hJIMQGJzEhtTOxUaNnp++bnU3mtm3yfYLQ1n0Fw1wPINWsJ3ZdzMIAfQ88VdIdqXEs2EpCBkJZKVK8Ex+YSm0pHRNApxrFTmlMSeZLvRpmmKfazLEGN3o1V5orI+aoRAbgmcfxab0XxLwqJS6cFnJv2ISRmpksJALTpCqLFeSRyigm5rIvp/Zt4xSXj4l21scF+ApXMp2Dx1vDxNjOOVDGvwk7Nv6n4erTfLNgzcr9/HvH5sru+zlFcVrueBVlsn58p0CId+QJEapKN3I+phxvO2cCDgsT/AC0wRzthNVZANXAiIZARayvKnwPdSJathCRwqR3NPw0HW1bPEgV34CCs4gFBhdpwEu+rEEEJuIcPycIb94uxHkF7KIJ2LcOzAUp+4t6QQP6uA5JnZt0J0NOduEzsAtFMBlnXhIxUX/qerWqXxyFv2EsuFFCQVNvbrfcpzmhKb5aMRyjsPo6CgCZ1xqKSbVMHyia46f8MtEEQSuDqEzNoLgsyPYRkLQpsrnw9CA8IHRVKhyIKSP8Tok6buPy7cgBVtsnHVRG2eSqwOOC1ELfqZz05ucZh6HDyygjh8cFz8wPdPWB+C0COga+I3voPRosM5AAMamoNUN4UQRbPjJYGQOgoWBoAGcOGhXRRgvSn6M8pz4OlYByqM9rhVwODBC+iGwiWHpmvl4wJZzxEJ8rMOOgKUG9LwFaCVjOEacvOqLaENfjvBIiVjeYZu9xjZbjf0rlykbwVrCQPWjGtqNhuWyjYb7vT+s8h10WL6fDmKf2e67VLN94UdRuIZzVb7b1t/e7VRpS/h31Yl4rjcTxapVUVCNzs63oXQblW9LnW/eqXRTNs7R+3EvzD7zRJy6R9F/IF42VgKmV1uciCxF8dSh7D8ZLxx2ms7F6baw1aQdt2iFLXuWQ1DGIxpSsXv3lbTTwvYZ2MnYSm9MW3lg1k63ne81lmA+zFoKwqBdrVpWVMiPzLcxzsI6bVq2Vl88lBP4UMsNB+Sga2px+A5j1d3HooBiEqaFNglGYiYEhaUhPopX8kVCgmCvBKKPrOg52YbFZ6/r8ksTLP+LbhXr9x/CgH0t8L1JKfcjKX+JSfb1zFbtqunvt+SMnBaqw7MZtXnr+TDpoLORj3THTumaIdPkIWy4Ke7IcTs0w4xVRibrmzSV2DWV+LLwfb7MEVhMTYvbHeVpYzgxNbZND1mo8QPyOZW9TxDDiemybWbRDeSGRhkqlBgSZczOHAsobvQGA+VZMFqizr2eL4ED3fJ3sdTY8eY8SiMfA5r0OXMiRV3w+rCs7ttj0jdm/ZafI+tnW7MRydtkWTNqGErXnuzhR8WxDTdq2RPHNr0u7wJxrG1eGjIU508cW23GaHEs+N9uVNellJ1uWj5+Y2bFtRt1U7ZUuOq+sX3/Gw== \ No newline at end of file diff --git a/docs/auth/index.md b/docs/auth/index.md index cf468c7bb7..a8ddf17c6b 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -86,7 +86,8 @@ to a `guest` identity for all users, without any ID token. To enable sign-in, a `SignInPage` needs to be configured, which in turn has to supply a user to the app. The `@backstage/core` package provides a basic sign-in page that allows both the user and the app developer to choose between a couple of different -sign-in methods. +sign-in methods, or to designate a single provider that may also be logged in to +automatically. ## Further Reading diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 9e531de655..35a027df62 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -7,8 +7,8 @@ description: Installing Kubernetes plugin into Backstage The Kubernetes feature is a plugin to Backstage, and it is exposed as a tab when viewing entities in the software catalog. -If you haven't setup Backstage already, start -[here](../../getting-started/index.md). +If you haven't setup Backstage already, read the +[Getting Started](../../getting-started/index.md). ## Adding the Kubernetes frontend plugin @@ -111,7 +111,7 @@ Backstage app. ## Running Backstage locally Start the frontend and the backend app by -[running backstage locally](../../getting-started/running-backstage-locally.md). +[running Backstage locally](../../getting-started/running-backstage-locally.md). ## Configuration diff --git a/docs/features/search/README.md b/docs/features/search/README.md index 0b13b47979..cd971d9d41 100644 --- a/docs/features/search/README.md +++ b/docs/features/search/README.md @@ -67,7 +67,7 @@ more to come... - As a contributor I should be able to integrate plugin data to the indexing process of Backstage Search by using the standardized API. - As a software engineer I should be able to search for all content (for - example, entities, metadata, documentation) in backstage search. + example, entities, metadata, documentation) in Backstage search. more to come... diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 2b173c4b3e..1aab1268cd 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -54,6 +54,11 @@ software catalog API. "labels": { "system": "public-websites" }, + "links": [{ + "url": "https://admin.example-org.com", + "title": "Admin Dashboard", + "icon": "dashboard" + }], "tags": ["java"], "name": "artist-web", "uid": "2152f463-549d-4d8d-a94d-ce2b7676c6e2" @@ -81,6 +86,10 @@ metadata: circleci.com/project-slug: github/example-org/artist-website tags: - java + links: + - url: https://admin.example-org.com + title: Admin Dashboard + icon: dashboard spec: type: website lifecycle: production @@ -314,6 +323,34 @@ This field is optional, and currently has no special semantics. Each tag must be sequences of `[a-z0-9]` separated by `-`, at most 63 characters in total. +### `links` [optional] + +A list of external hyperlinks related to the entity. Links can provide +additional contextual information that may be located outside of Backstage +itself. For example, an admin dashboard or external CMS page. + +Users may add links to descriptor YAML files to provide additional reference +information to external content & resources. Links are not intended to drive any +additional functionality within Backstage, which is best left to `annotations` +and `labels`. It is recommended to use links only when an equivalent well-known +`annotation` does not cover a similar use case. + +Fields of a link are: + +| Field | Type | Description | +| ------- | ------ | ------------------------------------------------------------------------------------ | +| `url` | String | [Required] A `url` in a standard `uri` format (e.g. `https://example.com/some/page`) | +| `title` | String | [Optional] A user friendly display name for the link. | +| `icon` | String | [Optional] A key representing a visual icon to be displayed in the UI. | + +_NOTE_: The `icon` field value is meant to be a semantic key that will map to a +specific icon that may be provided by an icon library (e.g. `material-ui` +icons). These keys should be a sequence of `[a-z0-9A-Z]`, possibly separated by +one of `[-_.]`. Backstage may support some basic icons out of the box, but the +Backstage integrator will ultimately be left to provide the appropriate icon +component mappings. A generic fallback icon would be provided if a mapping +cannot be resolved. + ## Common to All Kinds: Relations The `relations` root field is a read-only list of relations, between the current diff --git a/docs/features/software-templates/extending/create-your-own-templater.md b/docs/features/software-templates/extending/create-your-own-templater.md index 37e68c4153..28769d77e1 100644 --- a/docs/features/software-templates/extending/create-your-own-templater.md +++ b/docs/features/software-templates/extending/create-your-own-templater.md @@ -86,7 +86,7 @@ follows: _note_ Currently the templaters that we provide are basically Docker action containers that are run on top of the skeleton folder. This keeps dependencies -to a minimum for running backstage scaffolder, but you don't _have_ to use +to a minimum for running Backstage scaffolder, but you don't _have_ to use Docker. You can `pip install cookiecutter` to run it locally in your backend. You could create your own templater that spins up an EC2 instance and downloads the folder and does everything using an AMI if you want. It's entirely up to diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 1034ffef0f..12ce6e3ed3 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -39,13 +39,12 @@ internally. ![Enter some variables](../../assets/software-templates/template-picked.png) After filling in these variables, you'll get some more fields to fill out which -are required for backstage usage: the owner (which is a `user` in the backstage -system), the `storePath` (which right now must be a GitHub Organisation or -GitHub user and a non-existing GitHub repository name in the format -`organisation/reponame`), and a GitHub team or user account which should be -granted admin access to the repository. +are required for Backstage usage: the owner (which is a `user` in the backstage +system), and the `storePath` which is a destination URL to create for the +provider, for instance `https://github.com/backstage/my-new-repository`, or +`https://gitlab.com/myorg/myrepo`. -![Enter backstage vars](../../assets/software-templates/template-picked-2.png) +![Enter Backstage vars](../../assets/software-templates/template-picked-2.png) ### Run! diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index c43d6d978d..f81f41668d 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -108,12 +108,12 @@ providers are used. | GitLab | Yes ✅ | | GitLab Enterprise | Yes ✅ | -| File Storage Provider | Support Status | -| --------------------------------- | ----------------------------------------------------------------- | -| Local Filesystem of Backstage app | Yes ✅ | -| Google Cloud Storage (GCS) | Yes ✅ | -| Amazon Web Services (AWS) S3 | Yes ✅ | -| Azure Storage | No ❌ [#3938](https://github.com/backstage/backstage/issues/3938) | +| File Storage Provider | Support Status | +| --------------------------------- | -------------- | +| Local Filesystem of Backstage app | Yes ✅ | +| Google Cloud Storage (GCS) | Yes ✅ | +| Amazon Web Services (AWS) S3 | Yes ✅ | +| Azure Blob Storage | Yes ✅ | [Reach out to us](#feedback) if you want to request more platforms. diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 92cd78b071..8d25b4047f 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -12,32 +12,31 @@ out-of-the box experience. TechDocs Architecture diagram > Note: See below for our recommended deployment architecture which takes care -> of stability, scalability and speed. +> of stability, scalability and speed. Also look at the +> [HOW TO migrate guide](how-to-guides.md#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach). When you open a TechDocs site in Backstage, the [TechDocs Reader](./concepts.md#techdocs-reader) makes a request to -`techdocs-backend` with the entity ID and the path of the current page you are -looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to -render on the page in TechDocs/Backstage. +`techdocs-backend` plugin with the entity ID and the path of the current page +you are looking at. In response, it receives the static files (HTML, CSS, JSON, +etc.) to render on the page in TechDocs/Backstage. The static files consist of HTML, CSS and Images generated by MkDocs. We remove all the JavaScript before adding them to Backstage for security reasons. And -there are some additional techdocs metadata JSON files that TechDocs needs to -render a site. +there is an additional `techdocs_metadata.json` file that TechDocs needs to +render a site. It's important that you use either +[techdocs-cli](https://github.com/backstage/techdocs-cli) or +[techdocs-container](https://github.com/backstage/techdocs-container) to +generate the docs for the expected output. The TechDocs Reader then applies a list of "Transformers" (see [Concepts](./concepts.md)) which modify the generated static HTML files for a number of use cases e.g. Remove certain headers, filter out some HTML tags, etc. Currently, we use the Backstage server's (or techdocs-backend's) local file -system to store the generated files. Publishing to an external storage system -(AWS S3, GCS, etc.) is also possible, but has not been implemented yet. - -A word about `UrlReader` vs Git preparer - Right now, we have two ways to fetch -files from its source repository for docs site generation. 1. By using Git -and 2. By directly using Source control (GitHub, Azure, etc.) APIs. This work is -heavily in progress. Please reach out to us on Discord in the #docs-like-code -channel to talk about it. +system to store the generated files. However, it's ideal to use an external +storage system (e.g. AWS S3, GCS or Azure Blob Storage). Read more in +[using cloud storage](using-cloud-storage.md). ## Recommended deployment @@ -49,22 +48,16 @@ The key difference in the recommended deployment approach is where the docs are built. We assume each entity lives in a repository somewhere (GitHub, GitLab, etc.). We -recommend using a CI/CD pipeline with the repository that has a dedicated -step/job to generate docs for TechDocs. The generated static files are then -stored in a cloud storage solution of your choice. -[Track progress here](https://github.com/backstage/backstage/issues/3096). +recommend [using a CI/CD pipeline](configuring-ci-cd.md) with the repository +that has a dedicated step/job to generate docs for TechDocs. The generated +static files are then [stored in a cloud storage](using-cloud-storage.md) +solution of your choice. Similar to how it is done in the Basic setup, the TechDocs Reader requests `techdocs-backend` plugin for the docs site. `techdocs-backend` then requests your configured storage solution for the necessary files and returns them to TechDocs Reader. -We will provide instructions, scripts and/or templates (e.g. GitHub Actions) to -generate docs in your CI/CD system. -[Track progress here.](https://github.com/backstage/backstage/issues/3400) You -will be able to use `techdocs-cli` to generate docs and publish the generated -docs site files to your cloud storage system. - Note about caching: We have noticed internally that some storage providers can be quite slow, which is why we are recommending a cache that sits between the TechDocs Reader and the Storage. @@ -97,7 +90,7 @@ permissions to publish the generated docs site files. **Q: Why do you have separate "basic" and "recommended" deployment approaches?** A: The basic or out-of-the-box setup is what you get when you create a new app -or do a git clone of the Backstage repository. We want the first experience to +or do a `git clone` of the Backstage repository. We want the first experience to _just work magically_ so that you can have your first experience with TechDocs which is smooth. However, if you decide to deploy Backstage/TechDocs for production use, the basic setup would work but there are going to be downsides @@ -109,19 +102,21 @@ and we welcome such "Alternative" ideas from the community. **Q: Why don't you recommend techdocs-backend local filesystem to serve static files?** -A: It would make scaling a Backstage instance harder. Think about the case where -we have distributed Backstage deployments. Using a separate file storage system -for TechDocs makes it easier to do some operations like delete a docs site and -wipe its contents. +A: It makes scaling a Backstage instance harder. Think about the case where we +have distributed Backstage deployments (e.g. multiple Kubernetes pods of your +Backstage app). Using a separate/central file storage system for TechDocs is +necessary to make sure sites are persistent when the servers/pods are restarted +and avoid duplicating sites per instance. By having an external storage, we are +making it easier to do some operations like delete a docs site or wipe its +contents. **Q: Why aren't docs built on the fly i.e. when users visits a page, generate docs site in real-time?** -A: Generating the content from Markdown on the fly is not optimal (although that -is how the basic out-of-the-box setup is implemented). Storage solutions act as -a cache for the generated static content. TechDocs is also currently built on -MkDocs which does not allow us to generate docs per-page, so we would have to -build all docs for a entity on every request. +A: Generating the content from Markdown on the fly is not optimal. Storage +solutions act as a cache for the generated static content. TechDocs is also +currently built on MkDocs which does not allow us to generate docs per-page, so +we would have to build all docs for a entity on every request. **Q. Can you use the techdocs plugin without the techdocs-backend plugin?** @@ -146,21 +141,5 @@ We currently depend on MkDocs to parse doc sites written in Markdown. And we store the generated static assets and re-use it later to render in Backstage. A better (futuristic) approach will be to directly parse whatever type of source files you have in your docs repository and directly render in Backstage in -real-time. - -# Features status - -Status of all the features mentioned above. - -**In place ✅** - -- Basic setup with techdocs-backend file server as storage. -- Basic setup with cloud storage solution. -- `techdocs-cli` is able to generate docs in CI/CD environment. -- `techdocs-cli` is able to publish docs site to any storage. - -**Work in progress 🚧** - -**Not implemented yet ❌** - -- `techdocs-backend` integration with Backstage access control management. +real-time. You can read more in this +[RFC - Simplifying TechDocs Frontend Architecture](https://github.com/backstage/backstage/issues/3998). diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 1580abe69e..20e436d8a4 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -13,15 +13,6 @@ configuration options for TechDocs. # File: app-config.yaml techdocs: - # TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. - - requestUrl: http://localhost:7000/api/techdocs - - # Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware - # to serve files from either a local directory or an External storage provider. - - storageUrl: http://localhost:7000/api/techdocs/static/docs - # generators.techdocs can have two values: 'docker' or 'local'. This is to determine how to run the generator - whether to # spin up the techdocs-container docker image or to run mkdocs locally (assuming all the dependencies are taken care of). # You want to change this to 'local' if you are running Backstage using your own custom Docker setup and want to avoid running @@ -44,7 +35,7 @@ techdocs: # or you want to use External storage providers like Google Cloud Storage, AWS S3, etc. publisher: - # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' (azureStorage to be available in future). + # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' or 'azureBlobStorage'. # When set to 'local', techdocs-backend will create a 'static' directory at its root to store generated documentation files. # When set to 'googleGcs', techdocs-backend will use a Google Cloud Storage Bucket to store generated documentation files. # When set to 'awsS3', techdocs-backend will use an Amazon Web Service (AWS) S3 bucket to store generated documentation files. @@ -84,4 +75,32 @@ techdocs: # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html region: $env: AWS_REGION + + # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. + + azureBlobStorage: + # (Required) Azure Blob Storage Container Name + containerName: 'techdocs-storage' + + # (Required) An account name is required to write to a storage blob container. + # https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME + # (Optional) An account key is required to write to a storage container. + # If missing,AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET environment variable will be used. + # https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + accountKey: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY + + # (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. + # You don't have to specify this anymore. + + requestUrl: http://localhost:7000/api/techdocs + + # (Optional and Legacy) Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware + # to serve files from either a local directory or an External storage provider. + # You don't have to specify this anymore. + + storageUrl: http://localhost:7000/api/techdocs/static/docs ``` diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index 187fe97aed..c15dc1fe69 100644 --- a/docs/features/techdocs/creating-and-publishing.md +++ b/docs/features/techdocs/creating-and-publishing.md @@ -66,9 +66,16 @@ Update your component's entity description by adding the following lines to its ```yaml metadata: annotations: - backstage.io/techdocs-ref: dir:./ + backstage.io/techdocs-ref: url:https://github.com/org/repo + # Or + # backstage.io/techdocs-ref: url:https://github.com/org/repo/tree/branchName/subFolder ``` +The +[`backstage.io/techdocs-ref` annotation](../software-catalog/well-known-annotations.md#backstageiotechdocs-ref) +is used by TechDocs to download the documentation source files for generating an +Entity's TechDocs site. + Create a `/docs` folder in the root of the project with at least an `index.md` file. _(If you add more markdown files, make sure to update the nav in the mkdocs.yml file to get a proper navigation for your documentation.)_ diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index e1ddfd23f9..e0b323857d 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -150,26 +150,10 @@ app. Now let us tweak some configurations to suit your needs. **See [TechDocs Configuration Options](configuration.md) for complete configuration reference.** -### Setting TechDocs URLs - -```yaml -techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ -``` - -`requestUrl` is used by TechDocs frontend plugin to discover techdocs-backend -endpoints, and the `storageUrl` is another endpoint in `techdocs-backend` which -acts as a middleware between TechDocs and the storage (where the static -generated docs site are stored). These default values should mostly work for -you. These options will soon be optional to set. - ### Should TechDocs Backend generate docs? ```yaml techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ builder: 'local' ``` @@ -180,8 +164,8 @@ pipeline, and are being stored in a storage somewhere. When `techdocs.builder` is set to `'external'`, TechDocs becomes more or less a read-only experience where it serves static files from a storage containing all -the generated documentation. Read more in the "Basic" and "Recommended" setup of -TechDocs [here](architecture.md) +the generated documentation. Read more in the "Basic" and "Recommended" sections +of the [TechDocs Architecture](architecture.md). ### Choosing storage (publisher) @@ -190,14 +174,12 @@ fetch the sites from. This is managed by a [Publisher](./concepts.md#techdocs-publisher). Examples: Google Cloud Storage, Amazon S3, or local filesystem of Backstage server. -It is okay to use the local filesystem in a "Basic" setup when you are trying -out Backstage for the first time. Using Cloud Storage is documented -[here](./using-cloud-storage.md). +It is okay to use the local filesystem in a "basic" setup when you are trying +out Backstage for the first time. At a later time, review +[Using Cloud Storage](./using-cloud-storage.md). ```yaml techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ builder: 'local' publisher: type: 'local' @@ -219,6 +201,9 @@ no config is provided. ```yaml techdocs: + builder: 'local' + publisher: + type: 'local' generators: techdocs: local ``` @@ -229,23 +214,23 @@ environment is compatible with techdocs. You will have to install the `mkdocs` and `mkdocs-techdocs-core` package from pip, as well as `graphviz` and `plantuml` from your OS package manager (e.g. apt). See our -[Dockerfile](https://github.com/backstage/techdocs-container/blob/main/Dockerfile) -for the latest requirements. You should be trying to match your Dockerfile with -this one. +[`Dockerfile`](https://github.com/backstage/techdocs-container/blob/main/Dockerfile) +for the latest requirements. You should be trying to match your `Dockerfile` +with this one. Note: We recommend Python version 3.7 or higher. -Caveat: Please install the `mkdocs-techdocs-core` package after all other Python -packages. The order is important to make sure we get correct version of some of -the dependencies. For example, we want `Markdown` version to be -[3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11). -You can also explicitly install `Markdown==3.2.2` after installing all other -Python packages. +> Caveat: Please install the `mkdocs-techdocs-core` package after all other +> Python packages. The order is important to make sure we get correct version of +> some of the dependencies. For example, we want `Markdown` version to be +> [3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11). +> You can also explicitly install `Markdown==3.2.2` after installing all other +> Python packages. ## Running Backstage locally Start the frontend and the backend app by -[running backstage locally](../../getting-started/running-backstage-locally.md). +[running Backstage locally](../../getting-started/running-backstage-locally.md). Open your browser at [http://localhost:3000/docs/](http://localhost:3000/docs/) to see all your documentation sites. diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index b32ff40589..2cb769b0e3 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -5,6 +5,36 @@ sidebar_label: "HOW TO" guides description: TechDocs "HOW TO" guides related to TechDocs --- +## How to migrate from TechDocs Basic to Recommended deployment approach? + +The main difference between TechDocs Basic and Recommended deployment approach +is where the docs are generated and stored. In Basic or the out-of-the-box +setup, docs are generated and stored at the server running your Backstage +instance. But the recommended setup is to generate docs on CI/CD and store the +generated sites to an external storage (e.g. AWS S3 or GCS). TechDocs in your +Backstage instance should turn into read-only mode. Read more details and the +benefits in the [TechDocs Architecture](architecture.md). + +Here are the steps needed to switch from the Basic to Recommended setup - + +### 1. Prepare a cloud storage + +Choose a cloud storage provider like AWS, Google Cloud or Microsoft Azure. +Follow the detailed instructions for +[using cloud storage](using-cloud-storage.md) in TechDocs. + +### 2. Publish to storage from CI/CD + +Start publishing your TechDocs sites from the CI/CD workflow of each repository +containing the source markdown files. Read the detailed instructions for +[configuring CI/CD](configuring-ci-cd.md). + +### 3. Switch TechDocs to read-only mode + +In your Backstage instance's `app-config.yaml`, set `techdocs.builder` from +`'local'` to `'external'`. By doing this, TechDocs will not try to generate +docs. Look at [TechDocs configuration](configuration.md) for reference. + ## How to use URL Reader in TechDocs Prepare step? If TechDocs is configured to generate docs, it will first download the diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 21206dede5..de2d505c87 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -195,3 +195,94 @@ Your Backstage app is now ready to use AWS S3 for TechDocs, to store and read the static generated documentation files. When you start the backend of the app, you should be able to see `techdocs info Successfully connected to the AWS S3 bucket` in the logs. + +## Configuring Azure Blob Storage Container with TechDocs + +Follow the +[official Azure Blob Storage documentation](https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json) +for the latest instructions on the following steps involving Azure Blob Storage. + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'azureBlobStorage'`. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' +``` + +**2. Create an Azure Blob Storage Container** + +Create a dedicated container for TechDocs sites. +[Refer to the official documentation](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-portal). + +TechDocs will publish documentation to this container and will fetch files from +here to serve documentation in Backstage. Note that the container names are +globally unique. + +Set the config `techdocs.publisher.azureBlobStorage.containerName` in your +`app-config.yaml` to the name of the container you just created. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-container' +``` + +**3a. (Recommended) Authentication using environment variable** + +Set the config `techdocs.publisher.azureBlobStorage.credentials.accountName` in +your `app-config.yaml` to the your account name. + +The storage blob client will automatically use the environment variable +`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` to authenticate with +Azure Blob Storage. +[Steps to create the service where the variables can be retrieved from](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal). + +https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad for more +details. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-bucket' + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME +``` + +**3b. Authentication using app-config.yaml** + +If you do not prefer (3a) and optionally like to use a service account, you can +follow these steps. + +To get credentials, access the Azure Portal and go to "Settings > Access Keys", +and get your Storage account name and Primary Key. +https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key +for more details. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-bucket' + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME + accountKey: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY +``` + +**4. That's it!** + +Your Backstage app is now ready to use Azure Blob Storage for TechDocs, to store +and read the static generated documentation files. When you start the backend of +the app, you should be able to see +`techdocs info Successfully connected to the Azure Blob Storage container` in +the logs. diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index b5ff3885b3..01fd7bdc24 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -31,7 +31,7 @@ $ yarn start This should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. -By default, backstage will start on port 3000, however you can override this by +By default, Backstage will start on port 3000, however you can override this by setting an environment variable `PORT` on your local machine. e.g. `export PORT=8080` then running `yarn start`. Or `PORT=8080 yarn start`. diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md index bcb86024fd..4ccd48f17e 100644 --- a/docs/plugins/create-a-plugin.md +++ b/docs/plugins/create-a-plugin.md @@ -9,8 +9,9 @@ A Backstage Plugin adds functionality to Backstage. ## Create a Plugin To create a new plugin, make sure you've run `yarn install` and installed -dependencies, then run the following on your command line (invoking the -`backstage-cli`). +dependencies, then run the following on your command line (a shortcut to +invoking the [`backstage-cli create-plugin`](../cli/commands.md#create-plugin)) +from the root of your project. ```bash yarn create-plugin diff --git a/docs/plugins/integrating-plugin-into-service-catalog.md b/docs/plugins/integrating-plugin-into-service-catalog.md index 51d2331140..01de779635 100644 --- a/docs/plugins/integrating-plugin-into-service-catalog.md +++ b/docs/plugins/integrating-plugin-into-service-catalog.md @@ -59,7 +59,7 @@ accordingly inside `Router.tsx`) ### Import and use router in the APP In the `app/src/components/catalog/EntityPage.tsx` (app === your folder, -containing backstage app) import your created Router: +containing Backstage app) import your created Router: ```tsx import { Router as MyPluginRouter } from '@backstage/plugin-my-plugin; diff --git a/docs/plugins/observability.md b/docs/plugins/observability.md new file mode 100644 index 0000000000..8b6668606f --- /dev/null +++ b/docs/plugins/observability.md @@ -0,0 +1,54 @@ +--- +id: observability +title: Observability +# prettier-ignore +description: Adding Observability to Your Plugin +--- + +This article briefly describes the observability options that are available to a +Backstage integrator. + +## Google Analytics + +There is a basic Google Analytics integration built into Backstage. You can +enable it by adding the following to your app configuration: + +```yaml +app: + googleAnalyticsTrackingId: UA-000000-0 +``` + +Replace the tracking ID with your own. + +For more information, learn about Google Analytics +[here](https://marketingplatform.google.com/about/analytics/). + +## Logging + +The backend supplies a central [winston](https://github.com/winstonjs/winston) +root logger that plugins are expected to use for their logging needs. In the +default production setup, it emits structured JSON logs on stdout, with a field +`"service": "backstage"` and also tagged on a per-plugin basis. Plugins that +want to more finely specify what part of their processes that emitted the log +message should add a `"component"` field to do so. + +An example log line could look as follows: + +```json +{ + "service": "backstage", + "type": "plugin", + "plugin": "catalog", + "component": "catalog-all-locations-refresh", + "level": "info", + "message": "Locations Refresh: Refreshing location bootstrap:bootstrap" +} +``` + +## Health Checks + +The example backend in the Backstage repository +[supplies](https://github.com/backstage/backstage/blob/bc18571b7a742863a770b2a54e785d6bbef7e184/packages/backend/src/index.ts#L99) +a very basic health check endpoint on the `/healthcheck` route. You may add such +a handler to your backend as well, and supply your own logic to it that fits +your particular health checking needs. diff --git a/docs/plugins/plugin-development.md b/docs/plugins/plugin-development.md index 109f6ed6ba..bfeed58c8f 100644 --- a/docs/plugins/plugin-development.md +++ b/docs/plugins/plugin-development.md @@ -23,35 +23,60 @@ browser APIs or by depending on external modules to do the work. ### Routing -Each plugin is responsible for registering its components to corresponding -routes in the app. +Each plugin can export routable extensions, which are then imported into the app +and mounted at a path. -The app will call the `createPlugin` method on each plugin, passing in a -`router` object with a set of methods on it. +First you will need a `RouteRef` instance to serve as the mount point of your +extensions. This can be used within your own plugin to create a link to the +extension page using `useRouteRef`, as well as for other plugins to link to your +extension. -```jsx +It is best to place these in a separate top-level `src/routes.ts` file, in order +to avoid import cycles, for example like this: + +```tsx +/* src/routes.ts */ +import { createRouteRef } from '@backstage/core'; + +// Note: This route ref is for internal use only, don't export it from the plugin +export const rootRouteRef = createRouteRef({ + title: 'Example Page', +}); +``` + +Now that we have a `RouteRef`, we import it into `src/plugin.ts`, create our +plugin instance with `createPlugin`, as well as create and wrap our routable +extension using `createRoutableExtension` from `@backstage/core`: + +```tsx +/* src/plugin.ts */ import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; -export const rootRouteRef = createRouteRef({ - path: '/new-plugin', - title: 'New plugin', -}); - -export const plugin = createPlugin({ - id: 'new-plugin', - register({ router }) { - router.addRoute(rootRouteRef, ExampleComponent); +// Create a plugin instance and export this from your plugin package +export const examplePlugin = createPlugin({ + id: 'example', + routes: { + root: rootRouteRef, // This is where the route ref should be exported for usage in the app }, }); + +// This creates a routable extension, which are typically full pages of content. +// Each extension should also be exported from your plugin package. +export const ExamplePage = examplePlugin.provide( + createRoutableExtension({ + // The component needs to be lazy-loaded. It's what will actually be rendered in the end. + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + // This binds the extension to this route ref, which allows for routing within and across plugin extensions + mountPoint: rootRouteRef, + }), +); ``` -#### `router` API +This extension can then be imported and used in the app as follow, typically +placed within the top-level ``: -```typescript -addRoute( - target: RouteRef, - Component: ComponentType, - options?: RouteOptions, -): void; +```tsx +} /> ``` diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index 3f631a9a46..2aacd02cf2 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -28,6 +28,7 @@ new-plugin/ index.ts plugin.test.ts plugin.ts + routes.ts jest.config.js jest.setup.ts package.json @@ -56,26 +57,30 @@ package.json to declare the plugin dependencies, metadata and scripts. In the `src` folder we get to the interesting bits. Check out the `plugin.ts`: ```jsx -import { createPlugin, createRouteRef } from '@backstage/core'; -import ExampleComponent from './components/ExampleComponent'; +import { createPlugin, createRoutableExtension } from '@backstage/core'; -export const rootRouteRef = createRouteRef({ - path: '/new-plugin', - title: 'New plugin', -}); +import { rootRouteRef } from './routes'; -export const plugin = createPlugin({ - id: 'new-plugin', - register({ router }) { - router.addRoute(rootRouteRef, ExampleComponent); +export const examplePlugin = createPlugin({ + id: 'example', + routes: { + root: rootRouteRef, }, }); + +export const ExamplePage = examplePlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + mountPoint: rootRouteRef, + }), +); ``` -This is where the plugin is created and where it hooks into the app by declaring -what component should be shown on what URL. See reference docs for -[createPlugin](../reference/createPlugin.md) or -[router](../reference/createPlugin-router.md). +This is where the plugin is created and where it creates and exports extensions +that can be imported and used the app. See reference docs for +[createPlugin](../reference/createPlugin.md) or introduction to the new +[Composability System](./composability.md). ## Components @@ -91,12 +96,15 @@ You may tweak these components, rename them and/or replace them completely. ## Connecting the plugin to the Backstage app -There are two things needed for a Backstage app to start making use of a plugin. +There are three things needed for a Backstage app to start making use of a +plugin. 1. Add plugin as dependency in `app/package.json` 2. `import` plugin in `app/src/plugins.ts` +3. Import and use one or more plugin extensions, for example in + `app/src/App.tsx`. -Luckily these two steps happen automatically when you create a plugin with the +Luckily these three steps happen automatically when you create a plugin with the Backstage CLI. ## Talking to the outside world diff --git a/docs/reference/createPlugin-router.md b/docs/reference/createPlugin-router.md deleted file mode 100644 index 0ef5bdbd0f..0000000000 --- a/docs/reference/createPlugin-router.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -id: createPlugin-router -title: createPlugin - router -description: Documentation on createPlugin - router ---- - -The router that is passed to the `register` function makes it possible for -plugins to hook into routing of the Backstage app and provide the end users with -new views to navigate to. This is done by utilising the following methods on the -`router`: - -```typescript -addRoute( - target: RouteRef, - Component: ComponentType, - options?: RouteOptions, -): void; -``` - -## RouteRef - -`addRoute` method is using mutable RouteRefs, which can be created as following: - -```ts -import { createRouteRef } from '@backstage/core'; - -const myPluginRouteRef = createRouteRef({ - path: '/my-plugin', - title: 'My Plugin', -}); -``` diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md index 4a58a5ecdc..e3df66fa12 100644 --- a/docs/reference/createPlugin.md +++ b/docs/reference/createPlugin.md @@ -17,32 +17,24 @@ type PluginConfig = { }; type PluginHooks = { - router: RouterHooks; + featureFlags: FeatureFlagsHooks; }; ``` -- [Read more about the router here](createPlugin-router.md) - [Read more about feature flags here](createPlugin-feature-flags.md) ## Example Uses ### Creating a basic plugin -Showcasing adding a route and a feature flag. +Showcasing adding a feature flag. ```jsx -import { createPlugin, createRouteRef } from '@backstage/core'; -import ExampleComponent from './components/ExampleComponent'; - -export const rootRouteRef = createRouteRef({ - path: '/new-plugin', - title: 'New Plugin', -}); +import { createPlugin } from '@backstage/core'; export default createPlugin({ id: 'new-plugin', register({ router, featureFlags }) { - router.addRoute(rootRouteRef, ExampleComponent); featureFlags.register('enable-example-component'); }, }); diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md index dafdb7d55f..69fe5d26ba 100644 --- a/docs/reference/utility-apis/BackstageIdentityApi.md +++ b/docs/reference/utility-apis/BackstageIdentityApi.md @@ -79,7 +79,7 @@ Referenced by: [getBackstageIdentity](#getbackstageidentity).
 export type BackstageIdentity = {
   /**
-   * The backstage user ID.
+   * The Backstage user ID.
    */
   id: string;
 
diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md
index 1c661d274c..d322e2ec3c 100644
--- a/docs/tutorials/quickstart-app-auth.md
+++ b/docs/tutorials/quickstart-app-auth.md
@@ -349,22 +349,18 @@ const app = createApp({
   apis,
   plugins: Object.values(plugins),
   components: {
-    SignInPage: props => {
-      return (
-        
-      );
-    },
+    SignInPage: props => (
+      
+    ),
   },
 });
 ```
diff --git a/microsite/sidebars.json b/microsite/sidebars.json
index 99bd430b56..ebce8d900c 100644
--- a/microsite/sidebars.json
+++ b/microsite/sidebars.json
@@ -13,7 +13,6 @@
     "Getting Started": [
       "getting-started/index",
       "getting-started/running-backstage-locally",
-      "getting-started/installation",
       "getting-started/development-environment",
       "getting-started/create-an-app",
       {
@@ -130,7 +129,8 @@
         "ids": [
           "plugins/publishing",
           "plugins/publish-private",
-          "plugins/add-to-marketplace"
+          "plugins/add-to-marketplace",
+          "plugins/observability"
         ]
       }
     ],
diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js
index 8d0c4ea57c..51354343d8 100644
--- a/microsite/siteConfig.js
+++ b/microsite/siteConfig.js
@@ -83,6 +83,7 @@ const siteConfig = {
     'https://unpkg.com/medium-zoom@1.0.6/dist/medium-zoom.min.js',
     '/js/medium-zoom.js',
     '/js/dismissable-banner.js',
+    '/js/scroll-nav-to-view-in-docs.js',
   ],
 
   // On page navigation for the current documentation page.
diff --git a/microsite/static/js/scroll-nav-to-view-in-docs.js b/microsite/static/js/scroll-nav-to-view-in-docs.js
new file mode 100644
index 0000000000..0e4da26674
--- /dev/null
+++ b/microsite/static/js/scroll-nav-to-view-in-docs.js
@@ -0,0 +1,23 @@
+// On backstage.io/docs pages, scroll the Nav sidebar to focus on
+// the page being viewed. Helpful when the Nav is large enough that
+// the selected page is hidden somewhere at bottom.
+// Credits: https://github.com/facebook/docusaurus/issues/823#issuecomment-421152269
+document.addEventListener('DOMContentLoaded', () => {
+  // Find the active nav item in the sidebar
+  const item = document.getElementsByClassName('navListItemActive')[0];
+  if (!item) {
+    return;
+  }
+  const bounding = item.getBoundingClientRect();
+  if (
+    bounding.top >= 0 &&
+    bounding.bottom <=
+      (window.innerHeight || document.documentElement.clientHeight)
+  ) {
+    // Already visible.  Do nothing.
+  } else {
+    // Not visible.  Scroll sidebar.
+    item.scrollIntoView({ block: 'start', inline: 'nearest' });
+    document.body.scrollTop = document.documentElement.scrollTop = 0;
+  }
+});
diff --git a/mkdocs.yml b/mkdocs.yml
index 6236d1c1a0..a303fd9978 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -80,6 +80,7 @@ nav:
       - Publishing:
           - Open source and npm: 'plugins/publishing.md'
           - Private/internal (non-open source): 'plugins/publish-private.md'
+          - Observability: 'plugins/observability.md'
   - Configuration:
       - Overview: 'conf/index.md'
       - Reading Configuration: 'conf/reading.md'
diff --git a/package.json b/package.json
index 758a8ba884..fd130fcbbd 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
   },
   "version": "1.0.0",
   "devDependencies": {
-    "@changesets/cli": "^2.11.0",
+    "@changesets/cli": "^2.14.0",
     "@octokit/openapi-types": "^2.2.0",
     "@spotify/eslint-config-oss": "^1.0.1",
     "@spotify/prettier-config": "^9.0.0",
diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md
index d6157f560f..d39f705546 100644
--- a/packages/app/CHANGELOG.md
+++ b/packages/app/CHANGELOG.md
@@ -1,5 +1,26 @@
 # example-app
 
+## 0.2.13
+
+### Patch Changes
+
+- Updated dependencies [681111228]
+- Updated dependencies [12a56cdfe]
+- Updated dependencies [8b7ef9f8b]
+- Updated dependencies [fac91bcc5]
+- Updated dependencies [9dd057662]
+- Updated dependencies [234e7d985]
+- Updated dependencies [ef7957be4]
+- Updated dependencies [0b1182346]
+- Updated dependencies [a6e3b9596]
+  - @backstage/plugin-kubernetes@0.3.7
+  - @backstage/cli@0.5.0
+  - @backstage/plugin-cost-insights@0.6.0
+  - @backstage/plugin-catalog@0.2.14
+  - @backstage/plugin-catalog-import@0.3.6
+  - @backstage/plugin-scaffolder@0.4.1
+  - @backstage/plugin-kafka@0.2.0
+
 ## 0.2.12
 
 ### Patch Changes
diff --git a/packages/app/package.json b/packages/app/package.json
index 7734dfc244..9de8299d93 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,18 +1,19 @@
 {
   "name": "example-app",
-  "version": "0.2.12",
+  "version": "0.2.13",
   "private": true,
   "bundled": true,
   "dependencies": {
     "@backstage/catalog-model": "^0.7.0",
-    "@backstage/cli": "^0.4.7",
+    "@backstage/cli": "^0.5.0",
     "@backstage/core": "^0.5.0",
     "@backstage/plugin-api-docs": "^0.4.3",
-    "@backstage/plugin-catalog": "^0.2.12",
-    "@backstage/plugin-catalog-import": "^0.3.5",
+    "@backstage/plugin-catalog": "^0.2.14",
+    "@backstage/plugin-catalog-react": "^0.0.1",
+    "@backstage/plugin-catalog-import": "^0.3.6",
     "@backstage/plugin-circleci": "^0.2.6",
     "@backstage/plugin-cloudbuild": "^0.2.7",
-    "@backstage/plugin-cost-insights": "^0.5.7",
+    "@backstage/plugin-cost-insights": "^0.7.0",
     "@backstage/plugin-explore": "^0.2.3",
     "@backstage/plugin-gcp-projects": "^0.2.3",
     "@backstage/plugin-github-actions": "^0.3.0",
@@ -20,14 +21,14 @@
     "@backstage/plugin-graphiql": "^0.2.6",
     "@backstage/plugin-org": "^0.3.4",
     "@backstage/plugin-jenkins": "^0.3.6",
-    "@backstage/plugin-kafka": "^0.1.1",
-    "@backstage/plugin-kubernetes": "^0.3.6",
+    "@backstage/plugin-kafka": "^0.2.0",
+    "@backstage/plugin-kubernetes": "^0.3.7",
     "@backstage/plugin-lighthouse": "^0.2.8",
     "@backstage/plugin-newrelic": "^0.2.3",
     "@backstage/plugin-pagerduty": "0.2.6",
     "@backstage/plugin-register-component": "^0.2.7",
     "@backstage/plugin-rollbar": "^0.2.8",
-    "@backstage/plugin-scaffolder": "^0.4.0",
+    "@backstage/plugin-scaffolder": "^0.4.1",
     "@backstage/plugin-sentry": "^0.3.3",
     "@backstage/plugin-search": "^0.2.6",
     "@backstage/plugin-tech-radar": "^0.3.3",
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 1b76773979..00696736d1 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -15,30 +15,30 @@
  */
 
 import {
-  createApp,
   AlertDisplay,
-  OAuthRequestDialog,
-  SignInPage,
+  createApp,
   createRouteRef,
   FlatRoutes,
+  OAuthRequestDialog,
+  SignInPage,
 } from '@backstage/core';
-import React from 'react';
-import Root from './components/Root';
-import * as plugins from './plugins';
-import { apis } from './apis';
-import { hot } from 'react-hot-loader/root';
-import { providers } from './identityProviders';
 import { Router as CatalogRouter } from '@backstage/plugin-catalog';
-import { Router as DocsRouter } from '@backstage/plugin-techdocs';
+import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
+import { ExplorePage } from '@backstage/plugin-explore';
 import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql';
-import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
 import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
 import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
+import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
+import { Router as DocsRouter } from '@backstage/plugin-techdocs';
 import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
-import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
-import { Route, Navigate } from 'react-router';
-
+import React from 'react';
+import { hot } from 'react-hot-loader/root';
+import { Navigate, Route } from 'react-router';
+import { apis } from './apis';
 import { EntityPage } from './components/catalog/EntityPage';
+import Root from './components/Root';
+import { providers } from './identityProviders';
+import * as plugins from './plugins';
 
 const app = createApp({
   apis,
@@ -78,6 +78,7 @@ const routes = (
       element={}
     />
     } />
+    } />
     }
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index 52dd397418..80706fe459 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -20,6 +20,7 @@ import HomeIcon from '@material-ui/icons/Home';
 import ExtensionIcon from '@material-ui/icons/Extension';
 import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
 import MapIcon from '@material-ui/icons/MyLocation';
+import LayersIcon from '@material-ui/icons/Layers';
 import LibraryBooks from '@material-ui/icons/LibraryBooks';
 import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
 import MoneyIcon from '@material-ui/icons/MonetizationOn';
@@ -82,6 +83,7 @@ const Root = ({ children }: PropsWithChildren<{}>) => (
       
       
       
+      
       
       {/* End global nav */}
       
diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx
index a1ab181211..5474afa72c 100644
--- a/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/app/src/components/catalog/EntityPage.tsx
@@ -13,6 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 import {
   ApiEntity,
   Entity,
@@ -27,11 +28,8 @@ import {
   ProvidedApisCard,
   ProvidingComponentsCard,
 } from '@backstage/plugin-api-docs';
-import {
-  AboutCard,
-  EntityPageLayout,
-  useEntity,
-} from '@backstage/plugin-catalog';
+import { AboutCard, EntityPageLayout } from '@backstage/plugin-catalog';
+import { useEntity } from '@backstage/plugin-catalog-react';
 import {
   isPluginApplicableToEntity as isCircleCIAvailable,
   Router as CircleCIRouter,
@@ -50,6 +48,7 @@ import {
   LatestRunCard as JenkinsLatestRunCard,
   Router as JenkinsRouter,
 } from '@backstage/plugin-jenkins';
+import { Router as KafkaRouter } from '@backstage/plugin-kafka';
 import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes';
 import {
   EmbeddedRouter as LighthouseRouter,
@@ -57,13 +56,20 @@ import {
   LastLighthouseAuditCard,
 } from '@backstage/plugin-lighthouse';
 import {
-  OwnershipCard,
-  MembersListCard,
   GroupProfileCard,
+  MembersListCard,
+  OwnershipCard,
   UserProfileCard,
 } from '@backstage/plugin-org';
+import {
+  isPluginApplicableToEntity as isPagerDutyAvailable,
+  PagerDutyCard,
+} from '@backstage/plugin-pagerduty';
+import {
+  isRollbarAvailable,
+  Router as RollbarRouter,
+} from '@backstage/plugin-rollbar';
 import { Router as SentryRouter } from '@backstage/plugin-sentry';
-import { Router as KafkaRouter } from '@backstage/plugin-kafka';
 import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
 import { Button, Grid } from '@material-ui/core';
 import {
@@ -82,10 +88,6 @@ import {
   PullRequestsStatsCard,
   Router as PullRequestsRouter,
 } from '@roadiehq/backstage-plugin-github-pull-requests';
-import {
-  isPluginApplicableToEntity as isPagerDutyAvailable,
-  PagerDutyCard,
-} from '@backstage/plugin-pagerduty';
 import {
   isPluginApplicableToEntity as isTravisCIAvailable,
   RecentTravisCIBuildsWidget,
@@ -156,6 +158,15 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
   );
 };
 
+export const ErrorsSwitcher = ({ entity }: { entity: Entity }) => {
+  switch (true) {
+    case isRollbarAvailable(entity):
+      return ;
+    default:
+      return ;
+  }
+};
+
 const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
   
     
@@ -215,9 +226,9 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
       element={}
     />
     }
+      path="/errors/*"
+      title="Errors"
+      element={}
     />
      (
       element={}
     />
     }
+      path="/errors/*"
+      title="Errors"
+      element={}
     />
      {
   it('can replace the default logger', () => {
@@ -29,4 +29,70 @@ describe('rootLogger', () => {
       expect.stringContaining('testing'),
     );
   });
+
+  describe('createRootLoger', () => {
+    it('creates a new logger', () => {
+      const oldLogger = getRootLogger();
+      const newLogger = createRootLogger();
+
+      expect(oldLogger).not.toBe(newLogger);
+    });
+
+    it('replaces the existing root logger', () => {
+      const oldLogger = getRootLogger();
+      createRootLogger();
+      const newLogger = getRootLogger();
+      expect(oldLogger).not.toBe(newLogger);
+    });
+
+    it('can append additional default metadata', () => {
+      const format = winston.format.json();
+      const logger = createRootLogger({
+        format,
+        defaultMeta: {
+          appName: 'backstage',
+          appEnv: 'prod',
+          containerId: 'abc',
+        },
+      });
+      jest.spyOn(format, 'transform');
+
+      logger.info('testing');
+
+      expect(format.transform).toHaveBeenCalledWith(
+        expect.objectContaining({
+          message: 'testing',
+          service: 'backstage',
+          appName: 'backstage',
+          appEnv: 'prod',
+          containerId: 'abc',
+        }),
+        {},
+      );
+    });
+
+    it('can add override existing transports', () => {
+      const transport = new winston.transports.Console({ level: 'debug' });
+      const logger = createRootLogger({ transports: [transport] });
+      expect(logger.transports.length).toBe(1);
+      expect(logger.transports[0]).toBe(transport);
+    });
+
+    it('can append an additional transport', () => {
+      const logger = createRootLogger();
+      const transport = new winston.transports.Console({ level: 'debug' });
+      logger.add(transport);
+      expect(logger.transports.length).toBe(2);
+      expect(logger.transports[1]).toBe(transport);
+      expect(logger.transports[1].level).toBe('debug');
+    });
+
+    it('can override default format', () => {
+      const format = winston.format(() => false)();
+      const logger = createRootLogger({ format });
+      expect(
+        logger.format.transform({ message: 'hello', level: 'info' }),
+      ).toBeFalsy();
+    });
+  });
 });
diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts
index 306ed23444..a8cf4721d6 100644
--- a/packages/backend-common/src/logging/rootLogger.ts
+++ b/packages/backend-common/src/logging/rootLogger.ts
@@ -13,23 +13,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
+import { merge } from 'lodash';
 import * as winston from 'winston';
+import { LoggerOptions } from 'winston';
 import { coloredFormat } from './formats';
 
-let rootLogger: winston.Logger = winston.createLogger({
-  level: process.env.LOG_LEVEL || 'info',
-  format:
-    process.env.NODE_ENV === 'production'
-      ? winston.format.json()
-      : coloredFormat,
-  defaultMeta: { service: 'backstage' },
-  transports: [
-    new winston.transports.Console({
-      silent:
-        process.env.JEST_WORKER_ID !== undefined && !process.env.LOG_LEVEL,
-    }),
-  ],
-});
+let rootLogger: winston.Logger;
 
 export function getRootLogger(): winston.Logger {
   return rootLogger;
@@ -38,3 +28,34 @@ export function getRootLogger(): winston.Logger {
 export function setRootLogger(newLogger: winston.Logger) {
   rootLogger = newLogger;
 }
+
+export function createRootLogger(
+  options: winston.LoggerOptions = {},
+  env = process.env,
+): winston.Logger {
+  const logger = winston.createLogger(
+    merge(
+      {
+        level: env.LOG_LEVEL || 'info',
+        format: winston.format.combine(
+          env.NODE_ENV === 'production' ? winston.format.json() : coloredFormat,
+        ),
+        defaultMeta: {
+          service: 'backstage',
+        },
+        transports: [
+          new winston.transports.Console({
+            silent: env.JEST_WORKER_ID !== undefined && !env.LOG_LEVEL,
+          }),
+        ],
+      },
+      options,
+    ),
+  );
+
+  setRootLogger(logger);
+
+  return logger;
+}
+
+rootLogger = createRootLogger();
diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts
index 20f8feba42..c937f8a40c 100644
--- a/packages/backend-common/src/reading/AzureUrlReader.test.ts
+++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts
@@ -14,7 +14,9 @@
  * limitations under the License.
  */
 
-import fs from 'fs';
+import * as os from 'os';
+import fs from 'fs-extra';
+import mockFs from 'mock-fs';
 import path from 'path';
 import { rest } from 'msw';
 import { setupServer } from 'msw/node';
@@ -31,6 +33,8 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
   config: new ConfigReader({}),
 });
 
+const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
+
 describe('AzureUrlReader', () => {
   const worker = setupServer();
   msw.setupDefaultHandlers(worker);
@@ -139,6 +143,16 @@ describe('AzureUrlReader', () => {
   });
 
   describe('readTree', () => {
+    beforeEach(() => {
+      mockFs({
+        [tmpDir]: mockFs.directory(),
+      });
+    });
+
+    afterEach(() => {
+      mockFs.restore();
+    });
+
     const repoBuffer = fs.readFileSync(
       path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'),
     );
@@ -200,6 +214,21 @@ describe('AzureUrlReader', () => {
       expect(indexMarkdownFile.toString()).toBe('# Test\n');
     });
 
+    it('creates a directory with the wanted files', async () => {
+      const response = await processor.readTree(
+        'https://dev.azure.com/organization/project/_git/repository',
+      );
+
+      const dir = await response.dir({ targetDir: tmpDir });
+
+      await expect(
+        fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
+      ).resolves.toBe('site_name: Test\n');
+      await expect(
+        fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
+      ).resolves.toBe('# Test\n');
+    });
+
     it('throws a NotModifiedError when given a etag in options', async () => {
       const fnAzure = async () => {
         await processor.readTree(
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
index 9661368b5e..974c84b2c2 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts
@@ -16,7 +16,8 @@
 
 import { ConfigReader } from '@backstage/config';
 import { msw } from '@backstage/test-utils';
-import fs from 'fs';
+import fs from 'fs-extra';
+import mockFs from 'mock-fs';
 import { rest } from 'msw';
 import { setupServer } from 'msw/node';
 import path from 'path';
@@ -53,6 +54,16 @@ describe('BitbucketUrlReader', () => {
   });
 
   describe('readTree', () => {
+    beforeEach(() => {
+      mockFs({
+        '/tmp': mockFs.directory(),
+      });
+    });
+
+    afterEach(() => {
+      mockFs.restore();
+    });
+
     const worker = setupServer();
     msw.setupDefaultHandlers(worker);
 
@@ -126,12 +137,12 @@ describe('BitbucketUrlReader', () => {
             ),
         ),
         rest.get(
-          'https://api.bitbucket.mycompany.net/rest/api/1.0/repositories/backstage/mock/commits/some-branch',
+          'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
           (_, res, ctx) =>
             res(
               ctx.status(200),
               ctx.json({
-                values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
+                values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
               }),
             ),
         ),
@@ -155,6 +166,21 @@ describe('BitbucketUrlReader', () => {
       expect(mkDocsFile.toString()).toBe('site_name: Test\n');
     });
 
+    it('creates a directory with the wanted files', async () => {
+      const response = await bitbucketProcessor.readTree(
+        'https://bitbucket.org/backstage/mock',
+      );
+
+      const dir = await response.dir({ targetDir: '/tmp' });
+
+      await expect(
+        fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
+      ).resolves.toBe('site_name: Test\n');
+      await expect(
+        fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
+      ).resolves.toBe('# Test\n');
+    });
+
     it('uses private bitbucket host', async () => {
       const response = await hostedBitbucketProcessor.readTree(
         'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch',
@@ -185,6 +211,18 @@ describe('BitbucketUrlReader', () => {
       expect(indexMarkdownFile.toString()).toBe('# Test\n');
     });
 
+    it('creates a directory with the wanted files with a subpath', async () => {
+      const response = await bitbucketProcessor.readTree(
+        'https://bitbucket.org/backstage/mock/src/master/docs',
+      );
+
+      const dir = await response.dir({ targetDir: '/tmp' });
+
+      await expect(
+        fs.readFile(path.join(dir, 'index.md'), 'utf8'),
+      ).resolves.toBe('# Test\n');
+    });
+
     it('throws a NotModifiedError when given a etag in options', async () => {
       const fnBitbucket = async () => {
         await bitbucketProcessor.readTree(
@@ -204,5 +242,17 @@ describe('BitbucketUrlReader', () => {
 
       expect(response.etag).toBe('12ab34cd56ef');
     });
+
+    it('should throw error when apiBaseUrl is missing', () => {
+      expect(() => {
+        /* eslint-disable no-new */
+        new BitbucketUrlReader(
+          {
+            host: 'bitbucket.mycompany.net',
+          },
+          { treeResponseFactory },
+        );
+      }).toThrowError('must configure an explicit apiBaseUrl');
+    });
   });
 });
diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts
index e9727e04cf..373c75588e 100644
--- a/packages/backend-common/src/reading/BitbucketUrlReader.ts
+++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts
@@ -121,31 +121,9 @@ export class BitbucketUrlReader implements UrlReader {
       throw new Error(message);
     }
 
-    // Get the filename of archive from the header of the response
-    const contentDispositionHeader = archiveBitbucketResponse.headers.get(
-      'content-disposition',
-    ) as string;
-    if (!contentDispositionHeader) {
-      throw new Error(
-        `Failed to read tree from ${url}. ` +
-          'Bitbucket API response for downloading archive does not contain content-disposition header ',
-      );
-    }
-    const fileNameRegEx = new RegExp(
-      /^attachment; filename=(?.*).zip$/,
-    );
-    const archiveFileName = contentDispositionHeader.match(fileNameRegEx)
-      ?.groups?.fileName;
-    if (!archiveFileName) {
-      throw new Error(
-        `Failed to read tree from ${url}. Bitbucket API response for downloading archive has an unexpected ` +
-          `format of content-disposition header ${contentDispositionHeader} `,
-      );
-    }
-
     return await this.treeResponseFactory.fromZipArchive({
       stream: (archiveBitbucketResponse.body as unknown) as Readable,
-      path: `${archiveFileName}/${filepath}`,
+      subpath: filepath,
       etag: lastCommitShortHash,
       filter: options?.filter,
     });
@@ -161,13 +139,18 @@ export class BitbucketUrlReader implements UrlReader {
   }
 
   private async getLastCommitShortHash(url: string): Promise {
-    const { name: repoName, owner: project, ref } = parseGitUrl(url);
+    const { resource, name: repoName, owner: project, ref } = parseGitUrl(url);
 
     let branch = ref;
     if (!branch) {
       branch = await getBitbucketDefaultBranch(url, this.config);
     }
-    const commitsApiUrl = `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`;
+
+    const isHosted = resource === 'bitbucket.org';
+    // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222
+    const commitsApiUrl = isHosted
+      ? `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`
+      : `${this.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`;
 
     const commitsResponse = await fetch(
       commitsApiUrl,
@@ -182,14 +165,26 @@ export class BitbucketUrlReader implements UrlReader {
     }
 
     const commits = await commitsResponse.json();
-    if (
-      commits &&
-      commits.values &&
-      commits.values.length > 0 &&
-      commits.values[0].hash
-    ) {
-      return commits.values[0].hash.substring(0, 12);
+    if (isHosted) {
+      if (
+        commits &&
+        commits.values &&
+        commits.values.length > 0 &&
+        commits.values[0].hash
+      ) {
+        return commits.values[0].hash.substring(0, 12);
+      }
+    } else {
+      if (
+        commits &&
+        commits.values &&
+        commits.values.length > 0 &&
+        commits.values[0].id
+      ) {
+        return commits.values[0].id.substring(0, 12);
+      }
     }
+
     throw new Error(`Failed to read response from ${commitsApiUrl}`);
   }
 }
diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts
index 080e8b1d5b..fae3f3ba12 100644
--- a/packages/backend-common/src/reading/GithubUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts
@@ -17,7 +17,8 @@
 import { ConfigReader } from '@backstage/config';
 import { GithubCredentialsProvider } from '@backstage/integration';
 import { msw } from '@backstage/test-utils';
-import fs from 'fs';
+import fs from 'fs-extra';
+import mockFs from 'mock-fs';
 import { rest } from 'msw';
 import { setupServer } from 'msw/node';
 import path from 'path';
@@ -107,6 +108,16 @@ describe('GithubUrlReader', () => {
   });
 
   describe('readTree', () => {
+    beforeEach(() => {
+      mockFs({
+        '/tmp': mockFs.directory(),
+      });
+    });
+
+    afterEach(() => {
+      mockFs.restore();
+    });
+
     const repoBuffer = fs.readFileSync(
       path.resolve(
         'src',
@@ -227,6 +238,21 @@ describe('GithubUrlReader', () => {
       expect(indexMarkdownFile.toString()).toBe('# Test\n');
     });
 
+    it('creates a directory with the wanted files', async () => {
+      const response = await githubProcessor.readTree(
+        'https://github.com/backstage/mock',
+      );
+
+      const dir = await response.dir({ targetDir: '/tmp' });
+
+      await expect(
+        fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
+      ).resolves.toBe('site_name: Test\n');
+      await expect(
+        fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
+      ).resolves.toBe('# Test\n');
+    });
+
     it('should use the headers from the credentials provider to the fetch request', async () => {
       expect.assertions(2);
 
@@ -293,6 +319,18 @@ describe('GithubUrlReader', () => {
       expect(indexMarkdownFile.toString()).toBe('# Test\n');
     });
 
+    it('creates a directory with the wanted files with subpath', async () => {
+      const response = await githubProcessor.readTree(
+        'https://github.com/backstage/mock/tree/main/docs',
+      );
+
+      const dir = await response.dir({ targetDir: '/tmp' });
+
+      await expect(
+        fs.readFile(path.join(dir, 'index.md'), 'utf8'),
+      ).resolves.toBe('# Test\n');
+    });
+
     it('throws a NotModifiedError when given a etag in options', async () => {
       const fnGithub = async () => {
         await githubProcessor.readTree('https://github.com/backstage/mock', {
@@ -338,5 +376,20 @@ describe('GithubUrlReader', () => {
       };
       await expect(fnGithub).rejects.toThrow(NotFoundError);
     });
+
+    it('should throw error when apiBaseUrl is missing', () => {
+      expect(() => {
+        /* eslint-disable no-new */
+        new GithubUrlReader(
+          {
+            host: 'ghe.mycompany.net',
+          },
+          {
+            treeResponseFactory,
+            credentialsProvider: mockCredentialsProvider,
+          },
+        );
+      }).toThrowError('must configure an explicit apiBaseUrl');
+    });
   });
 });
diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts
index 6c7cefe2ef..4f1aa7c07b 100644
--- a/packages/backend-common/src/reading/GithubUrlReader.ts
+++ b/packages/backend-common/src/reading/GithubUrlReader.ts
@@ -166,37 +166,11 @@ export class GithubUrlReader implements UrlReader {
       throw new Error(message);
     }
 
-    // Get the filename of archive from the header of the response
-    const contentDispositionHeader = archive.headers.get(
-      'content-disposition',
-    ) as string;
-    if (!contentDispositionHeader) {
-      throw new Error(
-        `Failed to read tree from ${url}. ` +
-          'GitHub API response for downloading archive does not contain content-disposition header ',
-      );
-    }
-    const fileNameRegEx = new RegExp(
-      /^attachment; filename=(?.*).tar.gz$/,
-    );
-    const archiveFileName = contentDispositionHeader.match(fileNameRegEx)
-      ?.groups?.fileName;
-    if (!archiveFileName) {
-      throw new Error(
-        `Failed to read tree from ${url}. GitHub API response for downloading archive has an unexpected ` +
-          `format of content-disposition header ${contentDispositionHeader} `,
-      );
-    }
-
-    // The path includes the name of the directory inside the tarball and a sub path
-    // if requested in readTree.
-    const path = `${archiveFileName}/${filepath}`;
-
     return await this.deps.treeResponseFactory.fromTarArchive({
       // TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want
       //               to stick to using that in exclusively backend code.
       stream: (archive.body as unknown) as Readable,
-      path,
+      subpath: filepath,
       etag: commitSha,
       filter: options?.filter,
     });
diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
index c0736f769d..b18e7a4294 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts
@@ -16,7 +16,8 @@
 
 import { ConfigReader } from '@backstage/config';
 import { msw } from '@backstage/test-utils';
-import fs from 'fs';
+import fs from 'fs-extra';
+import mockFs from 'mock-fs';
 import { rest } from 'msw';
 import { setupServer } from 'msw/node';
 import path from 'path';
@@ -153,6 +154,16 @@ describe('GitlabUrlReader', () => {
   });
 
   describe('readTree', () => {
+    beforeEach(() => {
+      mockFs({
+        '/tmp': mockFs.directory(),
+      });
+    });
+
+    afterEach(() => {
+      mockFs.restore();
+    });
+
     const archiveBuffer = fs.readFileSync(
       path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'),
     );
@@ -254,6 +265,21 @@ describe('GitlabUrlReader', () => {
       expect(indexMarkdownFile.toString()).toBe('# Test\n');
     });
 
+    it('creates a directory with the wanted files', async () => {
+      const response = await gitlabProcessor.readTree(
+        'https://gitlab.com/backstage/mock',
+      );
+
+      const dir = await response.dir({ targetDir: '/tmp' });
+
+      await expect(
+        fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
+      ).resolves.toBe('site_name: Test\n');
+      await expect(
+        fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
+      ).resolves.toBe('# Test\n');
+    });
+
     it('returns the wanted files from hosted gitlab', async () => {
       worker.use(
         rest.get(
@@ -296,6 +322,18 @@ describe('GitlabUrlReader', () => {
       expect(indexMarkdownFile.toString()).toBe('# Test\n');
     });
 
+    it('creates a directory with the wanted files with subpath', async () => {
+      const response = await gitlabProcessor.readTree(
+        'https://gitlab.com/backstage/mock/tree/main/docs',
+      );
+
+      const dir = await response.dir({ targetDir: '/tmp' });
+
+      await expect(
+        fs.readFile(path.join(dir, 'index.md'), 'utf8'),
+      ).resolves.toBe('# Test\n');
+    });
+
     it('throws a NotModifiedError when given a etag in options', async () => {
       const fnGitlab = async () => {
         await gitlabProcessor.readTree('https://gitlab.com/backstage/mock', {
@@ -341,5 +379,17 @@ describe('GitlabUrlReader', () => {
       };
       await expect(fnGithub).rejects.toThrow(NotFoundError);
     });
+
+    it('should throw error when apiBaseUrl is missing', () => {
+      expect(() => {
+        /* eslint-disable no-new */
+        new GitlabUrlReader(
+          {
+            host: 'gitlab.mycompany.com',
+          },
+          { treeResponseFactory },
+        );
+      }).toThrowError('must configure an explicit apiBaseUrl');
+    });
   });
 });
diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts
index 654f4f9a85..8a763d1259 100644
--- a/packages/backend-common/src/reading/GitlabUrlReader.ts
+++ b/packages/backend-common/src/reading/GitlabUrlReader.ts
@@ -51,6 +51,12 @@ export class GitlabUrlReader implements UrlReader {
     deps: { treeResponseFactory: ReadTreeResponseFactory },
   ) {
     this.treeResponseFactory = deps.treeResponseFactory;
+
+    if (!config.apiBaseUrl) {
+      throw new Error(
+        `GitLab integration for '${config.host}' must configure an explicit apiBaseUrl`,
+      );
+    }
   }
 
   async read(url: string): Promise {
@@ -140,33 +146,9 @@ export class GitlabUrlReader implements UrlReader {
       throw new Error(message);
     }
 
-    // Get the filename of archive from the header of the response
-    const contentDispositionHeader = archiveGitLabResponse.headers.get(
-      'content-disposition',
-    ) as string;
-    if (!contentDispositionHeader) {
-      throw new Error(
-        `Failed to read tree from ${url}. ` +
-          'GitLab API response for downloading archive does not contain content-disposition header ',
-      );
-    }
-    const fileNameRegEx = new RegExp(
-      /^attachment; filename="(?.*).zip"$/,
-    );
-    const archiveFileName = contentDispositionHeader.match(fileNameRegEx)
-      ?.groups?.fileName;
-    if (!archiveFileName) {
-      throw new Error(
-        `Failed to read tree from ${url}. GitLab API response for downloading archive has an unexpected ` +
-          `format of content-disposition header ${contentDispositionHeader} `,
-      );
-    }
-
-    const path = filepath ? `${archiveFileName}/${filepath}/` : '';
-
     return await this.treeResponseFactory.fromZipArchive({
       stream: (archiveGitLabResponse.body as unknown) as Readable,
-      path,
+      subpath: filepath,
       etag: commitSha,
       filter: options?.filter,
     });
diff --git a/packages/backend-common/src/reading/integration.test.ts b/packages/backend-common/src/reading/integration.test.ts
new file mode 100644
index 0000000000..18f87636a5
--- /dev/null
+++ b/packages/backend-common/src/reading/integration.test.ts
@@ -0,0 +1,164 @@
+/*
+ * 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 { ConfigReader } from '@backstage/config';
+import { getVoidLogger } from '../logging';
+import { UrlReaders } from './UrlReaders';
+
+const reader = UrlReaders.default({
+  logger: getVoidLogger(),
+  config: new ConfigReader({
+    // The tokens in this config provide read only access to the backstage-verification repos
+    integrations: {
+      github: [
+        {
+          host: 'github.com',
+          token:
+            process.env.INTEGRATION_TEST_GITHUB_TOKEN ||
+            `${86}af${617}d9c3c8bf958b37a${630691452765}bb0b0a`,
+        },
+      ],
+      gitlab: [
+        {
+          host: 'gitlab.com',
+          token:
+            process.env.INTEGRATION_TEST_GITLAB_TOKEN || 'tveGtSHDBJM9ZRHZNRfm',
+        },
+      ],
+      bitbucket: [
+        {
+          host: 'bitbucket.org',
+          username: 'backstage-verification',
+          appPassword:
+            process.env.INTEGRATION_TEST_BITBUCKET_TOKEN ||
+            'H79MAAhtbZwCafkVTrrQ',
+        },
+      ],
+      azure: [
+        {
+          host: 'dev.azure.com',
+          // lasts until 2022-01-28
+          token:
+            process.env.INTEGRATION_TEST_AZURE_TOKEN ||
+            `myvyavvfojh6wvw4ose4bfywqttqx${5}z${5}zs${5}bdxauqaek3yinkazq`,
+        },
+      ],
+    },
+  }),
+});
+
+function withRetries(count: number, fn: () => Promise) {
+  return async () => {
+    let error;
+    for (let i = 0; i < count; i++) {
+      try {
+        await fn();
+        return;
+      } catch (err) {
+        error = err;
+      }
+    }
+    throw error;
+  };
+}
+
+describe('UrlReaders', () => {
+  jest.setTimeout(30_000);
+
+  it(
+    'should read data from azure',
+    withRetries(3, async () => {
+      const data = await reader.read(
+        'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2Ftemplate.yaml',
+      );
+      expect(data.toString()).toContain('test-template-azure');
+
+      const res = await reader.readTree(
+        'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2F{{cookiecutter.name}}',
+      );
+      const files = await res.files();
+      expect(files).toEqual([
+        {
+          path: 'catalog-info.yaml',
+          content: expect.any(Function),
+        },
+      ]);
+    }),
+  );
+
+  it(
+    'should read data from gitlab',
+    withRetries(3, async () => {
+      const data = await reader.read(
+        'https://gitlab.com/backstage-verification/test-templates/-/blob/master/template.yaml',
+      );
+      expect(data.toString()).toContain('test-template-gitlab');
+
+      const res = await reader.readTree(
+        'https://gitlab.com/backstage-verification/test-templates/-/tree/master/{{cookiecutter.name}}',
+      );
+      const files = await res.files();
+      expect(files).toEqual([
+        {
+          path: 'catalog-info.yaml',
+          content: expect.any(Function),
+        },
+      ]);
+    }),
+  );
+
+  it(
+    'should read data from bitbucket',
+    withRetries(3, async () => {
+      const data = await reader.read(
+        'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
+      );
+      expect(data.toString()).toContain('test-template-bitbucket');
+
+      const res = await reader.readTree(
+        'https://bitbucket.org/backstage-verification/test-template/src/master/{{cookiecutter.name}}',
+      );
+      const files = await res.files();
+      expect(files).toEqual([
+        {
+          path: 'catalog-info.yaml',
+          content: expect.any(Function),
+        },
+      ]);
+    }),
+  );
+
+  it(
+    'should read data from github',
+    withRetries(3, async () => {
+      const data = await reader.read(
+        'https://github.com/backstage-verification/test-templates/blob/master/template.yaml',
+      );
+      expect(data.toString()).toContain('test-template-github');
+
+      const res = await reader.readTree(
+        'https://github.com/backstage-verification/test-templates/tree/master/{{cookiecutter.name}}',
+      );
+      const files = await res.files();
+      expect(files).toEqual([
+        {
+          path: 'catalog-info.yaml',
+          content: expect.any(Function),
+        },
+      ]);
+    }),
+  );
+});
diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
index 7332154d09..05ecdb0fc3 100644
--- a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
+++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts
@@ -24,8 +24,9 @@ import { ZipArchiveResponse } from './ZipArchiveResponse';
 type FromArchiveOptions = {
   // A binary stream of a tar archive.
   stream: Readable;
-  // If set, the root of the tree will be set to the given directory path.
-  path?: string;
+  // If unset, the files at the root of the tree will be read.
+  // subpath must not contain the name of the top level directory.
+  subpath?: string;
   // etag of the blob
   etag: string;
   // Filter passed on from the ReadTreeOptions
@@ -45,7 +46,7 @@ export class ReadTreeResponseFactory {
   async fromTarArchive(options: FromArchiveOptions): Promise {
     return new TarArchiveResponse(
       options.stream,
-      options.path ?? '',
+      options.subpath ?? '',
       this.workDir,
       options.etag,
       options.filter,
@@ -55,7 +56,7 @@ export class ReadTreeResponseFactory {
   async fromZipArchive(options: FromArchiveOptions): Promise {
     return new ZipArchiveResponse(
       options.stream,
-      options.path ?? '',
+      options.subpath ?? '',
       this.workDir,
       options.etag,
       options.filter,
diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
index 2cbfc4a89e..2b76bea9e3 100644
--- a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
+++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts
@@ -38,7 +38,7 @@ describe('TarArchiveResponse', () => {
   it('should read files', async () => {
     const stream = fs.createReadStream('/test-archive.tar.gz');
 
-    const res = new TarArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
+    const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
     const files = await res.files();
 
     expect(files).toEqual([
@@ -61,12 +61,8 @@ describe('TarArchiveResponse', () => {
   it('should read files with filter', async () => {
     const stream = fs.createReadStream('/test-archive.tar.gz');
 
-    const res = new TarArchiveResponse(
-      stream,
-      'mock-main/',
-      '/tmp',
-      'etag',
-      path => path.endsWith('.yml'),
+    const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path =>
+      path.endsWith('.yml'),
     );
     const files = await res.files();
 
@@ -83,7 +79,7 @@ describe('TarArchiveResponse', () => {
   it('should read as archive and files', async () => {
     const stream = fs.createReadStream('/test-archive.tar.gz');
 
-    const res = new TarArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
+    const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
     const buffer = await res.archive();
 
     await expect(res.archive()).rejects.toThrow(
@@ -115,24 +111,18 @@ describe('TarArchiveResponse', () => {
 
     const res = new TarArchiveResponse(stream, '', '/tmp', 'etag');
     const dir = await res.dir();
-
     await expect(
-      fs.readFile(resolvePath(dir, 'mock-main/mkdocs.yml'), 'utf8'),
+      fs.readFile(resolvePath(dir, 'mkdocs.yml'), 'utf8'),
     ).resolves.toBe('site_name: Test\n');
     await expect(
-      fs.readFile(resolvePath(dir, 'mock-main/docs/index.md'), 'utf8'),
+      fs.readFile(resolvePath(dir, 'docs/index.md'), 'utf8'),
     ).resolves.toBe('# Test\n');
   });
 
   it('should extract archive into directory with a subpath', async () => {
     const stream = fs.createReadStream('/test-archive.tar.gz');
 
-    const res = new TarArchiveResponse(
-      stream,
-      'mock-main/docs/',
-      '/tmp',
-      'etag',
-    );
+    const res = new TarArchiveResponse(stream, 'docs', '/tmp', 'etag');
     const dir = await res.dir();
 
     expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
@@ -144,12 +134,8 @@ describe('TarArchiveResponse', () => {
   it('should extract archive into directory with a subpath and filter', async () => {
     const stream = fs.createReadStream('/test-archive.tar.gz');
 
-    const res = new TarArchiveResponse(
-      stream,
-      'mock-main/',
-      '/tmp',
-      'etag',
-      path => path.endsWith('.yml'),
+    const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path =>
+      path.endsWith('.yml'),
     );
     const dir = await res.dir({ targetDir: '/tmp' });
 
diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts
index 5927eb75a1..24bfa3f1a8 100644
--- a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts
+++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts
@@ -15,7 +15,7 @@
  */
 
 import tar, { Parse, ParseStream, ReadEntry } from 'tar';
-import path from 'path';
+import platformPath from 'path';
 import fs from 'fs-extra';
 import { Readable, pipeline as pipelineCb } from 'stream';
 import { promisify } from 'util';
@@ -30,6 +30,10 @@ import {
 const TarParseStream = (Parse as unknown) as { new (): ParseStream };
 
 const pipeline = promisify(pipelineCb);
+// Matches a directory name + one `/` at the start of any string,
+// containing any character except `/` one or more times, and ending with a `/`
+// e.g. Will match `dirA/` in `dirA/dirB/file.ext`
+const directoryNameRegex = /^[^\/]+\//;
 
 /**
  * Wraps a tar archive stream into a tree response reader.
@@ -78,14 +82,18 @@ export class TarArchiveResponse implements ReadTreeResponse {
         return;
       }
 
+      // File path relative to the root extracted directory. Will remove the
+      // top level dir name from the path since its name is hard to predetermine.
+      const relativePath = entry.path.replace(directoryNameRegex, '');
+
       if (this.subPath) {
-        if (!entry.path.startsWith(this.subPath)) {
+        if (!relativePath.startsWith(this.subPath)) {
           entry.resume();
           return;
         }
       }
 
-      const path = entry.path.slice(this.subPath.length);
+      const path = relativePath.slice(this.subPath.length);
       if (this.filter) {
         if (!this.filter(path)) {
           entry.resume();
@@ -97,7 +105,10 @@ export class TarArchiveResponse implements ReadTreeResponse {
         await pipeline(entry, concatStream(resolve));
       });
 
-      files.push({ path, content: () => content });
+      files.push({
+        path,
+        content: () => content,
+      });
 
       entry.resume();
     });
@@ -136,9 +147,11 @@ export class TarArchiveResponse implements ReadTreeResponse {
 
     const dir =
       options?.targetDir ??
-      (await fs.mkdtemp(path.join(this.workDir, 'backstage-')));
+      (await fs.mkdtemp(platformPath.join(this.workDir, 'backstage-')));
 
-    const strip = this.subPath ? this.subPath.split('/').length - 1 : 0;
+    // Equivalent of tar --strip-components=N
+    // When no subPath is given, remove just 1 top level directory
+    const strip = this.subPath ? this.subPath.split('/').length : 1;
 
     await pipeline(
       this.stream,
@@ -146,7 +159,10 @@ export class TarArchiveResponse implements ReadTreeResponse {
         strip,
         cwd: dir,
         filter: path => {
-          if (this.subPath && !path.startsWith(this.subPath)) {
+          // File path relative to the root extracted directory. Will remove the
+          // top level dir name from the path since its name is hard to predetermine.
+          const relativePath = path.replace(directoryNameRegex, '');
+          if (this.subPath && !relativePath.startsWith(this.subPath)) {
             return false;
           }
           if (this.filter) {
diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
index b42ec79d81..3bcaa5e0e3 100644
--- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
+++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts
@@ -38,7 +38,7 @@ describe('ZipArchiveResponse', () => {
   it('should read files', async () => {
     const stream = fs.createReadStream('/test-archive.zip');
 
-    const res = new ZipArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
+    const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
     const files = await res.files();
 
     expect(files).toEqual([
@@ -61,12 +61,8 @@ describe('ZipArchiveResponse', () => {
   it('should read files with filter', async () => {
     const stream = fs.createReadStream('/test-archive.zip');
 
-    const res = new ZipArchiveResponse(
-      stream,
-      'mock-main/',
-      '/tmp',
-      'etag',
-      path => path.endsWith('.yml'),
+    const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path =>
+      path.endsWith('.yml'),
     );
     const files = await res.files();
 
@@ -83,7 +79,7 @@ describe('ZipArchiveResponse', () => {
   it('should read as archive and files', async () => {
     const stream = fs.createReadStream('/test-archive.zip');
 
-    const res = new ZipArchiveResponse(stream, 'mock-main/', '/tmp', 'etag');
+    const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag');
     const buffer = await res.archive();
 
     await expect(res.archive()).rejects.toThrow(
@@ -117,22 +113,17 @@ describe('ZipArchiveResponse', () => {
     const dir = await res.dir();
 
     await expect(
-      fs.readFile(resolvePath(dir, 'mock-main/mkdocs.yml'), 'utf8'),
+      fs.readFile(resolvePath(dir, 'mkdocs.yml'), 'utf8'),
     ).resolves.toBe('site_name: Test\n');
     await expect(
-      fs.readFile(resolvePath(dir, 'mock-main/docs/index.md'), 'utf8'),
+      fs.readFile(resolvePath(dir, 'docs/index.md'), 'utf8'),
     ).resolves.toBe('# Test\n');
   });
 
   it('should extract archive into directory with a subpath', async () => {
     const stream = fs.createReadStream('/test-archive.zip');
 
-    const res = new ZipArchiveResponse(
-      stream,
-      'mock-main/docs/',
-      '/tmp',
-      'etag',
-    );
+    const res = new ZipArchiveResponse(stream, 'docs/', '/tmp', 'etag');
     const dir = await res.dir();
 
     expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/);
@@ -144,12 +135,8 @@ describe('ZipArchiveResponse', () => {
   it('should extract archive into directory with a subpath and filter', async () => {
     const stream = fs.createReadStream('/test-archive.zip');
 
-    const res = new ZipArchiveResponse(
-      stream,
-      'mock-main/',
-      '/tmp',
-      'etag',
-      path => path.endsWith('.yml'),
+    const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path =>
+      path.endsWith('.yml'),
     );
     const dir = await res.dir({ targetDir: '/tmp' });
 
diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
index 07d34faaa3..323f7b4778 100644
--- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
+++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-import path from 'path';
+import platformPath from 'path';
 import fs from 'fs-extra';
 import unzipper, { Entry } from 'unzipper';
 import archiver from 'archiver';
@@ -25,6 +25,11 @@ import {
   ReadTreeResponseDirOptions,
 } from '../types';
 
+// Matches a directory name + one `/` at the start of any string,
+// containing any character except / one or more times, and ending with a `/`
+// e.g. Will match `dirA/` in `dirA/dirB/file.ext`
+const directoryNameRegex = /^[^\/]+\//;
+
 /**
  * Wraps a zip archive stream into a tree response reader.
  */
@@ -60,18 +65,26 @@ export class ZipArchiveResponse implements ReadTreeResponse {
     this.read = true;
   }
 
-  private getPath(entry: Entry): string {
-    return entry.path.slice(this.subPath.length);
+  // Will remove the top level dir name from the path since its name is hard to predetermine.
+  private stripTopDirectory(path: string): string {
+    return path.replace(directoryNameRegex, '');
+  }
+
+  // File path relative to the root extracted directory or a sub directory if subpath is set.
+  private getInnerPath(path: string): string {
+    return path.slice(this.subPath.length);
   }
 
   private shouldBeIncluded(entry: Entry): boolean {
+    const strippedPath = this.stripTopDirectory(entry.path);
+
     if (this.subPath) {
-      if (!entry.path.startsWith(this.subPath)) {
+      if (!strippedPath.startsWith(this.subPath)) {
         return false;
       }
     }
     if (this.filter) {
-      return this.filter(this.getPath(entry));
+      return this.filter(this.getInnerPath(entry.path));
     }
     return true;
   }
@@ -91,7 +104,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
 
         if (this.shouldBeIncluded(entry)) {
           files.push({
-            path: this.getPath(entry),
+            path: this.getInnerPath(this.stripTopDirectory(entry.path)),
             content: () => entry.buffer(),
           });
         } else {
@@ -115,7 +128,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
       .pipe(unzipper.Parse())
       .on('entry', (entry: Entry) => {
         if (entry.type === 'File' && this.shouldBeIncluded(entry)) {
-          archive.append(entry, { name: this.getPath(entry) });
+          archive.append(entry, { name: this.getInnerPath(entry.path) });
         } else {
           entry.autodrain();
         }
@@ -131,7 +144,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
 
     const dir =
       options?.targetDir ??
-      (await fs.mkdtemp(path.join(this.workDir, 'backstage-')));
+      (await fs.mkdtemp(platformPath.join(this.workDir, 'backstage-')));
 
     await this.stream
       .pipe(unzipper.Parse())
@@ -139,12 +152,14 @@ export class ZipArchiveResponse implements ReadTreeResponse {
         // Ignore directory entries since we handle that with the file entries
         // as a zip can have files with directories without directory entries
         if (entry.type === 'File' && this.shouldBeIncluded(entry)) {
-          const entryPath = this.getPath(entry);
-          const dirname = path.dirname(entryPath);
+          const entryPath = this.getInnerPath(
+            this.stripTopDirectory(entry.path),
+          );
+          const dirname = platformPath.dirname(entryPath);
           if (dirname) {
-            await fs.mkdirp(path.join(dir, dirname));
+            await fs.mkdirp(platformPath.join(dir, dirname));
           }
-          entry.pipe(fs.createWriteStream(path.join(dir, entryPath)));
+          entry.pipe(fs.createWriteStream(platformPath.join(dir, entryPath)));
         } else {
           entry.autodrain();
         }
diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts
index e98f760d8f..f9dfbe9aff 100644
--- a/packages/backend-common/src/reading/types.ts
+++ b/packages/backend-common/src/reading/types.ts
@@ -81,6 +81,9 @@ export type ReadTreeResponseDirOptions = {
 };
 
 export type ReadTreeResponse = {
+  /**
+   * files() returns an array of all the files inside the tree and corresponding functions to read their content.
+   */
   files(): Promise;
   archive(): Promise;
 
diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts
index b0a1df6541..5b24d23b99 100644
--- a/packages/backend-common/src/scm/git.ts
+++ b/packages/backend-common/src/scm/git.ts
@@ -86,13 +86,22 @@ export class Git {
     return git.commit({ fs, dir, message, author, committer });
   }
 
-  async clone({ url, dir }: { url: string; dir: string }): Promise {
+  async clone({
+    url,
+    dir,
+    ref,
+  }: {
+    url: string;
+    dir: string;
+    ref?: string;
+  }): Promise {
     this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`);
     return git.clone({
       fs,
       http,
       url,
       dir,
+      ref,
       singleBranch: true,
       depth: 1,
       onProgress: this.onProgressHandler(),
diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md
index 1d840d8761..215e4e7296 100644
--- a/packages/backend/CHANGELOG.md
+++ b/packages/backend/CHANGELOG.md
@@ -1,5 +1,27 @@
 # example-backend
 
+## 0.2.13
+
+### Patch Changes
+
+- Updated dependencies [26a3a6cf0]
+- Updated dependencies [681111228]
+- Updated dependencies [664dd08c9]
+- Updated dependencies [9dd057662]
+- Updated dependencies [234e7d985]
+- Updated dependencies [d7b1d317f]
+- Updated dependencies [a91aa6bf2]
+- Updated dependencies [39b05b9ae]
+- Updated dependencies [4eaa06057]
+  - @backstage/backend-common@0.5.1
+  - @backstage/plugin-scaffolder-backend@0.5.2
+  - @backstage/plugin-kubernetes-backend@0.2.6
+  - @backstage/plugin-catalog-backend@0.5.5
+  - @backstage/plugin-kafka-backend@0.2.0
+  - @backstage/plugin-auth-backend@0.2.12
+  - example-app@0.2.13
+  - @backstage/plugin-app-backend@0.3.5
+
 ## 0.2.12
 
 ### Patch Changes
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 269089a400..aa00407952 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example-backend",
-  "version": "0.2.12",
+  "version": "0.2.13",
   "main": "dist/index.cjs.js",
   "types": "src/index.ts",
   "license": "Apache-2.0",
@@ -27,24 +27,24 @@
     "migrate:create": "knex migrate:make -x ts"
   },
   "dependencies": {
-    "@backstage/backend-common": "^0.5.0",
+    "@backstage/backend-common": "^0.5.1",
     "@backstage/catalog-model": "^0.7.0",
     "@backstage/config": "^0.1.2",
-    "@backstage/plugin-app-backend": "^0.3.4",
-    "@backstage/plugin-auth-backend": "^0.2.11",
-    "@backstage/plugin-catalog-backend": "^0.5.4",
+    "@backstage/plugin-app-backend": "^0.3.5",
+    "@backstage/plugin-auth-backend": "^0.2.12",
+    "@backstage/plugin-catalog-backend": "^0.5.5",
     "@backstage/plugin-graphql-backend": "^0.1.5",
-    "@backstage/plugin-kubernetes-backend": "^0.2.5",
-    "@backstage/plugin-kafka-backend": "^0.1.1",
+    "@backstage/plugin-kubernetes-backend": "^0.2.6",
+    "@backstage/plugin-kafka-backend": "^0.2.0",
     "@backstage/plugin-proxy-backend": "^0.2.4",
     "@backstage/plugin-rollbar-backend": "^0.1.7",
-    "@backstage/plugin-scaffolder-backend": "^0.5.0",
+    "@backstage/plugin-scaffolder-backend": "^0.5.2",
     "@backstage/plugin-techdocs-backend": "^0.5.4",
     "@gitbeaker/node": "^28.0.2",
     "@octokit/rest": "^18.0.12",
     "azure-devops-node-api": "^10.1.1",
     "dockerode": "^3.2.1",
-    "example-app": "^0.2.12",
+    "example-app": "^0.2.13",
     "express": "^4.17.1",
     "express-promise-router": "^3.0.3",
     "knex": "^0.21.6",
@@ -54,7 +54,7 @@
     "winston": "^3.2.1"
   },
   "devDependencies": {
-    "@backstage/cli": "^0.4.7",
+    "@backstage/cli": "^0.5.0",
     "@types/dockerode": "^3.2.1",
     "@types/express": "^4.17.6",
     "@types/express-serve-static-core": "^4.17.5"
diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json
index 0b68446463..c1cbd6157f 100644
--- a/packages/catalog-client/package.json
+++ b/packages/catalog-client/package.json
@@ -34,7 +34,7 @@
     "cross-fetch": "^3.0.6"
   },
   "devDependencies": {
-    "@backstage/cli": "^0.4.7",
+    "@backstage/cli": "^0.5.0",
     "@types/jest": "^26.0.7",
     "msw": "^0.21.2"
   },
diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml
index 05afc265c0..9a0d690b57 100644
--- a/packages/catalog-model/examples/acme/org.yaml
+++ b/packages/catalog-model/examples/acme/org.yaml
@@ -3,6 +3,11 @@ kind: Group
 metadata:
   name: acme-corp
   description: The acme-corp organization
+  links:
+    - url: http://www.acme.com/
+      title: Website
+    - url: https://meta.wikimedia.org/wiki/
+      title: Intranet
 spec:
   type: organization
   profile:
diff --git a/packages/catalog-model/examples/apis/petstore-api.yaml b/packages/catalog-model/examples/apis/petstore-api.yaml
index a314b4e255..5b6878f7d3 100644
--- a/packages/catalog-model/examples/apis/petstore-api.yaml
+++ b/packages/catalog-model/examples/apis/petstore-api.yaml
@@ -6,6 +6,13 @@ metadata:
   tags:
     - store
     - rest
+  links:
+    - url: https://github.com/swagger-api/swagger-petstore
+      title: GitHub Repo
+      icon: github
+    - url: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore.yaml
+      title: API Spec
+      icon: code
 spec:
   type: openapi
   lifecycle: experimental
diff --git a/packages/catalog-model/examples/apis/streetlights-api.yaml b/packages/catalog-model/examples/apis/streetlights-api.yaml
index 725811d0cc..4e0dbb1396 100644
--- a/packages/catalog-model/examples/apis/streetlights-api.yaml
+++ b/packages/catalog-model/examples/apis/streetlights-api.yaml
@@ -5,6 +5,10 @@ metadata:
   description: The Smartylighting Streetlights API allows you to remotely manage the city lights.
   tags:
     - mqtt
+  links:
+    - url: https://github.com/asyncapi/asyncapi/blob/master/examples/1.2.0/streetlights.yml
+      title: Source Code
+      icon: code
 spec:
   type: asyncapi
   lifecycle: production
diff --git a/packages/catalog-model/examples/apis/swapi-graphql.yaml b/packages/catalog-model/examples/apis/swapi-graphql.yaml
index 0c1f7af5a4..f3d924fc53 100644
--- a/packages/catalog-model/examples/apis/swapi-graphql.yaml
+++ b/packages/catalog-model/examples/apis/swapi-graphql.yaml
@@ -3,6 +3,10 @@ kind: API
 metadata:
   name: starwars-graphql
   description: SWAPI GraphQL Schema
+  links:
+    - url: https://github.com/graphql/swapi-graphql
+      title: GitHub Repo
+      icon: github
 spec:
   type: graphql
   lifecycle: production
diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml
index 3fc516ece9..fbf28d7fcb 100644
--- a/packages/catalog-model/examples/components/artist-lookup-component.yaml
+++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml
@@ -6,6 +6,12 @@ metadata:
   tags:
     - java
     - data
+  links:
+    - url: https://example.com/apm/artists-lookup
+      title: APM
+      icon: dashboard
+    - url: https://example.com/logs/artists-lookup
+      title: Logs
 spec:
   type: service
   lifecycle: experimental
diff --git a/packages/catalog-model/examples/components/petstore-component.yaml b/packages/catalog-model/examples/components/petstore-component.yaml
index acbb2f82b0..48286f9b74 100644
--- a/packages/catalog-model/examples/components/petstore-component.yaml
+++ b/packages/catalog-model/examples/components/petstore-component.yaml
@@ -3,6 +3,10 @@ kind: Component
 metadata:
   name: petstore
   description: Petstore
+  links:
+    - url: https://github.com/swagger-api/swagger-petstore
+      title: GitHub Repo
+      icon: github
 spec:
   type: service
   lifecycle: experimental
diff --git a/packages/catalog-model/examples/domains/artists-domain.yaml b/packages/catalog-model/examples/domains/artists-domain.yaml
index 7bcc4329dd..2a74425791 100644
--- a/packages/catalog-model/examples/domains/artists-domain.yaml
+++ b/packages/catalog-model/examples/domains/artists-domain.yaml
@@ -3,5 +3,11 @@ kind: Domain
 metadata:
   name: artists
   description: Everything related to artists
+  links:
+    - url: http://example.com/domain/artists/
+      title: Domain Readme
+    - url: http://example.com/domains/artists/dashboard
+      title: Domain Metrics Dashboard
+      icon: dashboard
 spec:
   owner: team-a
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
index e03ce8c465..e81bc3a9b6 100644
--- a/packages/catalog-model/package.json
+++ b/packages/catalog-model/package.json
@@ -32,13 +32,14 @@
     "@backstage/config": "^0.1.2",
     "@types/json-schema": "^7.0.5",
     "@types/yup": "^0.29.8",
+    "ajv": "^7.0.3",
     "json-schema": "^0.2.5",
     "lodash": "^4.17.15",
     "uuid": "^8.0.0",
     "yup": "^0.29.3"
   },
   "devDependencies": {
-    "@backstage/cli": "^0.4.7",
+    "@backstage/cli": "^0.5.0",
     "@types/express": "^4.17.6",
     "@types/jest": "^26.0.7",
     "@types/lodash": "^4.14.151",
diff --git a/packages/catalog-model/src/EntityPolicies.test.ts b/packages/catalog-model/src/EntityPolicies.test.ts
index cd869f798c..b67180241e 100644
--- a/packages/catalog-model/src/EntityPolicies.test.ts
+++ b/packages/catalog-model/src/EntityPolicies.test.ts
@@ -1,4 +1,3 @@
-import { Entity } from './entity';
 /*
  * Copyright 2020 Spotify AB
  *
@@ -15,8 +14,8 @@ import { Entity } from './entity';
  * limitations under the License.
  */
 
+import { Entity, EntityPolicy } from './entity';
 import { EntityPolicies } from './EntityPolicies';
-import { EntityPolicy } from './types';
 
 describe('EntityPolicies', () => {
   const p1: jest.Mocked = { enforce: jest.fn() };
diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts
index 1f304f8f27..2d576b1670 100644
--- a/packages/catalog-model/src/EntityPolicies.ts
+++ b/packages/catalog-model/src/EntityPolicies.ts
@@ -14,8 +14,7 @@
  * limitations under the License.
  */
 
-import { Entity } from './entity';
-import { EntityPolicy } from './types';
+import { Entity, EntityPolicy } from './entity';
 
 // Helper that requires that all of a set of policies can be successfully
 // applied
diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts
index ac43137546..7abe1f9c0c 100644
--- a/packages/catalog-model/src/entity/Entity.ts
+++ b/packages/catalog-model/src/entity/Entity.ts
@@ -125,6 +125,11 @@ export type EntityMeta = JsonObject & {
    * various ways.
    */
   tags?: string[];
+
+  /**
+   * A list of external hyperlinks related to the entity.
+   */
+  links?: EntityLink[];
 };
 
 /**
@@ -161,3 +166,23 @@ export type EntityRelationSpec = {
    */
   target: EntityName;
 };
+
+/**
+ * A link to external information that is related to the entity.
+ */
+export type EntityLink = {
+  /**
+   * The url to the external site, document, etc.
+   */
+  url: string;
+
+  /**
+   * An optional descriptive title for the link.
+   */
+  title?: string;
+
+  /**
+   * An optional semantic key that represents a visual icon.
+   */
+  icon?: string;
+};
diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts
index d450119f0a..e80e14f7a4 100644
--- a/packages/catalog-model/src/entity/index.ts
+++ b/packages/catalog-model/src/entity/index.ts
@@ -20,12 +20,14 @@ export {
 } from './constants';
 export type {
   Entity,
+  EntityLink,
   EntityMeta,
   EntityRelation,
   EntityRelationSpec,
 } from './Entity';
 export * from './policies';
 export {
+  compareEntityToRef,
   getEntityName,
   parseEntityName,
   parseEntityRef,
diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts
index 3119164454..4f5bbe04f4 100644
--- a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts
@@ -15,7 +15,7 @@
  */
 
 import lodash from 'lodash';
-import { EntityPolicy } from '../../types';
+import { EntityPolicy } from './types';
 import { ENTITY_DEFAULT_NAMESPACE } from '../constants';
 import { Entity } from '../Entity';
 
diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts
index edf04a64cc..33ee045c1e 100644
--- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts
+++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts
@@ -38,6 +38,10 @@ describe('FieldFormatEntityPolicy', () => {
         tags:
           - java
           - data-service
+        links:
+          - url: https://example.org
+            title: Website
+            icon: website
       spec:
         custom: stuff
     `);
@@ -110,4 +114,110 @@ describe('FieldFormatEntityPolicy', () => {
     data.metadata.tags.push('Hello World');
     await expect(policy.enforce(data)).rejects.toThrow(/tags.*"Hello World"/i);
   });
+
+  it('accepts missing links', async () => {
+    delete data.metadata.links;
+    await expect(policy.enforce(data)).resolves.toBe(data);
+  });
+
+  it('accepts empty links array', async () => {
+    data.metadata.links = [];
+    await expect(policy.enforce(data)).resolves.toBe(data);
+  });
+
+  it('accepts multiple links', async () => {
+    data.metadata.links = [{ url: 'http://foo' }, { url: 'https://bar' }];
+    await expect(policy.enforce(data)).resolves.toBe(data);
+  });
+
+  it('rejects missing link url value', async () => {
+    data.metadata.links = [{}];
+    await expect(policy.enforce(data)).rejects.toThrow(/links.0.url/i);
+  });
+
+  it('rejects a single bad missing link url value', async () => {
+    data.metadata.links = [{ url: 'http://good' }, { url: '' }];
+    await expect(policy.enforce(data)).rejects.toThrow(
+      /links.1.url.*valid url/i,
+    );
+  });
+
+  it('rejects empty link url value', async () => {
+    data.metadata.links = [{ url: '' }];
+    await expect(policy.enforce(data)).rejects.toThrow(/links.0.url.*/i);
+  });
+
+  it('rejects bad link url value', async () => {
+    data.metadata.links = [{ url: 'invalid' }];
+    await expect(policy.enforce(data)).rejects.toThrow(
+      /links.0.url.*"invalid"/i,
+    );
+  });
+
+  it('accepts missing link title', async () => {
+    data.metadata.links = [{ url: 'http://foo', icon: 'dashboard' }];
+    await expect(policy.enforce(data)).resolves.toBe(data);
+  });
+
+  it('rejects empty link title', async () => {
+    data.metadata.links = [{ url: 'http://foo', title: '' }];
+    await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*""/i);
+  });
+
+  it('rejects bad link title', async () => {
+    data.metadata.links = [{ url: 'http://foo', title: 123 }];
+    await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*"123"/i);
+  });
+
+  it.each([[123], [{}], [[]]])(
+    'rejects bad link title %s',
+    async (title: unknown) => {
+      data.metadata.links = [{ url: 'http://foo', title }];
+      await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*/i);
+    },
+  );
+
+  it('rejects a single bad link title', async () => {
+    data.metadata.links = [
+      { url: 'http://foo', title: 'good' },
+      { url: 'http://foo', title: '' },
+    ];
+    await expect(policy.enforce(data)).rejects.toThrow(/links.1.title.*""/i);
+  });
+
+  it('accepts missing link icon', async () => {
+    data.metadata.links = [{ url: 'http://foo', title: 'foo' }];
+    await expect(policy.enforce(data)).resolves.toBe(data);
+  });
+
+  it('rejects empty link icon', async () => {
+    data.metadata.links = [{ url: 'http://foo', icon: '' }];
+    await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*""/i);
+  });
+
+  it.each([['dashboard'], ['admin-dashboard'], ['foo_dashboard']])(
+    'accepts valid link icon',
+    async icon => {
+      data.metadata.links = [{ url: 'http://foo', icon }];
+      await expect(policy.enforce(data)).resolves.toBe(data);
+    },
+  );
+
+  it.each([[123], [{}], [[]], ['abc xyz']])(
+    'rejects bad link icon value %s',
+    async (icon: unknown) => {
+      data.metadata.links = [{ url: 'http://foo', icon }];
+      await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*/i);
+    },
+  );
+
+  it('rejects a single bad link icon value', async () => {
+    data.metadata.links = [
+      { url: 'http://foo', icon: 'good' },
+      { url: 'http://foo', icon: 'not good' },
+    ];
+    await expect(policy.enforce(data)).rejects.toThrow(
+      /links.1.icon.*"not good"/i,
+    );
+  });
 });
diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
index 0aabbd9e48..1ed13c972c 100644
--- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-import { EntityPolicy } from '../../types';
+import { EntityPolicy } from './types';
 import {
   CommonValidatorFunctions,
   KubernetesValidatorFunctions,
@@ -83,6 +83,12 @@ export class FieldFormatEntityPolicy implements EntityPolicy {
             expectation =
               'a string that is a sequence of [a-zA-Z][a-z0-9A-Z], at most 63 characters in total';
             break;
+          case 'isValidUrl':
+            expectation = 'a string that is a valid url';
+            break;
+          case 'isValidString':
+            expectation = 'a non empty string';
+            break;
           default:
             expectation = undefined;
             break;
@@ -134,6 +140,23 @@ export class FieldFormatEntityPolicy implements EntityPolicy {
       require(`tags.${i}`, tags[i], this.validators.isValidTag);
     }
 
+    const links = entity.metadata.links ?? [];
+
+    for (let i = 0; i < links.length; ++i) {
+      require(`links.${i}.url`, links[i]
+        ?.url, CommonValidatorFunctions.isValidUrl);
+      optional(
+        `links.${i}.title`,
+        links[i]?.title,
+        CommonValidatorFunctions.isValidString,
+      );
+      optional(
+        `links.${i}.icon`,
+        links[i]?.icon,
+        KubernetesValidatorFunctions.isValidObjectName,
+      );
+    }
+
     return entity;
   }
 }
diff --git a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts
index 9d1851bc02..7d401542ba 100644
--- a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-import { EntityPolicy } from '../../types';
+import { EntityPolicy } from './types';
 import { Entity } from '../Entity';
 
 const defaultKnownFields = ['apiVersion', 'kind', 'metadata', 'spec'];
diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts
index d63ba49dbe..c84bdbdd38 100644
--- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts
+++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts
@@ -39,6 +39,10 @@ describe('SchemaValidEntityPolicy', () => {
         tags:
           - java
           - data
+        links:
+          - url: https://example.com
+            title: Website
+            icon: website
       spec:
         custom: stuff
     `);
@@ -198,6 +202,24 @@ describe('SchemaValidEntityPolicy', () => {
     await expect(policy.enforce(data)).rejects.toThrow(/tags/);
   });
 
+  it('accepts missing links', async () => {
+    delete data.metadata.links;
+    await expect(policy.enforce(data)).resolves.toBe(data);
+  });
+
+  it('accepts empty links array', async () => {
+    data.metadata.links = [];
+    await expect(policy.enforce(data)).resolves.toBe(data);
+  });
+
+  it.each([['invalid type'], [123], [{}], [{ url: 'https://foo' }]])(
+    'rejects bad links type %s',
+    async (val: unknown) => {
+      data.metadata.links = val;
+      await expect(policy.enforce(data)).rejects.toThrow(/links/);
+    },
+  );
+
   //
   // spec
   //
diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
index 4ff73e9a49..7e0a8df268 100644
--- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
+++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts
@@ -14,30 +14,12 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
-import { EntityPolicy } from '../../types';
+import Ajv, { ValidateFunction } from 'ajv';
+import entitySchema from '../../schema/Entity.schema.json';
+import entityMetaSchema from '../../schema/EntityMeta.schema.json';
+import commonSchema from '../../schema/shared/common.schema.json';
 import { Entity } from '../Entity';
-
-const DEFAULT_ENTITY_SCHEMA = yup
-  .object({
-    apiVersion: yup.string().required(),
-    kind: yup.string().required(),
-    metadata: yup
-      .object({
-        uid: yup.string().notRequired().min(1),
-        etag: yup.string().notRequired().min(1),
-        generation: yup.number().notRequired().integer().min(1),
-        name: yup.string().required(),
-        namespace: yup.string().notRequired(),
-        description: yup.string().notRequired(),
-        labels: yup.object>().notRequired(),
-        annotations: yup.object>().notRequired(),
-        tags: yup.array().notRequired(),
-      })
-      .required(),
-    spec: yup.object({}).notRequired(),
-  })
-  .required();
+import { EntityPolicy } from './types';
 
 /**
  * Ensures that the entity spec is valid according to a schema.
@@ -47,17 +29,28 @@ const DEFAULT_ENTITY_SCHEMA = yup
  * typescript type.
  */
 export class SchemaValidEntityPolicy implements EntityPolicy {
-  private readonly schema: yup.Schema;
-
-  constructor(schema: yup.Schema = DEFAULT_ENTITY_SCHEMA) {
-    this.schema = schema;
-  }
+  private validate: ValidateFunction | undefined;
 
   async enforce(entity: Entity): Promise {
-    try {
-      return await this.schema.validate(entity, { strict: true });
-    } catch (e) {
-      throw new Error(`Malformed envelope, ${e}`);
+    if (!this.validate) {
+      const ajv = new Ajv({ allowUnionTypes: true });
+      this.validate = ajv
+        .addSchema([commonSchema, entityMetaSchema], undefined, undefined, true)
+        .compile(entitySchema);
     }
+
+    const result = this.validate(entity);
+    if (result === true) {
+      return entity;
+    }
+
+    const [error] = this.validate.errors || [];
+    if (!error) {
+      throw new Error(`Malformed envelope, Unknown error`);
+    }
+
+    throw new Error(
+      `Malformed envelope, ${error.dataPath || ''} ${error.message}`,
+    );
   }
 }
diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts
index c381d29a23..5d75ef4d84 100644
--- a/packages/catalog-model/src/entity/policies/index.ts
+++ b/packages/catalog-model/src/entity/policies/index.ts
@@ -18,3 +18,4 @@ export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
 export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
 export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
 export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';
+export type { EntityPolicy } from './types';
diff --git a/packages/catalog-model/src/entity/policies/types.ts b/packages/catalog-model/src/entity/policies/types.ts
new file mode 100644
index 0000000000..415c98bbd2
--- /dev/null
+++ b/packages/catalog-model/src/entity/policies/types.ts
@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+
+import type { Entity } from '../Entity';
+
+/**
+ * A policy for validation or mutation to be applied to entities as they are
+ * entering the system.
+ */
+export type EntityPolicy = {
+  /**
+   * Applies validation or mutation on an entity.
+   *
+   * @param entity The entity, as validated/mutated so far in the policy tree
+   * @returns The incoming entity, or a mutated version of the same, or
+   *          undefined if this processor could not handle the entity
+   * @throws An error if the entity should be rejected
+   */
+  enforce(entity: Entity): Promise;
+};
diff --git a/packages/catalog-model/src/entity/ref.test.ts b/packages/catalog-model/src/entity/ref.test.ts
index bd7cc9477d..5ca511ee03 100644
--- a/packages/catalog-model/src/entity/ref.test.ts
+++ b/packages/catalog-model/src/entity/ref.test.ts
@@ -16,7 +16,12 @@
 
 import { ENTITY_DEFAULT_NAMESPACE } from './constants';
 import { Entity } from './Entity';
-import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref';
+import {
+  compareEntityToRef,
+  parseEntityName,
+  parseEntityRef,
+  serializeEntityRef,
+} from './ref';
 
 describe('ref', () => {
   describe('parseEntityName', () => {
@@ -381,4 +386,320 @@ describe('ref', () => {
       ).toEqual({ kind: 'a', namespace: 'b', name: 'c/x' });
     });
   });
+
+  describe('compareEntityToRef', () => {
+    const entityWithNamespace: Entity = {
+      apiVersion: 'a',
+      kind: 'K',
+      metadata: {
+        name: 'n',
+        namespace: 'ns',
+      },
+    };
+    const entityWithoutNamespace: Entity = {
+      apiVersion: 'a',
+      kind: 'K',
+      metadata: {
+        name: 'n',
+      },
+    };
+
+    it('handles matching string refs', () => {
+      expect(compareEntityToRef(entityWithNamespace, 'K:ns/n')).toBe(true);
+      expect(compareEntityToRef(entityWithNamespace, 'k:nS/N')).toBe(true);
+      expect(
+        compareEntityToRef(entityWithNamespace, 'K:n', {
+          defaultNamespace: 'ns',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithNamespace, 'K:n', {
+          defaultNamespace: 'Ns',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithNamespace, 'ns/n', { defaultKind: 'K' }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithNamespace, 'n', {
+          defaultKind: 'K',
+          defaultNamespace: 'ns',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithNamespace, 'N', {
+          defaultKind: 'k',
+          defaultNamespace: 'nS',
+        }),
+      ).toBe(true);
+
+      expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe(
+        true,
+      );
+      expect(compareEntityToRef(entityWithoutNamespace, 'K:deFault/n')).toBe(
+        true,
+      );
+      expect(
+        compareEntityToRef(entityWithoutNamespace, 'K:n', {
+          defaultNamespace: 'default',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, 'K:n', {
+          defaultNamespace: 'deFault',
+        }),
+      ).toBe(true);
+      expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe(
+        true,
+      );
+      expect(compareEntityToRef(entityWithoutNamespace, 'K:n')).toBe(true);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, 'default/n', {
+          defaultKind: 'K',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, 'n', {
+          defaultKind: 'K',
+          defaultNamespace: 'default',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, 'n', {
+          defaultKind: 'K',
+        }),
+      ).toBe(true);
+    });
+
+    it('handles mismatching string refs', () => {
+      expect(compareEntityToRef(entityWithNamespace, 'X:ns/n')).toBe(false);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, 'ns/n', {
+          defaultKind: 'X',
+        }),
+      ).toBe(false);
+
+      expect(compareEntityToRef(entityWithNamespace, 'K:xx/n')).toBe(false);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, 'K:n', {
+          defaultNamespace: 'xx',
+        }),
+      ).toBe(false);
+
+      expect(compareEntityToRef(entityWithNamespace, 'K:ns/x')).toBe(false);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, 'x', {
+          defaultKind: 'K',
+          defaultNamespace: 'ns',
+        }),
+      ).toBe(false);
+    });
+
+    it('handles matching compound refs', () => {
+      expect(
+        compareEntityToRef(entityWithNamespace, {
+          kind: 'K',
+          namespace: 'ns',
+          name: 'n',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithNamespace, {
+          kind: 'k',
+          namespace: 'Ns',
+          name: 'N',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(
+          entityWithNamespace,
+          { kind: 'K', name: 'n' },
+          {
+            defaultNamespace: 'ns',
+          },
+        ),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(
+          entityWithNamespace,
+          { namespace: 'ns', name: 'n' },
+          { defaultKind: 'K' },
+        ),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithNamespace, 'n', {
+          defaultKind: 'K',
+          defaultNamespace: 'ns',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithNamespace, 'N', {
+          defaultKind: 'k',
+          defaultNamespace: 'nS',
+        }),
+      ).toBe(true);
+
+      expect(
+        compareEntityToRef(entityWithoutNamespace, {
+          kind: 'K',
+          namespace: 'default',
+          name: 'n',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, {
+          kind: 'k',
+          namespace: 'deFault',
+          name: 'N',
+        }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(
+          entityWithoutNamespace,
+          { kind: 'K', name: 'n' },
+          {
+            defaultNamespace: 'default',
+          },
+        ),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, { kind: 'K', name: 'n' }),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(
+          entityWithoutNamespace,
+          { namespace: 'default', name: 'n' },
+          {
+            defaultKind: 'K',
+          },
+        ),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(
+          entityWithoutNamespace,
+          { name: 'n' },
+          {
+            defaultKind: 'K',
+            defaultNamespace: 'default',
+          },
+        ),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(
+          entityWithoutNamespace,
+          { name: 'N' },
+          {
+            defaultKind: 'k',
+            defaultNamespace: 'defAult',
+          },
+        ),
+      ).toBe(true);
+      expect(
+        compareEntityToRef(
+          entityWithoutNamespace,
+          { name: 'n' },
+          {
+            defaultKind: 'K',
+          },
+        ),
+      ).toBe(true);
+    });
+
+    it('handles mismatching compound refs', () => {
+      expect(
+        compareEntityToRef(entityWithNamespace, {
+          kind: 'X',
+          namespace: 'ns',
+          name: 'n',
+        }),
+      ).toBe(false);
+      expect(
+        compareEntityToRef(
+          entityWithNamespace,
+          {
+            namespace: 'ns',
+            name: 'n',
+          },
+          { defaultKind: 'X' },
+        ),
+      ).toBe(false);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, {
+          kind: 'X',
+          namespace: 'default',
+          name: 'n',
+        }),
+      ).toBe(false);
+      expect(
+        compareEntityToRef(
+          entityWithoutNamespace,
+          {
+            namespace: 'default',
+            name: 'n',
+          },
+          { defaultKind: 'X' },
+        ),
+      ).toBe(false);
+
+      expect(
+        compareEntityToRef(entityWithNamespace, {
+          kind: 'K',
+          namespace: 'xx',
+          name: 'n',
+        }),
+      ).toBe(false);
+      expect(
+        compareEntityToRef(
+          entityWithNamespace,
+          {
+            kind: 'K',
+            name: 'n',
+          },
+          { defaultNamespace: 'xx' },
+        ),
+      ).toBe(false);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, {
+          kind: 'K',
+          namespace: 'xx',
+          name: 'n',
+        }),
+      ).toBe(false);
+      expect(
+        compareEntityToRef(
+          entityWithoutNamespace,
+          {
+            kind: 'K',
+            name: 'n',
+          },
+          { defaultNamespace: 'xx' },
+        ),
+      ).toBe(false);
+
+      expect(
+        compareEntityToRef(entityWithNamespace, {
+          kind: 'K',
+          namespace: 'ns',
+          name: 'x',
+        }),
+      ).toBe(false);
+      expect(
+        compareEntityToRef(entityWithoutNamespace, {
+          kind: 'K',
+          namespace: 'default',
+          name: 'x',
+        }),
+      ).toBe(false);
+      expect(
+        compareEntityToRef(
+          entityWithoutNamespace,
+          {
+            kind: 'K',
+            name: 'x',
+          },
+          { defaultNamespace: 'default' },
+        ),
+      ).toBe(false);
+    });
+  });
 });
diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts
index 9f6f175d8d..bf34962d01 100644
--- a/packages/catalog-model/src/entity/ref.ts
+++ b/packages/catalog-model/src/entity/ref.ts
@@ -18,6 +18,27 @@ import { EntityName, EntityRef } from '../types';
 import { ENTITY_DEFAULT_NAMESPACE } from './constants';
 import { Entity } from './Entity';
 
+function parseRefString(
+  ref: string,
+): {
+  kind?: string;
+  namespace?: string;
+  name: string;
+} {
+  const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim());
+  if (!match) {
+    throw new TypeError(
+      `Entity reference "${ref}" was not on the form [:][/]`,
+    );
+  }
+
+  return {
+    kind: match[1]?.slice(0, -1),
+    namespace: match[2]?.slice(0, -1),
+    name: match[3],
+  };
+}
+
 /**
  * Extracts the kind, namespace and name that form the name triplet of the
  * given entity.
@@ -121,17 +142,11 @@ export function parseEntityRef(
   }
 
   if (typeof ref === 'string') {
-    const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim());
-    if (!match) {
-      throw new Error(
-        `Entity reference "${ref}" was not on the form [:][/]`,
-      );
-    }
-
+    const parsed = parseRefString(ref);
     return {
-      kind: match[1]?.slice(0, -1) ?? context.defaultKind,
-      namespace: match[2]?.slice(0, -1) ?? context.defaultNamespace,
-      name: match[3],
+      kind: parsed.kind ?? context.defaultKind,
+      namespace: parsed.namespace ?? context.defaultNamespace,
+      name: parsed.name,
     };
   }
 
@@ -196,3 +211,53 @@ export function serializeEntityRef(
 
   return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`;
 }
+
+/**
+ * Compares an entity to either a string reference or a compound reference.
+ *
+ * The comparison is case insensitive, and all of kind, namespace, and name
+ * must match (after applying the optional context to the ref).
+ *
+ * @param entity The entity to match
+ * @param ref A string or compound entity ref
+ * @param context An optional context of default kind and namespace, that apply
+ *                to the ref if given
+ * @returns True if matching, false otherwise
+ */
+export function compareEntityToRef(
+  entity: Entity,
+  ref: EntityRef | EntityName,
+  context?: EntityRefContext,
+): boolean {
+  const entityKind = entity.kind;
+  const entityNamespace = entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE;
+  const entityName = entity.metadata.name;
+
+  let refKind: string | undefined;
+  let refNamespace: string | undefined;
+  let refName: string;
+  if (typeof ref === 'string') {
+    const parsed = parseRefString(ref);
+    refKind = parsed.kind || context?.defaultKind;
+    refNamespace =
+      parsed.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE;
+    refName = parsed.name;
+  } else {
+    refKind = ref.kind || context?.defaultKind;
+    refNamespace =
+      ref.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE;
+    refName = ref.name;
+  }
+
+  if (!refKind || !refNamespace) {
+    throw new Error(
+      `Entity reference or context did not contain kind and namespace`,
+    );
+  }
+
+  return (
+    entityKind.toLowerCase() === refKind.toLowerCase() &&
+    entityNamespace.toLowerCase() === refNamespace.toLowerCase() &&
+    entityName.toLowerCase() === refName.toLowerCase()
+  );
+}
diff --git a/packages/catalog-model/src/index.ts b/packages/catalog-model/src/index.ts
index f93a4001a5..976b5f6148 100644
--- a/packages/catalog-model/src/index.ts
+++ b/packages/catalog-model/src/index.ts
@@ -18,5 +18,5 @@ export * from './entity';
 export { EntityPolicies } from './EntityPolicies';
 export * from './kinds';
 export * from './location';
-export type { EntityName, EntityPolicy, EntityRef, JSONSchema } from './types';
+export type { EntityName, EntityRef, JSONSchema } from './types';
 export * from './validation';
diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
index 2c634ff091..5432cafdeb 100644
--- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts
@@ -14,27 +14,16 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
 import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/API.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
 
 const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
 const KIND = 'API' as const;
 
-const schema = yup.object>({
-  apiVersion: yup.string().required().oneOf(API_VERSION),
-  kind: yup.string().required().equals([KIND]),
-  spec: yup
-    .object({
-      type: yup.string().required().min(1),
-      lifecycle: yup.string().required().min(1),
-      owner: yup.string().required().min(1),
-      definition: yup.string().required().min(1),
-      system: yup.string().notRequired().min(1),
-    })
-    .required(),
-});
-
 export interface ApiEntityV1alpha1 extends Entity {
   apiVersion: typeof API_VERSION[number];
   kind: typeof KIND;
@@ -47,8 +36,9 @@ export interface ApiEntityV1alpha1 extends Entity {
   };
 }
 
-export const apiEntityV1alpha1Validator = schemaValidator(
+export const apiEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
   KIND,
   API_VERSION,
   schema,
+  [commonSchema, entityMetaSchema, entitySchema],
 );
diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
index c55c48055a..3006a4288c 100644
--- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts
@@ -14,29 +14,16 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
 import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Component.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
 
 const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
 const KIND = 'Component' as const;
 
-const schema = yup.object>({
-  apiVersion: yup.string().required().oneOf(API_VERSION),
-  kind: yup.string().required().equals([KIND]),
-  spec: yup
-    .object({
-      type: yup.string().required().min(1),
-      lifecycle: yup.string().required().min(1),
-      owner: yup.string().required().min(1),
-      subcomponentOf: yup.string().notRequired().min(1),
-      providesApis: yup.array(yup.string().required()).notRequired(),
-      consumesApis: yup.array(yup.string().required()).notRequired(),
-      system: yup.string().notRequired().min(1),
-    })
-    .required(),
-});
-
 export interface ComponentEntityV1alpha1 extends Entity {
   apiVersion: typeof API_VERSION[number];
   kind: typeof KIND;
@@ -51,8 +38,9 @@ export interface ComponentEntityV1alpha1 extends Entity {
   };
 }
 
-export const componentEntityV1alpha1Validator = schemaValidator(
+export const componentEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
   KIND,
   API_VERSION,
   schema,
+  [commonSchema, entityMetaSchema, entitySchema],
 );
diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts
index 60b11aa124..7aab35e367 100644
--- a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts
@@ -14,23 +14,16 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
 import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Domain.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
 
 const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
 const KIND = 'Domain' as const;
 
-const schema = yup.object>({
-  apiVersion: yup.string().required().oneOf(API_VERSION),
-  kind: yup.string().required().equals([KIND]),
-  spec: yup
-    .object({
-      owner: yup.string().required().min(1),
-    })
-    .required(),
-});
-
 export interface DomainEntityV1alpha1 extends Entity {
   apiVersion: typeof API_VERSION[number];
   kind: typeof KIND;
@@ -39,8 +32,9 @@ export interface DomainEntityV1alpha1 extends Entity {
   };
 }
 
-export const domainEntityV1alpha1Validator = schemaValidator(
+export const domainEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
   KIND,
   API_VERSION,
   schema,
+  [commonSchema, entityMetaSchema, entitySchema],
 );
diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
index 6f2664af77..d039fdaba8 100644
--- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts
@@ -14,40 +14,16 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
 import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Group.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
 
 const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
 const KIND = 'Group' as const;
 
-const schema = yup.object>({
-  apiVersion: yup.string().required().oneOf(API_VERSION),
-  kind: yup.string().required().equals([KIND]),
-  spec: yup
-    .object({
-      type: yup.string().required().min(1),
-      profile: yup
-        .object({
-          displayName: yup.string().min(1).notRequired(),
-          email: yup.string().min(1).notRequired(),
-          picture: yup.string().min(1).notRequired(),
-        })
-        .notRequired(),
-      parent: yup.string().notRequired().min(1),
-      // Use these manual tests because yup .required() requires at least
-      // one element and there is no simple workaround -_-
-      // the cast is there to convince typescript that the array itself is
-      // required without using .required()
-      children: yup.array(yup.string().required()).test({
-        name: 'isDefined',
-        message: 'children must be defined',
-        test: v => Boolean(v),
-      }) as yup.ArraySchema,
-    })
-    .required(),
-});
-
 export interface GroupEntityV1alpha1 extends Entity {
   apiVersion: typeof API_VERSION[number];
   kind: typeof KIND;
@@ -63,8 +39,9 @@ export interface GroupEntityV1alpha1 extends Entity {
   };
 }
 
-export const groupEntityV1alpha1Validator = schemaValidator(
+export const groupEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
   KIND,
   API_VERSION,
   schema,
+  [commonSchema, entityMetaSchema, entitySchema],
 );
diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
index 9cd767de94..fb452b6ac7 100644
--- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts
@@ -14,25 +14,16 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
 import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Location.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
 
 const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
 const KIND = 'Location' as const;
 
-const schema = yup.object>({
-  apiVersion: yup.string().required().oneOf(API_VERSION),
-  kind: yup.string().required().equals([KIND]),
-  spec: yup
-    .object({
-      type: yup.string().notRequired().min(1),
-      target: yup.string().notRequired().min(1),
-      targets: yup.array(yup.string().required()).notRequired(),
-    })
-    .required(),
-});
-
 export interface LocationEntityV1alpha1 extends Entity {
   apiVersion: typeof API_VERSION[number];
   kind: typeof KIND;
@@ -43,8 +34,9 @@ export interface LocationEntityV1alpha1 extends Entity {
   };
 }
 
-export const locationEntityV1alpha1Validator = schemaValidator(
+export const locationEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
   KIND,
   API_VERSION,
   schema,
+  [commonSchema, entityMetaSchema, entitySchema],
 );
diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts
index 12df7f6664..520a39f02c 100644
--- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts
@@ -14,25 +14,16 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
 import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/Resource.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
 
 const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
 const KIND = 'Resource' as const;
 
-const schema = yup.object>({
-  apiVersion: yup.string().required().oneOf(API_VERSION),
-  kind: yup.string().required().equals([KIND]),
-  spec: yup
-    .object({
-      type: yup.string().required().min(1),
-      owner: yup.string().required().min(1),
-      system: yup.string().notRequired().min(1),
-    })
-    .required(),
-});
-
 export interface ResourceEntityV1alpha1 extends Entity {
   apiVersion: typeof API_VERSION[number];
   kind: typeof KIND;
@@ -43,8 +34,9 @@ export interface ResourceEntityV1alpha1 extends Entity {
   };
 }
 
-export const resourceEntityV1alpha1Validator = schemaValidator(
+export const resourceEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
   KIND,
   API_VERSION,
   schema,
+  [commonSchema, entityMetaSchema, entitySchema],
 );
diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts
index 764514efdd..1ee19466f0 100644
--- a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts
@@ -14,24 +14,16 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
 import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/System.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
 
 const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
 const KIND = 'System' as const;
 
-const schema = yup.object>({
-  apiVersion: yup.string().required().oneOf(API_VERSION),
-  kind: yup.string().required().equals([KIND]),
-  spec: yup
-    .object({
-      owner: yup.string().required().min(1),
-      domain: yup.string().notRequired().min(1),
-    })
-    .required(),
-});
-
 export interface SystemEntityV1alpha1 extends Entity {
   apiVersion: typeof API_VERSION[number];
   kind: typeof KIND;
@@ -41,8 +33,9 @@ export interface SystemEntityV1alpha1 extends Entity {
   };
 }
 
-export const systemEntityV1alpha1Validator = schemaValidator(
+export const systemEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
   KIND,
   API_VERSION,
   schema,
+  [commonSchema, entityMetaSchema, entitySchema],
 );
diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
index b16fcdc7e4..72479c7efa 100644
--- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts
@@ -14,27 +14,17 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
 import type { Entity } from '../entity/Entity';
+import schema from '../schema/kinds/Template.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
 import type { JSONSchema } from '../types';
-import { schemaValidator } from './util';
+import { ajvCompiledJsonSchemaValidator } from './util';
 
 const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
 const KIND = 'Template' as const;
 
-const schema = yup.object>({
-  apiVersion: yup.string().required().oneOf(API_VERSION),
-  kind: yup.string().required().equals([KIND]),
-  spec: yup
-    .object({
-      type: yup.string().required().min(1),
-      path: yup.string(),
-      schema: yup.object().required(),
-      templater: yup.string().required(),
-    })
-    .required(),
-});
-
 export interface TemplateEntityV1alpha1 extends Entity {
   apiVersion: typeof API_VERSION[number];
   kind: typeof KIND;
@@ -46,8 +36,9 @@ export interface TemplateEntityV1alpha1 extends Entity {
   };
 }
 
-export const templateEntityV1alpha1Validator = schemaValidator(
+export const templateEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
   KIND,
   API_VERSION,
   schema,
+  [commonSchema, entityMetaSchema, entitySchema],
 );
diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
index 16a5a86e05..a8700a496e 100644
--- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
+++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts
@@ -14,38 +14,16 @@
  * limitations under the License.
  */
 
-import * as yup from 'yup';
 import type { Entity } from '../entity/Entity';
-import { schemaValidator } from './util';
+import schema from '../schema/kinds/User.v1alpha1.schema.json';
+import entitySchema from '../schema/Entity.schema.json';
+import entityMetaSchema from '../schema/EntityMeta.schema.json';
+import commonSchema from '../schema/shared/common.schema.json';
+import { ajvCompiledJsonSchemaValidator } from './util';
 
 const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
 const KIND = 'User' as const;
 
-const schema = yup.object>({
-  apiVersion: yup.string().required().oneOf(API_VERSION),
-  kind: yup.string().required().equals([KIND]),
-  spec: yup
-    .object({
-      profile: yup
-        .object({
-          displayName: yup.string().min(1).notRequired(),
-          email: yup.string().min(1).notRequired(),
-          picture: yup.string().min(1).notRequired(),
-        })
-        .notRequired(),
-      // Use this manual test because yup .required() requires at least one
-      // element and there is no simple workaround -_-
-      // the cast is there to convince typescript that the array itself is
-      // required without using .required()
-      memberOf: yup.array(yup.string().required()).test({
-        name: 'isDefined',
-        message: 'memberOf must be defined',
-        test: v => Boolean(v),
-      }) as yup.ArraySchema,
-    })
-    .required(),
-});
-
 export interface UserEntityV1alpha1 extends Entity {
   apiVersion: typeof API_VERSION[number];
   kind: typeof KIND;
@@ -59,8 +37,9 @@ export interface UserEntityV1alpha1 extends Entity {
   };
 }
 
-export const userEntityV1alpha1Validator = schemaValidator(
+export const userEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator(
   KIND,
   API_VERSION,
   schema,
+  [commonSchema, entityMetaSchema, entitySchema],
 );
diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts
index f1b02cfba1..4002f2806c 100644
--- a/packages/catalog-model/src/kinds/util.ts
+++ b/packages/catalog-model/src/kinds/util.ts
@@ -14,9 +14,13 @@
  * limitations under the License.
  */
 
+import Ajv, { AnySchema } from 'ajv';
 import * as yup from 'yup';
 import { KindValidator } from './types';
 
+/**
+ * @deprecated We no longer use yup for the catalog model. This utility method will be removed.
+ */
 export function schemaValidator(
   kind: string,
   apiVersion: readonly string[],
@@ -24,10 +28,7 @@ export function schemaValidator(
 ): KindValidator {
   return {
     async check(envelope) {
-      if (
-        kind !== envelope.kind ||
-        !apiVersion.includes(envelope.apiVersion as any)
-      ) {
+      if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) {
         return false;
       }
       await schema.validate(envelope, { strict: true });
@@ -35,3 +36,38 @@ export function schemaValidator(
     },
   };
 }
+
+export function ajvCompiledJsonSchemaValidator(
+  kind: string,
+  apiVersion: readonly string[],
+  schema: AnySchema,
+  extraSchemas?: AnySchema[],
+): KindValidator {
+  const ajv = new Ajv({ allowUnionTypes: true });
+  if (extraSchemas) {
+    ajv.addSchema(extraSchemas, undefined, undefined, true);
+  }
+  const validate = ajv.compile(schema);
+
+  return {
+    async check(envelope) {
+      if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) {
+        return false;
+      }
+
+      const result = validate(envelope);
+      if (result === true) {
+        return true;
+      }
+
+      const [error] = validate.errors || [];
+      if (!error) {
+        throw new TypeError(`Malformed ${kind}, Unknown error`);
+      }
+
+      throw new TypeError(
+        `Malformed ${kind}, ${error.dataPath || ''} ${error.message}`,
+      );
+    },
+  };
+}
diff --git a/packages/catalog-model/src/schema/Entity.schema.json b/packages/catalog-model/src/schema/Entity.schema.json
new file mode 100644
index 0000000000..803e025796
--- /dev/null
+++ b/packages/catalog-model/src/schema/Entity.schema.json
@@ -0,0 +1,67 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "Entity",
+  "description": "The format envelope that's common to all versions/kinds of entity.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "Component",
+      "metadata": {
+        "name": "LoremService",
+        "description": "Creates Lorems like a pro.",
+        "labels": {
+          "product_name": "Random value Generator"
+        },
+        "annotations": {
+          "docs": "https://github.com/..../tree/develop/doc"
+        }
+      },
+      "spec": {
+        "type": "service",
+        "lifecycle": "production",
+        "owner": "tools"
+      }
+    }
+  ],
+  "type": "object",
+  "required": ["apiVersion", "kind", "metadata"],
+  "additionalProperties": false,
+  "properties": {
+    "apiVersion": {
+      "type": "string",
+      "description": "The version of specification format for this particular entity that this is written against.",
+      "minLength": 1,
+      "examples": ["backstage.io/v1alpha1", "my-company.net/v1", "1.0"]
+    },
+    "kind": {
+      "type": "string",
+      "description": "The high level entity type being described.",
+      "minLength": 1,
+      "examples": [
+        "API",
+        "Component",
+        "Domain",
+        "Group",
+        "Location",
+        "Resource",
+        "System",
+        "Template",
+        "User"
+      ]
+    },
+    "metadata": {
+      "$ref": "EntityMeta"
+    },
+    "spec": {
+      "type": "object",
+      "description": "The specification data describing the entity itself."
+    },
+    "relations": {
+      "type": "array",
+      "description": "The relations that this entity has with other entities.",
+      "items": {
+        "$ref": "common#relation"
+      }
+    }
+  }
+}
diff --git a/packages/catalog-model/src/schema/EntityMeta.schema.json b/packages/catalog-model/src/schema/EntityMeta.schema.json
new file mode 100644
index 0000000000..ff0f9c84a8
--- /dev/null
+++ b/packages/catalog-model/src/schema/EntityMeta.schema.json
@@ -0,0 +1,117 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "EntityMeta",
+  "description": "Metadata fields common to all versions/kinds of entity.",
+  "examples": [
+    {
+      "uid": "e01199ab-08cc-44c2-8e19-5c29ded82521",
+      "etag": "lsndfkjsndfkjnsdfkjnsd==",
+      "generation": 13,
+      "name": "my-component-yay",
+      "namespace": "the-namespace",
+      "labels": {
+        "backstage.io/custom": "ValueStuff"
+      },
+      "annotations": {
+        "example.com/bindings": "are-secret"
+      },
+      "tags": ["java", "data"]
+    }
+  ],
+  "type": "object",
+  "required": ["name"],
+  "additionalProperties": true,
+  "properties": {
+    "uid": {
+      "type": "string",
+      "description": "A globally unique ID for the entity. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, but the server is free to reject requests that do so in such a way that it breaks semantics.",
+      "examples": ["e01199ab-08cc-44c2-8e19-5c29ded82521"],
+      "minLength": 1
+    },
+    "etag": {
+      "type": "string",
+      "description": "An opaque string that changes for each update operation to any part of the entity, including metadata. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, and the server will then reject the operation if it does not match the current stored value.",
+      "examples": ["lsndfkjsndfkjnsdfkjnsd=="],
+      "minLength": 1
+    },
+    "generation": {
+      "type": "integer",
+      "description": "A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations.",
+      "examples": [1],
+      "minimum": 1
+    },
+    "name": {
+      "type": "string",
+      "description": "The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.",
+      "examples": ["metadata-proxy"],
+      "minLength": 1
+    },
+    "namespace": {
+      "type": "string",
+      "description": "The namespace that the entity belongs to.",
+      "default": "default",
+      "examples": ["default", "admin"],
+      "minLength": 1
+    },
+    "description": {
+      "type": "string",
+      "description": "A short (typically relatively few words, on one line) description of the entity."
+    },
+    "labels": {
+      "type": "object",
+      "description": "Key/value pairs of identifying information attached to the entity.",
+      "additionalProperties": true,
+      "patternProperties": {
+        "^.+$": {
+          "type": "string"
+        }
+      }
+    },
+    "annotations": {
+      "type": "object",
+      "description": "Key/value pairs of non-identifying auxiliary information attached to the entity.",
+      "additionalProperties": true,
+      "patternProperties": {
+        "^.+$": {
+          "type": "string"
+        }
+      }
+    },
+    "tags": {
+      "type": "array",
+      "description": "A list of single-valued strings, to for example classify catalog entities in various ways.",
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "links": {
+      "type": "array",
+      "description": "A list of external hyperlinks related to the entity. Links can provide additional contextual information that may be located outside of Backstage itself. For example, an admin dashboard or external CMS page.",
+      "items": {
+        "type": "object",
+        "required": ["url"],
+        "properties": {
+          "url": {
+            "type": "string",
+            "description": "A url in a standard uri format.",
+            "examples": ["https://admin.example-org.com"],
+            "minLength": 1
+          },
+          "title": {
+            "type": "string",
+            "description": "A user friendly display name for the link.",
+            "examples": ["Admin Dashboard"],
+            "minLength": 1
+          },
+          "icon": {
+            "type": "string",
+            "description": "A key representing a visual icon to be displayed in the UI.",
+            "examples": ["dashboard"],
+            "minLength": 1
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json
new file mode 100644
index 0000000000..fae1225764
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json
@@ -0,0 +1,79 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "ApiV1alpha1",
+  "description": "An API describes an interface that can be exposed by a component. The API can be defined in different formats, like OpenAPI, AsyncAPI, GraphQL, gRPC, or other formats.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "API",
+      "metadata": {
+        "name": "artist-api",
+        "description": "Retrieve artist details",
+        "labels": {
+          "product_name": "Random value Generator"
+        },
+        "annotations": {
+          "docs": "https://github.com/..../tree/develop/doc"
+        }
+      },
+      "spec": {
+        "type": "openapi",
+        "lifecycle": "production",
+        "owner": "artist-relations-team",
+        "system": "artist-engagement-portal",
+        "definition": "openapi: \"3.0.0\"\ninfo:..."
+      }
+    }
+  ],
+  "allOf": [
+    {
+      "$ref": "Entity"
+    },
+    {
+      "type": "object",
+      "required": ["spec"],
+      "properties": {
+        "apiVersion": {
+          "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+        },
+        "kind": {
+          "enum": ["API"]
+        },
+        "spec": {
+          "type": "object",
+          "required": ["type", "lifecycle", "owner", "definition"],
+          "properties": {
+            "type": {
+              "type": "string",
+              "description": "The type of the API definition.",
+              "examples": ["openapi", "asyncapi", "graphql", "grpc"],
+              "minLength": 1
+            },
+            "lifecycle": {
+              "type": "string",
+              "description": "The lifecycle state of the API.",
+              "examples": ["experimental", "production", "deprecated"],
+              "minLength": 1
+            },
+            "owner": {
+              "type": "string",
+              "description": "An entity reference to the owner of the API.",
+              "examples": ["artist-relations-team", "user:john.johnson"],
+              "minLength": 1
+            },
+            "system": {
+              "type": "string",
+              "description": "An entity reference to the system that the API belongs to.",
+              "minLength": 1
+            },
+            "definition": {
+              "type": "string",
+              "description": "The definition of the API, based on the format defined by the type.",
+              "minLength": 1
+            }
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json
new file mode 100644
index 0000000000..822059a0ea
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json
@@ -0,0 +1,93 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "ComponentV1alpha1",
+  "description": "A Component describes a software component. It is typically intimately linked to the source code that constitutes the component, and should be what a developer may regard a \"unit of software\", usually with a distinct deployable or linkable artifact.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "Component",
+      "metadata": {
+        "name": "LoremService",
+        "description": "Creates Lorems like a pro.",
+        "labels": {
+          "product_name": "Random value Generator"
+        },
+        "annotations": {
+          "docs": "https://github.com/..../tree/develop/doc"
+        }
+      },
+      "spec": {
+        "type": "service",
+        "lifecycle": "production",
+        "owner": "tools"
+      }
+    }
+  ],
+  "allOf": [
+    {
+      "$ref": "Entity"
+    },
+    {
+      "type": "object",
+      "required": ["spec"],
+      "properties": {
+        "apiVersion": {
+          "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+        },
+        "kind": {
+          "enum": ["Component"]
+        },
+        "spec": {
+          "type": "object",
+          "required": ["type", "lifecycle", "owner"],
+          "properties": {
+            "type": {
+              "type": "string",
+              "description": "The type of component.",
+              "examples": ["service", "website", "library"],
+              "minLength": 1
+            },
+            "lifecycle": {
+              "type": "string",
+              "description": "The lifecycle state of the component.",
+              "examples": ["experimental", "production", "deprecated"],
+              "minLength": 1
+            },
+            "owner": {
+              "type": "string",
+              "description": "An entity reference to the owner of the component.",
+              "examples": ["artist-relations-team", "user:john.johnson"],
+              "minLength": 1
+            },
+            "system": {
+              "type": "string",
+              "description": "An entity reference to the system that the component belongs to.",
+              "minLength": 1
+            },
+            "subcomponentOf": {
+              "type": "string",
+              "description": "An entity reference to another component of which the component is a part.",
+              "minLength": 1
+            },
+            "providesApis": {
+              "type": "array",
+              "description": "An array of entity references to the APIs that are provided by the component.",
+              "items": {
+                "type": "string",
+                "minLength": 1
+              }
+            },
+            "consumesApis": {
+              "type": "array",
+              "description": "An array of entity references to the APIs that are consumed by the component.",
+              "items": {
+                "type": "string",
+                "minLength": 1
+              }
+            }
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json
new file mode 100644
index 0000000000..a1a09b1213
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json
@@ -0,0 +1,47 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "DomainV1alpha1",
+  "description": "A Domain groups a collection of systems that share terminology, domain models, business purpose, or documentation, i.e. form a bounded context.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "Domain",
+      "metadata": {
+        "name": "artists",
+        "description": "Everything about artists"
+      },
+      "spec": {
+        "owner": "artist-relations-team"
+      }
+    }
+  ],
+  "allOf": [
+    {
+      "$ref": "Entity"
+    },
+    {
+      "type": "object",
+      "required": ["spec"],
+      "properties": {
+        "apiVersion": {
+          "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+        },
+        "kind": {
+          "enum": ["Domain"]
+        },
+        "spec": {
+          "type": "object",
+          "required": ["owner"],
+          "properties": {
+            "owner": {
+              "type": "string",
+              "description": "An entity reference to the owner of the component.",
+              "examples": ["artist-relations-team", "user:john.johnson"],
+              "minLength": 1
+            }
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json
new file mode 100644
index 0000000000..fc19d98301
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json
@@ -0,0 +1,95 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "GroupV1alpha1",
+  "description": "A group describes an organizational entity, such as for example a team, a business unit, or a loose collection of people in an interest group. Members of these groups are modeled in the catalog as kind User.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "Group",
+      "metadata": {
+        "name": "infrastructure",
+        "description": "The infra business unit"
+      },
+      "spec": {
+        "type": "business-unit",
+        "profile": {
+          "displayName": "Infrastructure",
+          "email": "infrastructure@example.com",
+          "picture": "https://example.com/groups/bu-infrastructure.jpeg"
+        },
+        "parent": "ops",
+        "children": ["backstage", "other"]
+      }
+    }
+  ],
+  "allOf": [
+    {
+      "$ref": "Entity"
+    },
+    {
+      "type": "object",
+      "required": ["spec"],
+      "properties": {
+        "apiVersion": {
+          "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+        },
+        "kind": {
+          "enum": ["Group"]
+        },
+        "spec": {
+          "type": "object",
+          "required": ["type", "children"],
+          "properties": {
+            "type": {
+              "type": "string",
+              "description": "The type of group. There is currently no enforced set of values for this field, so it is left up to the adopting organization to choose a nomenclature that matches their org hierarchy.",
+              "examples": ["team", "business-unit", "product-area", "root"],
+              "minLength": 1
+            },
+            "profile": {
+              "type": "object",
+              "description": "Optional profile information about the group, mainly for display purposes. All fields of this structure are also optional. The email would be a group email of some form, that the group may wish to be used for contacting them. The picture is expected to be a URL pointing to an image that's representative of the group, and that a browser could fetch and render on a group page or similar.",
+              "properties": {
+                "displayName": {
+                  "type": "string",
+                  "description": "A simple display name to present to users.",
+                  "examples": ["Infrastructure"],
+                  "minLength": 1
+                },
+                "email": {
+                  "type": "string",
+                  "description": "An email where this entity can be reached.",
+                  "examples": ["infrastructure@example.com"],
+                  "minLength": 1
+                },
+                "picture": {
+                  "type": "string",
+                  "description": "The URL of an image that represents this entity.",
+                  "examples": [
+                    "https://example.com/groups/bu-infrastructure.jpeg"
+                  ],
+                  "minLength": 1
+                }
+              }
+            },
+            "parent": {
+              "type": "string",
+              "description": "The immediate parent group in the hierarchy, if any. Not all groups must have a parent; the catalog supports multi-root hierarchies. Groups may however not have more than one parent. This field is an entity reference.",
+              "examples": ["ops"],
+              "minLength": 1
+            },
+            "children": {
+              "type": "array",
+              "description": "The immediate child groups of this group in the hierarchy (whose parent field points to this group). The list must be present, but may be empty if there are no child groups. The items are not guaranteed to be ordered in any particular way. The entries of this array are entity references.",
+              "items": {
+                "type": "string",
+                "examples": ["backstage", "other"],
+                "minLength": 1
+              }
+            }
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json
new file mode 100644
index 0000000000..d633d30229
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json
@@ -0,0 +1,68 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "LocationV1alpha1",
+  "description": "A location is a marker that references other places to look for catalog data.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "Location",
+      "metadata": {
+        "name": "org-data"
+      },
+      "spec": {
+        "type": "url",
+        "targets": [
+          "http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml",
+          "http://github.com/myorg/myproject/org-data-dump/catalog-info-consultants.yaml"
+        ]
+      }
+    }
+  ],
+  "allOf": [
+    {
+      "$ref": "Entity"
+    },
+    {
+      "type": "object",
+      "required": ["spec"],
+      "properties": {
+        "apiVersion": {
+          "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+        },
+        "kind": {
+          "enum": ["Location"]
+        },
+        "spec": {
+          "type": "object",
+          "required": [],
+          "properties": {
+            "type": {
+              "type": "string",
+              "description": "The single location type, that's common to the targets specified in the spec. If it is left out, it is inherited from the location type that originally read the entity data.",
+              "examples": ["url"],
+              "minLength": 1
+            },
+            "target": {
+              "type": "string",
+              "description": "A single target as a string. Can be either an absolute path/URL (depending on the type), or a relative path such as ./details/catalog-info.yaml which is resolved relative to the location of this Location entity itself.",
+              "examples": ["./details/catalog-info.yaml"],
+              "minLength": 1
+            },
+            "targets": {
+              "type": "array",
+              "description": "A list of targets as strings. They can all be either absolute paths/URLs (depending on the type), or relative paths such as ./details/catalog-info.yaml which are resolved relative to the location of this Location entity itself.",
+              "items": {
+                "type": "string",
+                "examples": [
+                  "./details/catalog-info.yaml",
+                  "http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml"
+                ],
+                "minLength": 1
+              }
+            }
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json
new file mode 100644
index 0000000000..b426dd7f94
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json
@@ -0,0 +1,60 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "ResourceV1alpha1",
+  "description": "A resource describes the infrastructure a system needs to operate, like BigTable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and systems allows to visualize resource footprint, and create tooling around them.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "Resource",
+      "metadata": {
+        "name": "artists-db",
+        "description": "Stores artist details"
+      },
+      "spec": {
+        "type": "database",
+        "owner": "artist-relations-team",
+        "system": "artist-engagement-portal"
+      }
+    }
+  ],
+  "allOf": [
+    {
+      "$ref": "Entity"
+    },
+    {
+      "type": "object",
+      "required": ["spec"],
+      "properties": {
+        "apiVersion": {
+          "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+        },
+        "kind": {
+          "enum": ["Resource"]
+        },
+        "spec": {
+          "type": "object",
+          "required": ["type", "owner"],
+          "properties": {
+            "type": {
+              "type": "string",
+              "description": "The type of resource.",
+              "examples": ["database", "s3-bucket", "cluster"],
+              "minLength": 1
+            },
+            "owner": {
+              "type": "string",
+              "description": "An entity reference to the owner of the resource.",
+              "examples": ["artist-relations-team", "user:john.johnson"],
+              "minLength": 1
+            },
+            "system": {
+              "type": "string",
+              "description": "An entity reference to the system that the resource belongs to.",
+              "minLength": 1
+            }
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json
new file mode 100644
index 0000000000..2cdbc37076
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json
@@ -0,0 +1,54 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "SystemV1alpha1",
+  "description": "A system is a collection of resources and components. The system may expose or consume one or several APIs. It is viewed as abstraction level that provides potential consumers insights into exposed features without needing a too detailed view into the details of all components. This also gives the owning team the possibility to decide about published artifacts and APIs.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "System",
+      "metadata": {
+        "name": "artist-engagement-portal",
+        "description": "Handy tools to keep artists in the loop"
+      },
+      "spec": {
+        "owner": "artist-relations-team",
+        "domain": "artists"
+      }
+    }
+  ],
+  "allOf": [
+    {
+      "$ref": "Entity"
+    },
+    {
+      "type": "object",
+      "required": ["spec"],
+      "properties": {
+        "apiVersion": {
+          "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+        },
+        "kind": {
+          "enum": ["System"]
+        },
+        "spec": {
+          "type": "object",
+          "required": ["owner"],
+          "properties": {
+            "owner": {
+              "type": "string",
+              "description": "An entity reference to the owner of the component.",
+              "examples": ["artist-relations-team", "user:john.johnson"],
+              "minLength": 1
+            },
+            "domain": {
+              "type": "string",
+              "description": "An entity reference to the domain that the system belongs to.",
+              "examples": ["artists"],
+              "minLength": 1
+            }
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json
new file mode 100644
index 0000000000..5bb83d116b
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json
@@ -0,0 +1,94 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "TemplateV1alpha1",
+  "description": "A Template describes a skeleton for use with the Scaffolder. It is used for describing what templating library is supported, and also for documenting the variables that the template requires using JSON Forms Schema.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "Template",
+      "metadata": {
+        "name": "react-ssr-template",
+        "title": "React SSR Template",
+        "description": "Next.js application skeleton for creating isomorphic web applications.",
+        "tags": ["recommended", "react"]
+      },
+      "spec": {
+        "owner": "artist-relations-team",
+        "templater": "cookiecutter",
+        "type": "website",
+        "path": ".",
+        "schema": {
+          "required": ["component-id", "description"],
+          "properties": {
+            "component_id": {
+              "title": "Name",
+              "type": "string",
+              "description": "Unique name of the component"
+            },
+            "description": {
+              "title": "Description",
+              "type": "string",
+              "description": "Description of the component"
+            }
+          }
+        }
+      }
+    }
+  ],
+  "allOf": [
+    {
+      "$ref": "Entity"
+    },
+    {
+      "type": "object",
+      "required": ["spec"],
+      "properties": {
+        "apiVersion": {
+          "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+        },
+        "kind": {
+          "enum": ["Template"]
+        },
+        "metadata": {
+          "type": "object",
+          "properties": {
+            "title": {
+              "type": "string",
+              "description": "The nice display name for the template. This field is required as is used to reference the template to the user instead of the metadata.name field.",
+              "examples": ["React SSR Template"],
+              "minLength": 1
+            }
+          }
+        },
+        "spec": {
+          "type": "object",
+          "required": ["type", "templater", "schema"],
+          "properties": {
+            "type": {
+              "type": "string",
+              "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.",
+              "examples": ["service", "website", "library"],
+              "minLength": 1
+            },
+            "templater": {
+              "type": "string",
+              "description": "The templating library that is supported by the template skeleton.",
+              "examples": ["cookiecutter"],
+              "minLength": 1
+            },
+            "path": {
+              "type": "string",
+              "description": "The string location where the templater should be run if it is not on the same level as the template.yaml definition.",
+              "examples": ["./cookiecutter/skeleton"],
+              "minLength": 1
+            },
+            "schema": {
+              "type": "object",
+              "description": "The JSONSchema describing the inputs for the template."
+            }
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json
new file mode 100644
index 0000000000..a71409d5ba
--- /dev/null
+++ b/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json
@@ -0,0 +1,80 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "UserV1alpha1",
+  "description": "A user describes a person, such as an employee, a contractor, or similar. Users belong to Group entities in the catalog. These catalog user entries are connected to the way that authentication within the Backstage ecosystem works. See the auth section of the docs for a discussion of these concepts.",
+  "examples": [
+    {
+      "apiVersion": "backstage.io/v1alpha1",
+      "kind": "User",
+      "metadata": {
+        "name": "jdoe"
+      },
+      "spec": {
+        "profile": {
+          "displayName": "Jenny Doe",
+          "email": "jenny-doe@example.com",
+          "picture": "https://example.com/staff/jenny-with-party-hat.jpeg"
+        },
+        "memberOf": ["team-b", "employees"]
+      }
+    }
+  ],
+  "allOf": [
+    {
+      "$ref": "Entity"
+    },
+    {
+      "type": "object",
+      "required": ["spec"],
+      "properties": {
+        "apiVersion": {
+          "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+        },
+        "kind": {
+          "enum": ["User"]
+        },
+        "spec": {
+          "type": "object",
+          "required": ["memberOf"],
+          "properties": {
+            "profile": {
+              "type": "object",
+              "description": "Optional profile information about the user, mainly for display purposes. All fields of this structure are also optional. The email would be a primary email of some form, that the user may wish to be used for contacting them. The picture is expected to be a URL pointing to an image that's representative of the user, and that a browser could fetch and render on a profile page or similar.",
+              "properties": {
+                "displayName": {
+                  "type": "string",
+                  "description": "A simple display name to present to users.",
+                  "examples": ["Jenny Doe"],
+                  "minLength": 1
+                },
+                "email": {
+                  "type": "string",
+                  "description": "An email where this user can be reached.",
+                  "examples": ["jenny-doe@example.com"],
+                  "minLength": 1
+                },
+                "picture": {
+                  "type": "string",
+                  "description": "The URL of an image that represents this user.",
+                  "examples": [
+                    "https://example.com/staff/jenny-with-party-hat.jpeg"
+                  ],
+                  "minLength": 1
+                }
+              }
+            },
+            "memberOf": {
+              "type": "array",
+              "description": "The list of groups that the user is a direct member of (i.e., no transitive memberships are listed here). The list must be present, but may be empty if the user is not member of any groups. The items are not guaranteed to be ordered in any particular way. The entries of this array are entity references.",
+              "items": {
+                "type": "string",
+                "examples": ["team-b", "employees"],
+                "minLength": 1
+              }
+            }
+          }
+        }
+      }
+    }
+  ]
+}
diff --git a/packages/catalog-model/src/schema/shared/common.schema.json b/packages/catalog-model/src/schema/shared/common.schema.json
new file mode 100644
index 0000000000..84ae8a4147
--- /dev/null
+++ b/packages/catalog-model/src/schema/shared/common.schema.json
@@ -0,0 +1,50 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "$id": "common",
+  "type": "object",
+  "description": "Common definitions to import from other schemas",
+  "definitions": {
+    "reference": {
+      "$id": "#reference",
+      "type": "object",
+      "description": "A reference by name to another entity.",
+      "required": ["kind", "namespace", "name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": {
+          "type": "string",
+          "description": "The kind field of the entity."
+        },
+        "namespace": {
+          "type": "string",
+          "description": "The metadata.namespace field of the entity."
+        },
+        "name": {
+          "type": "string",
+          "description": "The metadata.name field of the entity."
+        }
+      }
+    },
+    "relation": {
+      "$id": "#relation",
+      "type": "object",
+      "description": "A directed relation from one entity to another.",
+      "required": ["type", "source", "target"],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "minLength": 1,
+          "pattern": "^\\w+$",
+          "description": "The type of relation."
+        },
+        "source": {
+          "$ref": "#reference"
+        },
+        "target": {
+          "$ref": "#reference"
+        }
+      }
+    }
+  }
+}
diff --git a/packages/catalog-model/src/types.ts b/packages/catalog-model/src/types.ts
index aa96e46102..edac03466d 100644
--- a/packages/catalog-model/src/types.ts
+++ b/packages/catalog-model/src/types.ts
@@ -16,23 +16,6 @@
 
 import { JsonValue } from '@backstage/config';
 import { JSONSchema7 } from 'json-schema';
-import type { Entity } from './entity/Entity';
-
-/**
- * A policy for validation or mutation to be applied to entities as they are
- * entering the system.
- */
-export type EntityPolicy = {
-  /**
-   * Applies validation or mutation on an entity.
-   *
-   * @param entity The entity, as validated/mutated so far in the policy tree
-   * @returns The incoming entity, or a mutated version of the same, or
-   *          undefined if this processor could not handle the entity
-   * @throws An error if the entity should be rejected
-   */
-  enforce(entity: Entity): Promise;
-};
 
 export type JSONSchema = JSONSchema7 & { [key in string]?: JsonValue };
 
diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
index 129fdd2b62..bef997d54a 100644
--- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
+++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts
@@ -161,4 +161,44 @@ describe('CommonValidatorFunctions', () => {
   ])(`isValidDnsLabel %p ? %p`, (value, result) => {
     expect(CommonValidatorFunctions.isValidDnsLabel(value)).toBe(result);
   });
+
+  it.each([
+    [null, false],
+    [7, false],
+    ['', false],
+    ['abc', false],
+    ['   abc', false],
+    ['', false],
+    [{}, false],
+    ['http://foo', true],
+    ['https://www.foo.com/', true],
+    ['https://foo.com:8080', true],
+    ['https://foo:8080/page', true],
+    ['https://foo:8080/sub/page', true],
+    ['https://foo:8080/sub/page?query', true],
+    ['https://foo:8080/sub/page/?query=value', true],
+    ['https://foo:8080/sub/page/?query=value&', true],
+    ['https://foo:8080/sub/page/?query=value&another=val', true],
+    ['https://foo.com/page#fragment', true],
+    ['ftp://ftp.some.domain.com/path', true],
+    ['xyz://custom-protocol:4444/path', true],
+  ])(`isValidUrl %p ? %p`, (value, result) => {
+    expect(CommonValidatorFunctions.isValidUrl(value)).toBe(result);
+  });
+
+  it.each([
+    [null, false],
+    [true, false],
+    [7, false],
+    [{}, false],
+    ['', false],
+    [' ', false],
+    ['    ', false],
+    ['abc', true],
+    [' abc ', true],
+    ['abc xyz', true],
+    ['abc xyz abc.', true],
+  ])(`isValidString %p ? %p`, (value, result) => {
+    expect(CommonValidatorFunctions.isValidString(value)).toBe(result);
+  });
 });
diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
index 90b4e1a611..87b6ad3838 100644
--- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
+++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts
@@ -92,4 +92,32 @@ export class CommonValidatorFunctions {
       /^[a-z0-9]+(\-[a-z0-9]+)*$/.test(value)
     );
   }
+
+  /**
+   * Checks that the value is a valid URL.
+   *
+   * @param value The value to check
+   */
+  static isValidUrl(value: unknown): boolean {
+    if (typeof value !== 'string') {
+      return false;
+    }
+
+    try {
+      // eslint-disable-next-line no-new
+      new URL(value);
+      return true;
+    } catch {
+      return false;
+    }
+  }
+
+  /**
+   * Checks that the value is a non empty string value.
+   *
+   * @param value The value to check
+   */
+  static isValidString(value: unknown): boolean {
+    return typeof value === 'string' && value?.trim()?.length >= 1;
+  }
 }
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index 7998d408e0..29cce97ae9 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,32 @@
 # @backstage/cli
 
+## 0.5.0
+
+### Minor Changes
+
+- 12a56cdfe: We've bumped the `@eslint-typescript` packages to the latest, which now add some additional rules that might cause lint failures.
+  The main one which could become an issue is the [no-use-before-define](https://eslint.org/docs/rules/no-use-before-define) rule.
+
+  Every plugin and app has the ability to override these rules if you want to ignore them for now.
+
+  You can reset back to the default behaviour by using the following in your own `.eslint.js`
+
+  ```js
+  rules: {
+    'no-use-before-define': 'off'
+  }
+  ```
+
+  Because of the nature of this change, we're unable to provide a grace period for the update :(
+
+### Patch Changes
+
+- ef7957be4: Add `--lax` option to `config:print` and `config:check`, which causes all environment variables to be assumed to be set.
+- Updated dependencies [ef7957be4]
+- Updated dependencies [ef7957be4]
+- Updated dependencies [ef7957be4]
+  - @backstage/config-loader@0.5.0
+
 ## 0.4.7
 
 ### Patch Changes
diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js
index 7acb538d38..e7fd4cb688 100644
--- a/packages/cli/config/eslint.backend.js
+++ b/packages/cli/config/eslint.backend.js
@@ -38,8 +38,10 @@ module.exports = {
   },
   ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'],
   rules: {
-    '@typescript-eslint/no-shadow': 'off',
-    '@typescript-eslint/no-redeclare': 'off',
+    'no-shadow': 'off',
+    'no-redeclare': 'off',
+    '@typescript-eslint/no-shadow': 'error',
+    '@typescript-eslint/no-redeclare': 'error',
 
     'no-console': 0, // Permitted in console programs
     'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js
index 044d737d15..37bd7bed94 100644
--- a/packages/cli/config/eslint.js
+++ b/packages/cli/config/eslint.js
@@ -45,8 +45,10 @@ module.exports = {
   },
   ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'],
   rules: {
-    '@typescript-eslint/no-shadow': 'off',
-    '@typescript-eslint/no-redeclare': 'off',
+    'no-shadow': 'off',
+    'no-redeclare': 'off',
+    '@typescript-eslint/no-shadow': 'error',
+    '@typescript-eslint/no-redeclare': 'error',
     'no-undef': 'off',
     'import/newline-after-import': 'error',
     'import/no-duplicates': 'warn',
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a98534ecb6..ed373f6058 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.4.7",
+  "version": "0.5.0",
   "private": false,
   "publishConfig": {
     "access": "public"
@@ -30,12 +30,12 @@
   "dependencies": {
     "@backstage/cli-common": "^0.1.1",
     "@backstage/config": "^0.1.2",
-    "@backstage/config-loader": "^0.4.1",
+    "@backstage/config-loader": "^0.5.0",
     "@hot-loader/react-dom": "^16.13.0",
     "@lerna/package-graph": "^3.18.5",
     "@lerna/project": "^3.18.0",
     "@octokit/request": "^5.4.12",
-    "@rollup/plugin-commonjs": "^16.0.0",
+    "@rollup/plugin-commonjs": "^17.1.0",
     "@rollup/plugin-json": "^4.0.2",
     "@rollup/plugin-node-resolve": "^9.0.0",
     "@rollup/plugin-yaml": "^2.1.1",
@@ -113,7 +113,7 @@
     "yn": "^4.0.0"
   },
   "devDependencies": {
-    "@backstage/backend-common": "^0.5.0",
+    "@backstage/backend-common": "^0.5.1",
     "@backstage/config": "^0.1.2",
     "@backstage/core": "^0.5.0",
     "@backstage/dev-utils": "^0.1.8",
@@ -173,7 +173,9 @@
             "type": "string",
             "visibility": "frontend",
             "description": "Tracking ID for Google Analytics",
-            "example": "UA-000000-0"
+            "examples": [
+              "UA-000000-0"
+            ]
           },
           "listen": {
             "type": "object",
diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts
index dc165ecb90..b542030109 100644
--- a/packages/cli/src/commands/app/serve.ts
+++ b/packages/cli/src/commands/app/serve.ts
@@ -15,12 +15,43 @@
  */
 
 import fs from 'fs-extra';
+import chalk from 'chalk';
+import uniq from 'lodash/uniq';
 import { Command } from 'commander';
 import { serveBundle } from '../../lib/bundler';
 import { loadCliConfig } from '../../lib/config';
 import { paths } from '../../lib/paths';
+import { Lockfile } from '../../lib/versioning';
+import { includedFilter } from '../versions/lint';
 
 export default async (cmd: Command) => {
+  const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
+  const result = lockfile.analyze({
+    filter: includedFilter,
+  });
+  const problemPackages = [...result.newVersions, ...result.newRanges].map(
+    ({ name }) => name,
+  );
+
+  if (problemPackages.length > 1) {
+    console.log(
+      chalk.yellow(
+        `⚠️ Some of the following packages may be outdated or have duplicate installations:
+
+          ${uniq(problemPackages).join(', ')}
+        `,
+      ),
+    );
+    console.log(
+      chalk.yellow(
+        `⚠️ This can be resolved using the following command:
+
+          yarn backstage-cli versions:check --fix
+      `,
+      ),
+    );
+  }
+
   const { name } = await fs.readJson(paths.resolveTarget('package.json'));
   const waitForExit = await serveBundle({
     entry: 'src/index',
diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts
index 56dcd3f753..930f2c98ca 100644
--- a/packages/cli/src/commands/config/print.ts
+++ b/packages/cli/src/commands/config/print.ts
@@ -63,8 +63,8 @@ function serializeConfigData(
   }
 
   const sanitizedConfigs = schema.process(appConfigs, {
-    valueTransform: (value, { visibility }) =>
-      visibility === 'secret' ? '' : value,
+    valueTransform: (value, context) =>
+      context.visibility === 'secret' ? '' : value,
   });
 
   return ConfigReader.fromConfigs(sanitizedConfigs).get();
diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts
index a2005c5580..affbf64922 100644
--- a/packages/cli/src/commands/create-plugin/createPlugin.ts
+++ b/packages/cli/src/commands/create-plugin/createPlugin.ts
@@ -20,6 +20,8 @@ import chalk from 'chalk';
 import inquirer, { Answers, Question } from 'inquirer';
 import { exec as execCb } from 'child_process';
 import { resolve as resolvePath, join as joinPath } from 'path';
+import camelCase from 'lodash/camelCase';
+import upperFirst from 'lodash/upperFirst';
 import os from 'os';
 import { Command } from 'commander';
 import {
@@ -104,16 +106,12 @@ export async function addPluginDependencyToApp(
   });
 }
 
-export async function addPluginToApp(
+export async function addPluginImportToApp(
   rootDir: string,
-  pluginName: string,
+  pluginVar: string,
   pluginPackage: string,
 ) {
-  const pluginNameCapitalized = pluginName
-    .split('-')
-    .map(name => capitalize(name))
-    .join('');
-  const pluginExport = `export { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`;
+  const pluginExport = `export { ${pluginVar} } from '${pluginPackage}';`;
   const pluginsFilePath = 'packages/app/src/plugins.ts';
   const pluginsFile = resolvePath(rootDir, pluginsFilePath);
 
@@ -126,6 +124,46 @@ export async function addPluginToApp(
   });
 }
 
+export async function addPluginExtensionToApp(
+  pluginId: string,
+  extensionName: string,
+  pluginPackage: string,
+) {
+  const pluginsFilePath = paths.resolveTargetRoot('packages/app/src/App.tsx');
+  if (!(await fs.pathExists(pluginsFilePath))) {
+    return;
+  }
+
+  await Task.forItem('processing', pluginsFilePath, async () => {
+    const content = await fs.readFile(pluginsFilePath, 'utf8');
+    const revLines = content.split('\n').reverse();
+
+    const lastImportIndex = revLines.findIndex(line =>
+      line.match(/ from ("|').*("|')/),
+    );
+    const lastRouteIndex = revLines.findIndex(line =>
+      line.match(/<\/FlatRoutes/),
+    );
+
+    if (lastImportIndex !== -1 && lastRouteIndex !== -1) {
+      revLines.splice(
+        lastImportIndex,
+        0,
+        `import { ${extensionName} } from '${pluginPackage}';`,
+      );
+      const [indentation] = revLines[lastRouteIndex + 1].match(/^\s*/) ?? [];
+      revLines.splice(
+        lastRouteIndex + 1,
+        0,
+        `${indentation}}/>`,
+      );
+
+      const newContent = revLines.reverse().join('\n');
+      await fs.writeFile(pluginsFilePath, newContent, 'utf8');
+    }
+  });
+}
+
 async function cleanUp(tempDir: string) {
   await Task.forItem('remove', 'temporary directory', async () => {
     await fs.remove(tempDir);
@@ -223,6 +261,8 @@ export default async (cmd: Command) => {
   const name = cmd.scope
     ? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}`
     : `plugin-${pluginId}`;
+  const pluginVar = `${camelCase(answers.id)}Plugin`;
+  const extensionName = `${upperFirst(camelCase(answers.id))}Page`;
   const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : '';
   const privatePackage = cmd.private === false ? false : true;
   const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
@@ -259,7 +299,9 @@ export default async (cmd: Command) => {
       tempDir,
       {
         ...answers,
+        pluginVar,
         pluginVersion,
+        extensionName,
         name,
         privatePackage,
         npmRegistry,
@@ -278,7 +320,8 @@ export default async (cmd: Command) => {
       await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion);
 
       Task.section('Import plugin in app');
-      await addPluginToApp(paths.targetRoot, pluginId, name);
+      await addPluginImportToApp(paths.targetRoot, pluginVar, name);
+      await addPluginExtensionToApp(pluginId, extensionName, name);
     }
 
     if (ownerIds && ownerIds.length) {
diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts
index 416d67869b..fb96250567 100644
--- a/packages/cli/src/commands/plugin/diff.ts
+++ b/packages/cli/src/commands/plugin/diff.ts
@@ -39,6 +39,11 @@ const fileHandlers = [
     patterns: ['package.json'],
     handler: handlers.packageJson,
   },
+  {
+    // Not all plugins have routes
+    patterns: ['src/routes.ts'],
+    handler: handlers.skip,
+  },
   {
     // make sure files in 1st level of src/ and dev/ exist
     patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/],
diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts
index 987d4f6f31..31803d4827 100644
--- a/packages/cli/src/commands/versions/bump.test.ts
+++ b/packages/cli/src/commands/versions/bump.test.ts
@@ -108,7 +108,7 @@ describe('bump', () => {
     paths.targetDir = '/';
     jest
       .spyOn(paths, 'resolveTargetRoot')
-      .mockImplementation((...paths) => resolvePath('/', ...paths));
+      .mockImplementation((...path) => resolvePath('/', ...path));
     jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
       JSON.stringify({
         type: 'inspect',
@@ -204,7 +204,7 @@ describe('bump', () => {
     paths.targetDir = '/';
     jest
       .spyOn(paths, 'resolveTargetRoot')
-      .mockImplementation((...paths) => resolvePath('/', ...paths));
+      .mockImplementation((...path) => resolvePath('/', ...path));
     jest.spyOn(runObj, 'runPlain').mockImplementation(async () => '');
     jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
 
diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts
index 0478337c61..fed8b39a19 100644
--- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts
+++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts
@@ -41,7 +41,7 @@ export class LinkedPackageResolvePlugin implements ResolvePlugin {
         callback: () => void,
       ) => {
         const pkg = this.packages.find(
-          pkg => data.path && isChildPath(pkg.location, data.path),
+          pkge => data.path && isChildPath(pkge.location, data.path),
         );
         if (!pkg) {
           callback();
diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts
index c53a8329f5..6fbcc65c9a 100644
--- a/packages/cli/src/lib/bundler/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -219,7 +219,7 @@ export async function createBackendConfig(
         }
       : {}),
     externals: [
-      nodeExternals({
+      nodeExternalsWithResolve({
         modulesDir: paths.rootNodeModules,
         additionalModuleDirs: moduleDirs,
         allowlist: ['webpack/hot/poll?100', ...localPackageNames],
@@ -296,3 +296,32 @@ export async function createBackendConfig(
     ],
   };
 }
+
+// This makes the module resolution happen from the context of each non-external module, rather
+// than the main entrypoint. This fixes a bug where dependencies would be resolved from the backend
+// package rather than each individual backend package and plugin.
+//
+// TODO(Rugvip): Feature suggestion/contribute this to webpack-externals
+function nodeExternalsWithResolve(
+  options: Parameters[0],
+) {
+  let currentContext: string;
+  const externals = nodeExternals({
+    ...options,
+    importType(request) {
+      const resolved = require.resolve(request, {
+        paths: [currentContext],
+      });
+      return `commonjs ${resolved}`;
+    },
+  });
+
+  return (
+    context: string,
+    request: string,
+    callback: webpack.ExternalsFunctionCallback,
+  ) => {
+    currentContext = context;
+    return externals(context, request, callback);
+  };
+}
diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts
index f38416ca4e..c8d48a7199 100644
--- a/packages/cli/src/lib/bundler/paths.ts
+++ b/packages/cli/src/lib/bundler/paths.ts
@@ -42,14 +42,14 @@ export type BundlingPathsOptions = {
 export function resolveBundlingPaths(options: BundlingPathsOptions) {
   const { entry } = options;
 
-  const resolveTargetModule = (path: string) => {
+  const resolveTargetModule = (pathString: string) => {
     for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
-      const filePath = paths.resolveTarget(`${path}.${ext}`);
+      const filePath = paths.resolveTarget(`${pathString}.${ext}`);
       if (fs.pathExistsSync(filePath)) {
         return filePath;
       }
     }
-    return paths.resolveTarget(`${path}.js`);
+    return paths.resolveTarget(`${pathString}.js`);
   };
 
   let targetPublic = undefined;
diff --git a/packages/cli/templates/default-plugin/dev/index.tsx b/packages/cli/templates/default-plugin/dev/index.tsx
deleted file mode 100644
index 6fce113093..0000000000
--- a/packages/cli/templates/default-plugin/dev/index.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-import { createDevApp } from '@backstage/dev-utils';
-import { plugin } from '../src/plugin';
-
-createDevApp().registerPlugin(plugin).render();
diff --git a/packages/cli/templates/default-plugin/dev/index.tsx.hbs b/packages/cli/templates/default-plugin/dev/index.tsx.hbs
new file mode 100644
index 0000000000..ade00a1613
--- /dev/null
+++ b/packages/cli/templates/default-plugin/dev/index.tsx.hbs
@@ -0,0 +1,11 @@
+import React from 'react';
+import { createDevApp } from '@backstage/dev-utils';
+import { {{ pluginVar }}, {{ extensionName }} } from '../src/plugin';
+
+createDevApp()
+  .registerPlugin({{ pluginVar }})
+  .addPage({
+    element: <{{ extensionName }} />,
+    title: 'Root Page',
+  })
+  .render();
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
index e805900f36..e6eab17493 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
@@ -1,13 +1,12 @@
 import React from 'react';
 import { render } from '@testing-library/react';
-import ExampleComponent from './ExampleComponent';
+import { ExampleComponent } from './ExampleComponent';
 import { ThemeProvider } from '@material-ui/core';
 import { lightTheme } from '@backstage/theme';
 import { rest } from 'msw';
 import { setupServer } from 'msw/node';
 import { msw } from '@backstage/test-utils';
 
-
 describe('ExampleComponent', () => {
   const server = setupServer();
   // Enable sane handlers for network requests
@@ -15,15 +14,17 @@ describe('ExampleComponent', () => {
 
   // setup mock response
   beforeEach(() => {
-    server.use(rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))))
-  })
+    server.use(
+      rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
+    );
+  });
 
   it('should render', () => {
     const rendered = render(
       
         
       ,
-      );
-      expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
+    );
+    expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
   });
 });
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
index 5f90f2de1e..dcecebdf46 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
@@ -9,9 +9,9 @@ import {
   HeaderLabel,
   SupportButton,
 } from '@backstage/core';
-import ExampleFetchComponent from '../ExampleFetchComponent';
+import { ExampleFetchComponent } from '../ExampleFetchComponent';
 
-const ExampleComponent = () => (
+export const ExampleComponent = () => (
   
     
@@ -36,5 +36,3 @@ const ExampleComponent = () => ( ); - -export default ExampleComponent; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts index 520a3bf553..8b8437521b 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts @@ -1 +1 @@ -export { default } from './ExampleComponent'; +export { ExampleComponent } from './ExampleComponent'; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs index 81e1b4be09..fa1e289d8e 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import ExampleFetchComponent from './ExampleFetchComponent'; +import { ExampleFetchComponent } from './ExampleFetchComponent'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { msw } from '@backstage/test-utils'; @@ -9,11 +9,15 @@ describe('ExampleFetchComponent', () => { const server = setupServer(); // Enable sane handlers for network requests msw.setupDefaultHandlers(server); - + // setup mock response beforeEach(() => { - server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({})))) - }) + server.use( + rest.get('https://randomuser.me/*', (_, res, ctx) => + res(ctx.status(200), ctx.delay(2000), ctx.json({})), + ), + ); + }); it('should render', async () => { const rendered = render(); expect(await rendered.findByTestId('progress')).toBeInTheDocument(); diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs index 8cc5ed2ab7..20dd6d1f57 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs @@ -48,7 +48,7 @@ export const DenseTable = ({ users }: DenseTableProps) => { { title: 'Nationality', field: 'nationality' }, ]; - const data = users.map((user) => { + const data = users.map(user => { return { avatar: ( { ); }; -const ExampleFetchComponent = () => { +export const ExampleFetchComponent = () => { const { value, loading, error } = useAsync(async (): Promise => { const response = await fetch('https://randomuser.me/api/?results=20'); const data = await response.json(); @@ -88,5 +88,3 @@ const ExampleFetchComponent = () => { return ; }; - -export default ExampleFetchComponent; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts index 3e53453948..41a43e84f1 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts @@ -1 +1 @@ -export { default } from './ExampleFetchComponent'; +export { ExampleFetchComponent } from './ExampleFetchComponent'; diff --git a/packages/cli/templates/default-plugin/src/index.ts b/packages/cli/templates/default-plugin/src/index.ts deleted file mode 100644 index 99edba26c3..0000000000 --- a/packages/cli/templates/default-plugin/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { plugin } from './plugin'; diff --git a/packages/cli/templates/default-plugin/src/index.ts.hbs b/packages/cli/templates/default-plugin/src/index.ts.hbs new file mode 100644 index 0000000000..be4881efaf --- /dev/null +++ b/packages/cli/templates/default-plugin/src/index.ts.hbs @@ -0,0 +1 @@ +export { {{ pluginVar }}, {{ extensionName }} } from './plugin'; diff --git a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs index c2be0a5301..9d44a9c497 100644 --- a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs @@ -1,7 +1,7 @@ -import { plugin } from './plugin'; +import { {{ pluginVar }} } from './plugin'; describe('{{ id }}', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect({{ pluginVar }}).toBeDefined(); }); }); diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index 1bf4d07cdb..0ae5356fd1 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -1,14 +1,18 @@ -import { createPlugin, createRouteRef } from '@backstage/core'; -import ExampleComponent from './components/ExampleComponent'; +import { createPlugin, createRoutableExtension } from '@backstage/core'; -export const rootRouteRef = createRouteRef({ - path: '/{{ id }}', - title: '{{ id }}', -}); +import { rootRouteRef } from './routes'; -export const plugin = createPlugin({ +export const {{ pluginVar }} = createPlugin({ id: '{{ id }}', - register({ router }) { - router.addRoute(rootRouteRef, ExampleComponent); + routes: { + root: rootRouteRef, }, }); + +export const {{ extensionName }} = {{ pluginVar }}.provide( + createRoutableExtension({ + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + mountPoint: rootRouteRef, + }), +); diff --git a/packages/cli/templates/default-plugin/src/routes.ts.hbs b/packages/cli/templates/default-plugin/src/routes.ts.hbs new file mode 100644 index 0000000000..b2afba074d --- /dev/null +++ b/packages/cli/templates/default-plugin/src/routes.ts.hbs @@ -0,0 +1,5 @@ +import { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + title: '{{ id }}', +}); diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index a5e34ad91e..2079c85096 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/config-loader +## 0.5.0 + +### Minor Changes + +- ef7957be4: Removed support for the deprecated `$data` placeholder. +- ef7957be4: Enable further processing of configuration files included using the `$include` placeholder. Meaning that for example for example `$env` includes will be processed as usual in included files. + +### Patch Changes + +- ef7957be4: Added support for environment variable substitutions in string configuration values using a `${VAR}` placeholder. All environment variables must be available, or the entire expression will be evaluated to `undefined`. To escape a substitution, use `${...}`, which will end up as `${...}`. + + For example: + + ```yaml + app: + baseUrl: https://${BASE_HOST} + ``` + ## 0.4.1 ### Patch Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 476c57b014..3387477231 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.4.1", + "version": "0.5.0", "private": false, "publishConfig": { "access": "public", @@ -32,7 +32,7 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.1", - "ajv": "^6.12.5", + "ajv": "^7.0.3", "fs-extra": "^9.0.0", "json-schema": "^0.2.5", "json-schema-merge-allof": "^0.7.0", diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts index 0e53562875..611413931e 100644 --- a/packages/config-loader/src/lib/schema/collect.ts +++ b/packages/config-loader/src/lib/schema/collect.ts @@ -118,7 +118,9 @@ export async function collectConfigSchemas( } await Promise.all( - depNames.map(name => processItem({ name, parentPath: pkgPath })), + depNames.map(depName => + processItem({ name: depName, parentPath: pkgPath }), + ), ); } diff --git a/packages/config-loader/src/lib/schema/compile.test.ts b/packages/config-loader/src/lib/schema/compile.test.ts index f03d7e6d10..e1d8ee5999 100644 --- a/packages/config-loader/src/lib/schema/compile.test.ts +++ b/packages/config-loader/src/lib/schema/compile.test.ts @@ -29,11 +29,11 @@ describe('compileConfigSchemas', () => { }, ]); expect(validate([{ data: { a: 1 }, context: 'test' }])).toEqual({ - errors: ['Config should be string { type=string } at .a'], + errors: ['Config should be string { type=string } at /a'], visibilityByPath: new Map(), }); expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({ - errors: ['Config should be number { type=number } at .b'], + errors: ['Config should be number { type=number } at /b'], visibilityByPath: new Map(), }); }); @@ -80,10 +80,10 @@ describe('compileConfigSchemas', () => { ).toEqual({ visibilityByPath: new Map( Object.entries({ - '.a': 'frontend', - '.b': 'secret', - '.d': 'secret', - '.d.0': 'frontend', + '/a': 'frontend', + '/b': 'secret', + '/d': 'secret', + '/d/0': 'frontend', }), ), }); diff --git a/packages/config-loader/src/lib/schema/compile.ts b/packages/config-loader/src/lib/schema/compile.ts index f01a4f640a..e340d775a1 100644 --- a/packages/config-loader/src/lib/schema/compile.ts +++ b/packages/config-loader/src/lib/schema/compile.ts @@ -42,23 +42,25 @@ export function compileConfigSchemas( const ajv = new Ajv({ allErrors: true, + allowUnionTypes: true, schemas: { 'https://backstage.io/schema/config-v1': true, }, - }).addKeyword('visibility', { + }).addKeyword({ + keyword: 'visibility', metaSchema: { type: 'string', enum: CONFIG_VISIBILITIES, }, compile(visibility: ConfigVisibility) { - return (_data, dataPath) => { - if (!dataPath) { + return (_data, context) => { + if (context?.dataPath === undefined) { return false; } if (visibility && visibility !== 'backend') { - const normalizedPath = dataPath.replace( + const normalizedPath = context.dataPath.replace( /\['?(.*?)'?\]/g, - (_, segment) => `.${segment}`, + (_, segment) => `/${segment}`, ); visibilityByPath.set(normalizedPath, visibility); } @@ -67,6 +69,14 @@ export function compileConfigSchemas( }, }); + for (const schema of schemas) { + try { + ajv.compile(schema.value); + } catch (error) { + throw new Error(`Schema at ${schema.path} is invalid, ${error}`); + } + } + const merged = mergeAllOf( { allOf: schemas.map(_ => _.value) }, { diff --git a/packages/config-loader/src/lib/schema/filtering.test.ts b/packages/config-loader/src/lib/schema/filtering.test.ts index f6926e3560..feb31ebab0 100644 --- a/packages/config-loader/src/lib/schema/filtering.test.ts +++ b/packages/config-loader/src/lib/schema/filtering.test.ts @@ -40,24 +40,24 @@ const data = { const visibility = new Map( Object.entries({ - '.arr.0': 'frontend', - '.arr.1': 'backend', - '.arr.2': 'secret', - '.obj.f': 'frontend', - '.obj.b': 'backend', - '.obj.b.s': 'secret', - '.objArr.0.f': 'frontend', - '.objArr.0.b': 'backend', - '.objArr.0.s': 'secret', - '.objArr.1.f': 'frontend', - '.objArr.1.b': 'backend', - '.objArr.1.s': 'secret', - '.arrF': 'frontend', - '.arrB': 'backend', - '.arrS': 'secret', - '.objF': 'frontend', - '.objB': 'backend', - '.objS': 'secret', + '/arr/0': 'frontend', + '/arr/1': 'backend', + '/arr/2': 'secret', + '/obj/f': 'frontend', + '/obj/b': 'backend', + '/obj/b/s': 'secret', + '/objArr/0/f': 'frontend', + '/objArr/0/b': 'backend', + '/objArr/0/s': 'secret', + '/objArr/1/f': 'frontend', + '/objArr/1/b': 'backend', + '/objArr/1/s': 'secret', + '/arrF': 'frontend', + '/arrB': 'backend', + '/arrS': 'secret', + '/objF': 'frontend', + '/objB': 'backend', + '/objS': 'secret', }), ); diff --git a/packages/config-loader/src/lib/schema/filtering.ts b/packages/config-loader/src/lib/schema/filtering.ts index 10a97f9a7f..93e899c9d8 100644 --- a/packages/config-loader/src/lib/schema/filtering.ts +++ b/packages/config-loader/src/lib/schema/filtering.ts @@ -49,7 +49,7 @@ export function filterByVisibility( const arr = new Array(); for (const [index, value] of jsonVal.entries()) { - const out = transform(value, `${path}.${index}`); + const out = transform(value, `${path}/${index}`); if (out !== undefined) { arr.push(out); } @@ -68,7 +68,7 @@ export function filterByVisibility( if (value === undefined) { continue; } - const out = transform(value, `${path}.${key}`); + const out = transform(value, `${path}/${key}`); if (out !== undefined) { outObj[key] = out; hasOutput = true; diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts index baf63525bb..7d9f7cc803 100644 --- a/packages/config-loader/src/lib/schema/load.test.ts +++ b/packages/config-loader/src/lib/schema/load.test.ts @@ -85,7 +85,7 @@ describe('loadConfigSchema', () => { expect(() => schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]), ).toThrow( - 'Config validation failed, Config should be string { type=string } at .key1', + 'Config validation failed, Config should be string { type=string } at /key1', ); await expect( diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 85f9d3a921..bbea58e846 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -42,7 +42,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.5", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.6", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts index 517bf82ae7..e7764fcbaa 100644 --- a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -13,12 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - AuthProvider, - ProfileInfo, - BackstageIdentity, - DiscoveryApi, -} from '../../apis/definitions'; +import { AuthProvider, DiscoveryApi } from '../../apis/definitions'; import { showLoginPopup } from '../loginPopup'; type Options = { @@ -26,13 +21,6 @@ type Options = { environment?: string; provider: AuthProvider & { id: string }; }; - -export type DirectAuthResponse = { - userId: string; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - export class DirectAuthConnector { private readonly discoveryApi: DiscoveryApi; private readonly environment: string | undefined; diff --git a/packages/core-api/src/lib/loginPopup.ts b/packages/core-api/src/lib/loginPopup.ts index 2e14447882..716c4f9651 100644 --- a/packages/core-api/src/lib/loginPopup.ts +++ b/packages/core-api/src/lib/loginPopup.ts @@ -82,7 +82,9 @@ export function showLoginPopup(options: LoginPopupOptions): Promise { let targetOrigin = ''; if (!popup || typeof popup.closed === 'undefined' || popup.closed) { - reject(new Error('Failed to open auth popup.')); + const error = new Error('Failed to open auth popup.'); + error.name = 'PopupRejectedError'; + reject(error); return; } @@ -120,7 +122,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise { const intervalId = setInterval(() => { if (popup.closed) { const errMessage = `Login failed, ${ - targetOrigin !== window.location.origin + targetOrigin && targetOrigin !== window.location.origin ? `Incorrect app origin, expected ${targetOrigin}` : 'popup was closed' }`; diff --git a/packages/core-api/src/routing/FlatRoutes.tsx b/packages/core-api/src/routing/FlatRoutes.tsx index a6783964a7..cefb347290 100644 --- a/packages/core-api/src/routing/FlatRoutes.tsx +++ b/packages/core-api/src/routing/FlatRoutes.tsx @@ -25,8 +25,8 @@ type RouteObject = { // Similar to the same function from react-router, this collects routes from the // children, but only the first level of routes -function createRoutesFromChildren(children: ReactNode): RouteObject[] { - return Children.toArray(children) +function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { + return Children.toArray(childrenNode) .flatMap(child => { if (!isValidElement(child)) { return []; diff --git a/packages/core/package.json b/packages/core/package.json index a937502247..49090e1362 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,6 +35,7 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@testing-library/react-hooks": "^3.4.2", "@types/dagre": "^0.7.44", "@types/react": "^16.9", "@types/react-sparklines": "^1.7.0", @@ -65,7 +66,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx similarity index 92% rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx rename to packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx index 065265cea4..549aa55eff 100644 --- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.test.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.test.tsx @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { TabbedLayout } from './TabbedLayout'; import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent } from '@testing-library/react'; +import React from 'react'; import { act } from 'react-dom/test-utils'; -import { Routes, Route } from 'react-router'; +import { Route, Routes } from 'react-router'; +import { RoutedTabs } from './RoutedTabs'; const testRoute1 = { path: '', @@ -31,10 +31,10 @@ const testRoute2 = { children:
tabbed-test-content-2
, }; -describe('TabbedLayout', () => { +describe('RoutedTabs', () => { it('renders simplest case', async () => { const rendered = await renderInTestApp( - , + , ); expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); @@ -46,7 +46,7 @@ describe('TabbedLayout', () => { } + element={} /> , ); @@ -70,7 +70,7 @@ describe('TabbedLayout', () => { { it('shows only one tab contents at a time', async () => { const rendered = await renderInTestApp( - , + , { routeEntries: ['/some-other-path'] }, ); @@ -135,7 +135,7 @@ describe('TabbedLayout', () => { it('redirects to the top level when no route is matching the url', async () => { const rendered = await renderInTestApp( - , + , { routeEntries: ['/non-existing-path'] }, ); diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx similarity index 84% rename from plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx rename to packages/core/src/components/TabbedLayout/RoutedTabs.tsx index a2209ba492..b17c8afb3b 100644 --- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ import React, { useMemo } from 'react'; -import { useParams, useNavigate, matchRoutes, useRoutes } from 'react-router'; -import { HeaderTabs, Content as LayoutContent } from '@backstage/core'; import { Helmet } from 'react-helmet'; +import { matchRoutes, useNavigate, useParams, useRoutes } from 'react-router'; +import { Content, HeaderTabs } from '../../layout'; import { SubRoute } from './types'; export function useSelectedSubRoute( @@ -44,7 +44,7 @@ export function useSelectedSubRoute( }; } -export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => { +export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => { const navigate = useNavigate(); const { index, route, element } = useSelectedSubRoute(routes); const headerTabs = useMemo( @@ -52,12 +52,12 @@ export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => { [routes], ); - const onTabChange = (index: number) => + const onTabChange = (tabIndex: number) => // Remove trailing /* // And remove leading / for relative navigation // Note! route resolves relative to the position in the React tree, // not relative to current location - navigate(routes[index].path.replace(/\/\*$/, '').replace(/^\//, '')); + navigate(routes[tabIndex].path.replace(/\/\*$/, '').replace(/^\//, '')); return ( <> @@ -66,10 +66,10 @@ export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => { selectedIndex={index} onChange={onTabChange} /> - + {element} - + ); }; diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx new file mode 100644 index 0000000000..bf6175a9a2 --- /dev/null +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.stories.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { PropsWithChildren } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { TabbedLayout } from './TabbedLayout'; + +export default { + title: 'Navigation/TabbedLayout', + component: TabbedLayout, +}; + +const Wrapper = ({ children }: PropsWithChildren<{}>) => ( + + + {children}} /> + + +); + +export const Default = () => ( + + + +
tabbed-test-content
+
+ +
tabbed-test-content-2
+
+
+
+); diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx new file mode 100644 index 0000000000..77230ab6cd --- /dev/null +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.test.tsx @@ -0,0 +1,94 @@ +/* + * 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 { renderInTestApp, withLogCollector } from '@backstage/test-utils'; +import { fireEvent } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Route, Routes } from 'react-router'; +import { TabbedLayout } from './TabbedLayout'; + +describe('TabbedLayout', () => { + it('renders simplest case', async () => { + const { getByText } = await renderInTestApp( + + +
tabbed-test-content
+
+
, + ); + + expect(getByText('tabbed-test-title')).toBeInTheDocument(); + expect(getByText('tabbed-test-content')).toBeInTheDocument(); + }); + + it('throws if any other component is a child of TabbedLayout', async () => { + const { error } = await withLogCollector(async () => { + await expect( + renderInTestApp( + + +
tabbed-test-content
+
+
This will cause app to throw
+
, + ), + ).rejects.toThrow(/Child of TabbedLayout must be an TabbedLayout.Route/); + }); + + expect(error).toEqual([ + expect.stringMatching( + /Child of TabbedLayout must be an TabbedLayout.Route/, + ), + expect.stringMatching( + /The above error occurred in the component/, + ), + ]); + }); + + it('navigates when user clicks different tab', async () => { + const { getByText, queryByText, queryAllByRole } = await renderInTestApp( + + + +
tabbed-test-content
+
+ +
tabbed-test-content-2
+
+
+ } + /> + , + ); + + const secondTab = queryAllByRole('tab')[1]; + act(() => { + fireEvent.click(secondTab); + }); + + expect(getByText('tabbed-test-title')).toBeInTheDocument(); + expect(queryByText('tabbed-test-content')).not.toBeInTheDocument(); + + expect(getByText('tabbed-test-title-2')).toBeInTheDocument(); + expect(queryByText('tabbed-test-content-2')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/TabbedLayout/TabbedLayout.tsx b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx new file mode 100644 index 0000000000..f181ee5980 --- /dev/null +++ b/packages/core/src/components/TabbedLayout/TabbedLayout.tsx @@ -0,0 +1,89 @@ +/* + * 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 { attachComponentData } from '@backstage/core-api'; +import React, { + Children, + Fragment, + isValidElement, + PropsWithChildren, + ReactNode, +} from 'react'; +import { RoutedTabs } from './RoutedTabs'; + +type SubRoute = { + path: string; + title: string; + children: JSX.Element; +}; + +const Route: (props: SubRoute) => null = () => null; + +// This causes all mount points that are discovered within this route to use the path of the route itself +attachComponentData(Route, 'core.gatherMountPoints', true); + +export function createSubRoutesFromChildren( + childrenProps: ReactNode, +): SubRoute[] { + // Directly comparing child.type with Route will not work with in + // combination with react-hot-loader in storybook + // https://github.com/gaearon/react-hot-loader/issues/304 + const routeType = ( + +
+ + ).type; + + return Children.toArray(childrenProps).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + if (child.type === Fragment) { + return createSubRoutesFromChildren(child.props.children); + } + + if (child.type !== routeType) { + throw new Error('Child of TabbedLayout must be an TabbedLayout.Route'); + } + + const { path, title, children } = child.props; + return [{ path, title, children }]; + }); +} + +/** + * TabbedLayout is a compound component, which allows you to define a layout for + * pages using a sub-navigation mechanism. + * + * Consists of two parts: TabbedLayout and TabbedLayout.Route + * + * @example + * ```jsx + * + * + *
This is rendered under /example/anything-here route
+ *
+ *
+ * ``` + */ +export const TabbedLayout = ({ children }: PropsWithChildren<{}>) => { + const routes = createSubRoutesFromChildren(children); + + return ; +}; + +TabbedLayout.Route = Route; diff --git a/packages/core/src/components/TabbedLayout/index.ts b/packages/core/src/components/TabbedLayout/index.ts new file mode 100644 index 0000000000..744b56959e --- /dev/null +++ b/packages/core/src/components/TabbedLayout/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { TabbedLayout } from './TabbedLayout'; diff --git a/plugins/catalog/src/components/EntityLayout/types.ts b/packages/core/src/components/TabbedLayout/types.ts similarity index 100% rename from plugins/catalog/src/components/EntityLayout/types.ts rename to packages/core/src/components/TabbedLayout/types.ts diff --git a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx index 5098f0aa31..ef99a0fce4 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { WarningPanel } from './WarningPanel'; -import { Link, Button } from '@material-ui/core'; +import { Button, Link, Typography } from '@material-ui/core'; export default { title: 'Feedback/Warning Panel', @@ -25,11 +25,11 @@ export default { export const Default = () => ( - This example entity is missing something. If this is unexpected, please - make sure you have set up everything correctly by following{' '} + This example entity is missing an annotation. If this is unexpected, + please make sure you have set up everything correctly by following{' '} this guide. } @@ -37,9 +37,36 @@ export const Default = () => ( ); export const Children = () => ( - - + + + Supports custom children - for example these text elements. This can be + used to hide/expose stack traces for warnings, like this example: +
+ SyntaxError: Error transforming + /home/user/github/backstage/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx: + Unexpected token (42:16) at unexpected + (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/util.js:83:15) + at tsParseMaybeAssignWithJSX + (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1399:22) + at tsParseMaybeAssign + (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1373:12) + at parseMaybeAssign + (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:118:43) + at parseExprListItem + (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:969:5) +
+
); + +export const FullExample = () => ( + + HTTP 500 Bad Gateway response from + https://usefulservice.mycompany.com/api/entity?44433 + +); + +export const TitleOnly = () => ; diff --git a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx index 07a25d34c8..38ba4bff9b 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx @@ -15,23 +15,53 @@ */ import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; import { renderInTestApp } from '@backstage/test-utils'; +import { Typography } from '@material-ui/core'; import { WarningPanel } from './WarningPanel'; -const minProps = { title: 'Mock title', message: 'Some more info' }; +const propsTitle = { title: 'Mock title' }; +const propsTitleMessage = { title: 'Mock title', message: 'Some more info' }; +const propsMessage = { message: 'Some more info' }; describe('', () => { it('renders without exploding', async () => { - const { getByText } = await renderInTestApp(); - expect(getByText('Mock title')).toBeInTheDocument(); + await renderInTestApp(); + expect(screen.getByText('Warning: Mock title')).toBeInTheDocument(); }); - it('renders message and children', async () => { - const { getByText } = await renderInTestApp( - children, + it('renders title', async () => { + await renderInTestApp(); + const expandIcon = await screen.getByText('Warning: Mock title'); + fireEvent.click(expandIcon); + expect(screen.getByText('Warning: Mock title')).toBeInTheDocument(); + expect(screen.getByText('Some more info')).toBeInTheDocument(); + }); + + it('renders title and children', async () => { + await renderInTestApp( + + Java stacktrace + , ); - expect(getByText('Some more info')).toBeInTheDocument(); - expect(getByText('children')).toBeInTheDocument(); + expect(screen.getByText('Java stacktrace')).toBeInTheDocument(); + }); + + it('renders message', async () => { + await renderInTestApp(); + expect(screen.getByText('Warning')).toBeInTheDocument(); + expect(screen.getByText('Some more info')).toBeInTheDocument(); + }); + + it('renders title, message, and children', async () => { + await renderInTestApp( + + Java stacktrace + , + ); + expect(screen.getByText('Warning: Mock title')).toBeInTheDocument(); + expect(screen.getByText('Some more info')).toBeInTheDocument(); + expect(screen.getByText('Java stacktrace')).toBeInTheDocument(); }); }); diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx index ae4d2bff02..e82c49c49a 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -15,8 +15,16 @@ */ import { BackstageTheme } from '@backstage/theme'; -import { makeStyles, Typography } from '@material-ui/core'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Grid, + makeStyles, + Typography, +} from '@material-ui/core'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import React from 'react'; const useErrorOutlineStyles = makeStyles(theme => ({ @@ -29,57 +37,100 @@ const ErrorOutlineStyled = () => { const classes = useErrorOutlineStyles(); return ; }; +const ExpandMoreIconStyled = () => { + const classes = useErrorOutlineStyles(); + return ; +}; const useStyles = makeStyles(theme => ({ - message: { - display: 'flex', - flexDirection: 'column', - padding: theme.spacing(1.5), + panel: { backgroundColor: theme.palette.warningBackground, color: theme.palette.warningText, verticalAlign: 'middle', }, - header: { + summary: { display: 'flex', flexDirection: 'row', - marginBottom: theme.spacing(1), }, - headerText: { + summaryText: { color: theme.palette.warningText, + fontWeight: 'bold', }, - messageText: { + message: { + width: '100%', + display: 'block', color: theme.palette.warningText, + backgroundColor: theme.palette.warningBackground, + }, + 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', }, })); -/** - * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel - * only shows the warning message to the user - */ - type Props = { - message?: React.ReactNode; title?: string; + severity?: 'warning' | 'error' | 'info'; + message?: React.ReactNode; children?: React.ReactNode; }; +const capitalize = (s: string) => { + return s.charAt(0).toUpperCase() + s.slice(1); +}; + +/** + * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel + * only shows the warning message to the user. + * + * @param {string} [severity=warning] Ability to change the severity of the alert. Not fully implemented. (error, warning, info) + * @param {string} [title] A title for the warning. If not supplied, "Warning" will be used. + * @param {Object} [message] Optional more detailed user-friendly message elaborating on the cause of the error. + * @param {Object} [children] Objects to provide context, such as a stack trace or detailed error reporting. + * Will be available inside an unfolded accordion. + */ export const WarningPanel = (props: Props) => { const classes = useStyles(props); - const { title, message, children } = props; + const { severity, title, message, children } = props; + + // If no severity or title provided, the heading will read simply "Warning" + const subTitle = + (severity ? capitalize(severity) : 'Warning') + (title ? `: ${title}` : ''); + return ( -
-
+ + } + className={classes.summary} + > - - {title} - -
- {message && ( - - {message} + + {subTitle} + + {(message || children) && ( + + + {message && ( + + + {message} + + + )} + {children && ( + + {children} + + )} + + )} - {children} -
+ ); }; diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 85ebba7a58..77eba9759a 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -21,10 +21,13 @@ export * from './CodeSnippet'; export * from './CopyTextButton'; export * from './DependencyGraph'; export * from './DismissableBanner'; +export * from './EmptyState'; export * from './FeatureDiscovery'; +export * from './HeaderIconLinkRow'; export * from './HorizontalScrollGrid'; export * from './Lifecycle'; export * from './Link'; +export * from './MarkdownContent'; export * from './OAuthRequestDialog'; export * from './Progress'; export * from './ProgressBars'; @@ -32,10 +35,8 @@ export * from './SimpleStepper'; export * from './Status'; export * from './StructuredMetadataTable'; export * from './SupportButton'; +export * from './TabbedLayout'; export * from './Table'; export * from './Tabs'; export * from './TrendLine'; export * from './WarningPanel'; -export * from './EmptyState'; -export * from './MarkdownContent'; -export * from './HeaderIconLinkRow'; diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx index a1b4e1a97f..718a103f00 100644 --- a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx @@ -18,7 +18,7 @@ // This is just a temporary solution to implementing tabs for now import React, { useState, useEffect } from 'react'; -import { makeStyles, Tabs, Tab } from '@material-ui/core'; +import { makeStyles, Tabs, Tab as TabUI } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ tabsWrapper: { @@ -81,7 +81,7 @@ export const HeaderTabs = ({ value={selectedTab} > {tabs.map((tab, index) => ( - { + const configApi: ConfigApi = new ConfigReader({ + homepage: { + clocks: [ + { + label: 'New York', + timezone: 'America/New_Pork', + }, + ], + }, + context: 'test', + }); + + const rendered = await renderWithEffects( + + + + + , + ); + + expect(rendered.getByText('GMT')).toBeInTheDocument(); +}); diff --git a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx index 59b7f3fb3d..2adbf8fbd5 100644 --- a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx +++ b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx @@ -39,18 +39,28 @@ function getTimes(configApi: ConfigApi) { for (const clock of clockConfigs) { if (clock.has('label') && clock.has('timezone')) { + let label = clock.getString('label'); + const options = { timeZone: clock.getString('timezone'), ...timeFormat, }; - const time = d.toLocaleTimeString(lang, options); - const label = clock.getString('label'); + try { + new Date().toLocaleString(lang, options); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `The timezone ${options.timeZone} is invalid. Defaulting to GMT`, + ); + options.timeZone = 'GMT'; + label = 'GMT'; + } + const time = d.toLocaleTimeString(lang, options); clocks.push({ time, label }); } } - return clocks; } diff --git a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx index 75031a0765..b53fa9eb7a 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { ItemCard } from '.'; import { Grid } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { ItemCard } from '.'; export default { title: 'Layout/Item Card', @@ -63,5 +64,35 @@ export const Tags = () => ( label="Button" /> + + + ); + +export const Link = () => ( + + + + + + + + + + +); diff --git a/packages/core/src/layout/ItemCard/ItemCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx index 991a05708f..4242cd6381 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.tsx @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Button, Card, Chip, makeStyles, Typography } from '@material-ui/core'; +import clsx from 'clsx'; import React from 'react'; -import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core'; +import { Link } from '../../components'; const useStyles = makeStyles(theme => ({ header: { @@ -30,6 +32,9 @@ const useStyles = makeStyles(theme => ({ overflow: 'hidden', textOverflow: 'ellipsis', }, + withTags: { + height: 'calc(175px - 32px - 8px)', + }, footer: { display: 'flex', flexDirection: 'row-reverse', @@ -43,7 +48,9 @@ type ItemCardProps = { type?: string; label: string; onClick?: () => void; + href?: string; }; + export const ItemCard = ({ description, tags, @@ -51,6 +58,7 @@ export const ItemCard = ({ type, label, onClick, + href, }: ItemCardProps) => { const classes = useStyles(); @@ -64,13 +72,27 @@ export const ItemCard = ({ {tags?.map((tag, i) => ( ))} - + 0 && classes.withTags, + )} + > {description}
- + {!href && ( + + )} + {href && ( + + )}
diff --git a/packages/core/src/layout/Sidebar/Bar.tsx b/packages/core/src/layout/Sidebar/Bar.tsx index d7318de27d..260593de95 100644 --- a/packages/core/src/layout/Sidebar/Bar.tsx +++ b/packages/core/src/layout/Sidebar/Bar.tsx @@ -39,6 +39,8 @@ const useStyles = makeStyles(theme => ({ padding: 0, background: theme.palette.navigation.background, overflowX: 'hidden', + msOverflowStyle: 'none', + scrollbarWidth: 'none', width: sidebarConfig.drawerWidthClosed, transition: theme.transitions.create('width', { easing: theme.transitions.easing.sharp, @@ -47,6 +49,9 @@ const useStyles = makeStyles(theme => ({ '& > *': { flexShrink: 0, }, + '&::-webkit-scrollbar': { + display: 'none', + }, }, drawerOpen: { width: sidebarConfig.drawerWidthOpen, diff --git a/packages/core/src/layout/Sidebar/Items.test.tsx b/packages/core/src/layout/Sidebar/Items.test.tsx index 099a3477d3..fb0ee174f8 100644 --- a/packages/core/src/layout/Sidebar/Items.test.tsx +++ b/packages/core/src/layout/Sidebar/Items.test.tsx @@ -22,8 +22,18 @@ import HomeIcon from '@material-ui/icons/Home'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import { Sidebar } from './Bar'; import { SidebarItem } from './Items'; +import { renderHook } from '@testing-library/react-hooks'; +import { hexToRgb, makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + spotlight: { + backgroundColor: '#2b2a2a', + }, +}); async function renderSidebar() { + const { result } = renderHook(() => useStyles()); + await renderInTestApp( @@ -31,6 +41,7 @@ async function renderSidebar() { icon={CreateComponentIcon} onClick={() => {}} text="Create..." + className={result.current.spotlight} /> , ); @@ -54,5 +65,11 @@ describe('Items', () => { await screen.findByRole('button', { name: /create/i }), ).toBeInTheDocument(); }); + + it('should render a button with custom style', async () => { + expect( + await screen.findByRole('button', { name: /create/i }), + ).toHaveStyle(`background-color: ${hexToRgb('2b2a2a')}`); + }); }); }); diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index e954dcb4f8..57cf435651 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -128,6 +128,7 @@ type SidebarItemBaseProps = { text?: string; hasNotifications?: boolean; children?: ReactNode; + className?: string; }; type SidebarItemButtonProps = SidebarItemBaseProps & { @@ -154,6 +155,7 @@ export const SidebarItem = forwardRef((props, ref) => { hasNotifications = false, onClick, children, + className, } = props; const classes = useStyles(); // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component @@ -193,6 +195,7 @@ export const SidebarItem = forwardRef((props, ref) => { const childProps = { onClick, className: clsx( + className, classes.root, isOpen ? classes.open : classes.closed, isButtonItem(props) && classes.buttonItem, diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index 83ef8fe8a8..c3e5b75bb7 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -14,30 +14,38 @@ * limitations under the License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Page } from '../Page'; import { Header } from '../Header'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; -import { Grid } from '@material-ui/core'; +import { Grid, Button, Typography } from '@material-ui/core'; import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api'; import { useSignInProviders, getSignInProviders } from './providers'; -import { IdentityProviders } from './types'; +import { IdentityProviders, SignInConfig } from './types'; import { Progress } from '../../components/Progress'; -import { useStyles } from './styles'; +import { GridItem, useStyles } from './styles'; +import { InfoCard } from '../InfoCard'; -export type Props = SignInPageProps & { +type MultiSignInPageProps = SignInPageProps & { providers: IdentityProviders; title?: string; align?: 'center' | 'left'; }; -export const SignInPage = ({ +type SingleSignInPageProps = SignInPageProps & { + provider: SignInConfig; + auto?: boolean; +}; + +export type Props = MultiSignInPageProps | SingleSignInPageProps; + +export const MultiSignInPage = ({ onResult, providers = [], title, align = 'left', -}: Props) => { +}: MultiSignInPageProps) => { const configApi = useApi(configApiRef); const classes = useStyles(); @@ -69,3 +77,86 @@ export const SignInPage = ({ ); }; + +export const SingleSignInPage = ({ + onResult, + provider, + auto, +}: SingleSignInPageProps) => { + const classes = useStyles(); + const authApi = useApi(provider.apiRef); + const configApi = useApi(configApiRef); + + const [retry, setRetry] = useState<{} | boolean | undefined>(auto); + const [error, setError] = useState(); + + useEffect(() => { + const login = async () => { + const identity = await authApi.getBackstageIdentity({ + instantPopup: true, + }); + + const profile = await authApi.getProfile(); + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => { + return authApi.getBackstageIdentity().then(i => i!.idToken); + }, + signOut: async () => { + await authApi.signOut(); + }, + }); + }; + + if (retry) { + login().catch(setError); + } + }, [onResult, authApi, retry]); + + return ( + +
+ + + + setRetry({})} + > + Sign In + + } + > + {provider.message} + {error && error.name !== 'PopupRejectedError' && ( + + {error.message} + + )} + + + + + + ); +}; + +export const SignInPage = (props: Props) => { + if ('provider' in props) { + return ; + } + + return ; +}; diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index e33d8cc633..6c449b33b5 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,45 @@ # @backstage/create-app +## 0.3.7 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [12a56cdfe] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [0b1182346] +- Updated dependencies [d7b1d317f] +- Updated dependencies [a91aa6bf2] +- Updated dependencies [39b05b9ae] +- Updated dependencies [4eaa06057] + - @backstage/backend-common@0.5.1 + - @backstage/plugin-scaffolder-backend@0.5.2 + - @backstage/cli@0.5.0 + - @backstage/plugin-catalog@0.2.14 + - @backstage/plugin-catalog-backend@0.5.5 + - @backstage/plugin-catalog-import@0.3.6 + - @backstage/plugin-scaffolder@0.4.1 + - @backstage/plugin-auth-backend@0.2.12 + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/test-utils@0.1.6 + - @backstage/theme@0.2.2 + - @backstage/plugin-api-docs@0.4.3 + - @backstage/plugin-app-backend@0.3.5 + - @backstage/plugin-circleci@0.2.6 + - @backstage/plugin-explore@0.2.3 + - @backstage/plugin-github-actions@0.3.0 + - @backstage/plugin-lighthouse@0.2.8 + - @backstage/plugin-proxy-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.7 + - @backstage/plugin-search@0.2.6 + - @backstage/plugin-tech-radar@0.3.3 + - @backstage/plugin-techdocs@0.5.4 + - @backstage/plugin-techdocs-backend@0.5.4 + - @backstage/plugin-user-settings@0.2.4 + ## 0.3.6 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 60dd150566..475b61dde8 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.6", + "version": "0.3.7", "private": false, "publishConfig": { "access": "public" @@ -44,26 +44,26 @@ "ts-node": "^8.6.2" }, "peerDependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", "@backstage/plugin-api-docs": "^0.4.3", - "@backstage/plugin-app-backend": "^0.3.4", - "@backstage/plugin-auth-backend": "^0.2.11", - "@backstage/plugin-catalog": "^0.2.13", - "@backstage/plugin-catalog-backend": "^0.5.4", - "@backstage/plugin-catalog-import": "^0.3.5", + "@backstage/plugin-app-backend": "^0.3.5", + "@backstage/plugin-auth-backend": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-backend": "^0.5.5", + "@backstage/plugin-catalog-import": "^0.3.6", "@backstage/plugin-circleci": "^0.2.6", "@backstage/plugin-explore": "^0.2.3", "@backstage/plugin-github-actions": "^0.3.0", "@backstage/plugin-lighthouse": "^0.2.8", "@backstage/plugin-proxy-backend": "^0.2.4", "@backstage/plugin-rollbar-backend": "^0.1.7", - "@backstage/plugin-scaffolder": "^0.4.0", + "@backstage/plugin-scaffolder": "^0.4.1", "@backstage/plugin-search": "^0.2.6", - "@backstage/plugin-scaffolder-backend": "^0.5.1", + "@backstage/plugin-scaffolder-backend": "^0.5.2", "@backstage/plugin-tech-radar": "^0.3.3", "@backstage/plugin-techdocs": "^0.5.4", "@backstage/plugin-techdocs-backend": "^0.5.4", diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index a703fb9305..e6a047921c 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -42,6 +42,7 @@ import { version as pluginApiDocs } from '../../../../plugins/api-docs/package.j import { version as pluginAppBackend } from '../../../../plugins/app-backend/package.json'; import { version as pluginAuthBackend } from '../../../../plugins/auth-backend/package.json'; import { version as pluginCatalog } from '../../../../plugins/catalog/package.json'; +import { version as pluginCatalogReact } from '../../../../plugins/catalog-react/package.json'; import { version as pluginCatalogBackend } from '../../../../plugins/catalog-backend/package.json'; import { version as pluginCatalogImport } from '../../../../plugins/catalog-import/package.json'; import { version as pluginCircleci } from '../../../../plugins/circleci/package.json'; @@ -68,6 +69,7 @@ export const packageVersions = { '@backstage/plugin-app-backend': pluginAppBackend, '@backstage/plugin-auth-backend': pluginAuthBackend, '@backstage/plugin-catalog': pluginCatalog, + '@backstage/plugin-catalog-react': pluginCatalogReact, '@backstage/plugin-catalog-backend': pluginCatalogBackend, '@backstage/plugin-catalog-import': pluginCatalogImport, '@backstage/plugin-circleci': pluginCircleci, diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index d253102f71..495b30dd6f 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -56,14 +56,13 @@ proxy: target: 'https://example.com' changeOrigin: true +# Reference documentation http://backstage.io/docs/features/techdocs/configuration techdocs: - requestUrl: http://localhost:7000/api/techdocs - storageUrl: http://localhost:7000/api/techdocs/static/docs - builder: 'local' + builder: 'local' # Alternatives - 'external' generators: - techdocs: 'docker' + techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' + type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. auth: # see https://backstage.io/docs/tutorials/quickstart-app-auth to know more about enabling auth providers diff --git a/packages/create-app/templates/default-app/catalog-info.yaml.hbs b/packages/create-app/templates/default-app/catalog-info.yaml.hbs index 0f8e4f5aeb..d66da552b7 100644 --- a/packages/create-app/templates/default-app/catalog-info.yaml.hbs +++ b/packages/create-app/templates/default-app/catalog-info.yaml.hbs @@ -6,7 +6,7 @@ metadata: # Example for optional annotations # annotations: # github.com/project-slug: backstage/backstage - # backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + # backstage.io/techdocs-ref: url:https://github.com/backstage/backstage spec: type: website owner: john@example.com diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index e40ed6f03d..c7561fe091 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -10,6 +10,7 @@ "@backstage/core": "^{{version '@backstage/core'}}", "@backstage/plugin-api-docs": "^{{version '@backstage/plugin-api-docs'}}", "@backstage/plugin-catalog": "^{{version '@backstage/plugin-catalog'}}", + "@backstage/plugin-catalog-react": "^{{version '@backstage/plugin-catalog-react'}}", "@backstage/plugin-catalog-import": "^{{version '@backstage/plugin-catalog-import'}}", "@backstage/plugin-scaffolder": "^{{version '@backstage/plugin-scaffolder'}}", "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx index b5e384f7a9..a3a3715ab0 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -24,8 +24,10 @@ import { } from '@backstage/plugin-api-docs'; import { AboutCard, EntityPageLayout, - useEntity } from '@backstage/plugin-catalog'; +import { + useEntity +} from '@backstage/plugin-catalog-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter } from '@backstage/plugin-circleci'; diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 629bc7c87e..eff2d6e218 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -31,7 +31,7 @@ "dependencies": { "@backstage/core": "^0.5.0", "@backstage/catalog-model": "^0.7.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/test-utils": "^0.1.5", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -47,7 +47,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx index 077a8f85e8..825a207064 100644 --- a/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx +++ b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ -import React from 'react'; -import { Grid, GridProps, makeStyles } from '@material-ui/core'; import { Entity } from '@backstage/catalog-model'; -import { EntityProvider } from '@backstage/plugin-catalog'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { BackstageTheme } from '@backstage/theme'; +import { Grid, GridProps, makeStyles } from '@material-ui/core'; +import React from 'react'; const useStyles = makeStyles(theme => ({ root: ({ entity }) => ({ diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index f1a6d30b4c..e1bdf4d0e9 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -34,17 +34,19 @@ import { attachComponentData, } from '@backstage/core'; import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied'; -import { Outlet } from 'react-router'; const GatheringRoute: (props: { path: string; - children: JSX.Element; -}) => JSX.Element = () => ; + element: JSX.Element; + children?: ReactNode; +}) => JSX.Element = ({ element }) => element; attachComponentData(GatheringRoute, 'core.gatherMountPoints', true); type RegisterPageOptions = { + path?: string; element: JSX.Element; + children?: JSX.Element; title?: string; icon?: IconComponent; }; @@ -93,21 +95,35 @@ class DevAppBuilder { return this; } - addPage({ element, title, icon }: RegisterPageOptions): DevAppBuilder { - const path = `/page-${this.routes.length + 1}`; - this.sidebarItems.push( - , - ); + /** + * Adds a page component along with accompanying sidebar item. + * + * If no path is provided one will be generated. + * If no title is provided, no sidebar item will be created. + */ + addPage(opts: RegisterPageOptions): DevAppBuilder { + const path = opts.path ?? `/page-${this.routes.length + 1}`; + if (opts.title) { + this.sidebarItems.push( + , + ); + } this.routes.push( - , + , ); return this; } + /** * Build a DevApp component using the resources registered so far */ diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index 2ddcc333d2..5f4894f9e9 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -104,11 +104,11 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) { { helpers: { version(name: string) { - const pkg = require(`${name}/package.json`); - if (!pkg) { + const pkge = require(`${name}/package.json`); + if (!pkge) { throw new Error(`No version available for package ${name}`); } - return pkg.version; + return pkge.version; }, }, }, diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 804fc0c63e..11eb164535 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/integration +## 0.3.1 + +### Patch Changes + +- 6800da78d: Fix default branch API url for custom hosted Bitbucket server +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. + ## 0.3.0 ### Minor Changes diff --git a/packages/integration/package.json b/packages/integration/package.json index 6c96a8562b..06a83ea883 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.3.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -37,7 +37,7 @@ "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@types/jest": "^26.0.7", "@types/luxon": "^1.25.0", diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts index b43e69eba4..44e427a5d0 100644 --- a/packages/integration/src/ScmIntegrations.test.ts +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -43,10 +43,10 @@ describe('ScmIntegrations', () => { } as GitLabIntegrationConfig); const i = new ScmIntegrations({ - azure: basicIntegrations([azure], i => i.config.host), - bitbucket: basicIntegrations([bitbucket], i => i.config.host), - github: basicIntegrations([github], i => i.config.host), - gitlab: basicIntegrations([gitlab], i => i.config.host), + azure: basicIntegrations([azure], item => item.config.host), + bitbucket: basicIntegrations([bitbucket], item => item.config.host), + github: basicIntegrations([github], item => item.config.host), + gitlab: basicIntegrations([gitlab], item => item.config.host), }); it('can get the specifics', () => { @@ -73,4 +73,19 @@ describe('ScmIntegrations', () => { expect(i.byHost('github.local')).toBe(github); expect(i.byHost('gitlab.local')).toBe(gitlab); }); + + it('can resolveUrl using fallback', () => { + expect( + i.resolveUrl({ + url: '../b.yaml', + base: 'https://no-matching-integration.com/x/a.yaml', + }), + ).toBe('https://no-matching-integration.com/b.yaml'); + expect( + i.resolveUrl({ + url: 'https://absolute.com/path', + base: 'https://no-matching-integration.com/x/a.yaml', + }), + ).toBe('https://absolute.com/path'); + }); }); diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index 102273a03b..c20fdcef9c 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -81,4 +81,13 @@ export class ScmIntegrations implements ScmIntegrationRegistry { .map(i => i.byHost(host)) .find(Boolean); } + + resolveUrl(options: { url: string; base: string }): string { + const resolve = this.byUrl(options.base)?.resolveUrl; + if (!resolve) { + return new URL(options.url, options.base).toString(); + } + + return resolve(options); + } } diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts index 90c098b637..8a10e40bcf 100644 --- a/packages/integration/src/azure/AzureIntegration.test.ts +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -41,4 +41,52 @@ describe('AzureIntegration', () => { expect(integration.type).toBe('azure'); expect(integration.title).toBe('h.com'); }); + + describe('resolveUrl', () => { + it('works for valid urls', () => { + const integration = new AzureIntegration({ + host: 'dev.azure.com', + } as any); + + expect( + integration.resolveUrl({ + url: '../a.yaml', + base: + 'https://dev.azure.com/organization/project/_git/repository?path=%2Ffolder%2Fcatalog-info.yaml', + }), + ).toBe( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', + ); + + expect( + integration.resolveUrl({ + url: './a.yaml', + base: 'https://dev.azure.com/organization/project/_git/repository', + }), + ).toBe( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', + ); + + expect( + integration.resolveUrl({ + url: 'https://absolute.com/path', + base: + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml', + }), + ).toBe('https://absolute.com/path'); + }); + + it('falls back to regular URL resolution if not in a repo', () => { + const integration = new AzureIntegration({ + host: 'dev.azure.com', + } as any); + + expect( + integration.resolveUrl({ + url: './test', + base: 'https://dev.azure.com/organization/project/_git', + }), + ).toBe('https://dev.azure.com/organization/project/test'); + }); + }); }); diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts index 446e6a9480..1413b7b343 100644 --- a/packages/integration/src/azure/AzureIntegration.ts +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import parseGitUrl from 'git-url-parse'; import { basicIntegrations } from '../helpers'; import { ScmIntegration, ScmIntegrationsFactory } from '../types'; import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config'; @@ -42,4 +43,39 @@ export class AzureIntegration implements ScmIntegration { get config(): AzureIntegrationConfig { return this.integrationConfig; } + + /* + * Azure repo URLs on the form with a `path` query param are treated specially. + * + * Example base URL: https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml + */ + resolveUrl(options: { url: string; base: string }): string { + const { url, base } = options; + + // If we can parse the url, it is absolute - then return it verbatim + try { + // eslint-disable-next-line no-new + new URL(url); + return url; + } catch { + // Ignore intentionally - looks like a relative path + } + + const parsed = parseGitUrl(base); + const { organization, owner, name, filepath } = parsed; + + // If not an actual file path within a repo, treat the URL as raw + if (!organization || !owner || !name) { + return new URL(url, base).toString(); + } + + const path = filepath?.replace(/^\//, '') || ''; + const mockBaseUrl = new URL(`https://a.com/${path}`); + const updatedPath = new URL(url, mockBaseUrl).pathname; + + const newUrl = new URL(base); + newUrl.searchParams.set('path', updatedPath); + + return newUrl.toString(); + } } diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts index 39707976fe..22b17d51fa 100644 --- a/packages/integration/src/bitbucket/core.test.ts +++ b/packages/integration/src/bitbucket/core.test.ts @@ -116,7 +116,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), @@ -125,6 +125,7 @@ describe('bitbucket core', () => { ), ), ); + const config: BitbucketIntegrationConfig = { host: 'bitbucket.mycompany.net', apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', @@ -144,7 +145,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), @@ -230,6 +231,41 @@ describe('bitbucket core', () => { displayId: 'main', }; worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const defaultBranch = await getBitbucketDefaultBranch( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + + it('return default branch for Bitbucket Server for bitbucket version 5.11', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(404), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), rest.get( 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', (_, res, ctx) => diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index ae61df497d..8b1d7d6b18 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -31,11 +31,20 @@ export async function getBitbucketDefaultBranch( const { name: repoName, owner: project, resource } = parseGitUrl(url); const isHosted = resource === 'bitbucket.org'; - const branchUrl = isHosted + // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184 + let branchUrl = isHosted ? `${config.apiBaseUrl}/repositories/${project}/${repoName}` - : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`; + + let response = await fetch(branchUrl, getBitbucketRequestOptions(config)); + + if (response.status === 404 && !isHosted) { + // First try the new format, and then if it gets specifically a 404 it should try the old format + // (to support old Atlassian Bitbucket v5.11.1 format ) + branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + response = await fetch(branchUrl, getBitbucketRequestOptions(config)); + } - const response = await fetch(branchUrl, getBitbucketRequestOptions(config)); if (!response.ok) { const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; throw new Error(message); @@ -50,7 +59,10 @@ export async function getBitbucketDefaultBranch( defaultBranch = displayId; } if (!defaultBranch) { - throw new Error(`Failed to read default branch from ${branchUrl}`); + throw new Error( + `Failed to read default branch from ${branchUrl}. ` + + `Response ${response.status} ${response.json()}`, + ); } return defaultBranch; } diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts index f708f75184..d295677c55 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -215,21 +215,21 @@ describe('GithubCredentialsProvider tests', () => { }); it('should return the default token if no app is configured', async () => { - const github = GithubCredentialsProvider.create({ + const githubProvider = GithubCredentialsProvider.create({ host: 'github.com', apps: [], token: 'fallback_token', }); await expect( - github.getCredentials({ + githubProvider.getCredentials({ url: 'https://github.com/404/foobar', }), ).resolves.toEqual(expect.objectContaining({ token: 'fallback_token' })); }); it('should return the configured token if listing installations throws', async () => { - const github = GithubCredentialsProvider.create({ + const githubProvider = GithubCredentialsProvider.create({ host: 'github.com', apps: [ { @@ -245,19 +245,19 @@ describe('GithubCredentialsProvider tests', () => { octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); await expect( - github.getCredentials({ + githubProvider.getCredentials({ url: 'https://github.com/backstage', }), ).resolves.toEqual(expect.objectContaining({ token: 'hardcoded_token' })); }); it('should return undefined if no token or apps are configured', async () => { - const github = GithubCredentialsProvider.create({ + const githubProvider = GithubCredentialsProvider.create({ host: 'github.com', }); await expect( - github.getCredentials({ + githubProvider.getCredentials({ url: 'https://github.com/backstage', }), ).resolves.toEqual({ headers: undefined, token: undefined }); diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index dfb809ee5f..a22c69909b 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -177,7 +177,7 @@ export class GithubAppCredentialsMux { ), ); - const result = results.find(result => result.credentials); + const result = results.find(resultItem => resultItem.credentials); if (result) { return result.credentials!.accessToken; } diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts index 43fea72e0b..2b4713604c 100644 --- a/packages/integration/src/gitlab/core.test.ts +++ b/packages/integration/src/gitlab/core.test.ts @@ -49,23 +49,23 @@ describe('gitlab core', () => { { config: configWithNoToken, url: - 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', result: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', }, { config: configWithToken, url: - 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', result: - 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', }, { config: configWithNoToken, url: - 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', // Repo not in subgroup result: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', }, // Raw URLs { diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts index 29dcc60bac..66b6733f56 100644 --- a/packages/integration/src/gitlab/core.ts +++ b/packages/integration/src/gitlab/core.ts @@ -109,7 +109,7 @@ export function buildProjectUrl(target: string, projectID: Number): URL { '/api/v4/projects', projectID, 'repository/files', - encodeURIComponent(filePath.join('/')), + encodeURIComponent(decodeURIComponent(filePath.join('/'))), 'raw', ].join('/'); url.search = `?ref=${branch}`; @@ -147,10 +147,15 @@ export async function getProjectId( repoIDLookup.toString(), getGitLabRequestOptions(config), ); - const projectIDJson = await response.json(); - const projectID = Number(projectIDJson.id); + const data = await response.json(); - return projectID; + if (!response.ok) { + throw new Error( + `GitLab Error '${data.error}', ${data.error_description}`, + ); + } + + return Number(data.id); } catch (e) { throw new Error(`Could not get GitLab project ID for: ${target}, ${e}`); } diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index d8fb7a14e2..7d5b0fbe74 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -34,6 +34,18 @@ export interface ScmIntegration { * differentiate between different integrations. */ title: string; + + /** + * Works like the two-argument form of the URL constructor, resolving an + * absolute or relative URL in relation to a base URL. + * + * If this method is not implemented, the URL constructor is used instead for + * URLs that match this integration. + * + * @param options.url The (absolute or relative) URL or path to resolve + * @param options.base The base URL onto which this resolution happens + */ + resolveUrl?(options: { url: string; base: string }): string; } /** @@ -69,6 +81,15 @@ export interface ScmIntegrationRegistry bitbucket: ScmIntegrationsGroup; github: ScmIntegrationsGroup; gitlab: ScmIntegrationsGroup; + + /** + * Works like the two-argument form of the URL constructor, resolving an + * absolute or relative URL in relation to a base URL. + * + * @param options.url The (absolute or relative) URL or path to resolve + * @param options.base The base URL onto which this resolution happens + */ + resolveUrl(options: { url: string; base: string }): string; } export type ScmIntegrationsFactory = (options: { diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 7bff39d3c4..568df3157c 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/techdocs-common +## 0.3.6 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- db2328c88: Add rate limiter for concurrent execution of file uploads in AWS and Google publishers +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + - @backstage/integration@0.3.1 + ## 0.3.5 ### Patch Changes diff --git a/packages/techdocs-common/__mocks__/@azure/identity.ts b/packages/techdocs-common/__mocks__/@azure/identity.ts new file mode 100644 index 0000000000..cc89a4d514 --- /dev/null +++ b/packages/techdocs-common/__mocks__/@azure/identity.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export class DefaultAzureCredential { + /** + * Creates an instance of the DefaultAzureCredential class. + */ +} diff --git a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts new file mode 100644 index 0000000000..60a705f6a7 --- /dev/null +++ b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts @@ -0,0 +1,86 @@ +/* + * 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 fs from 'fs'; + +export class BlockBlobClient { + private readonly blobName; + + constructor(blobName: string) { + this.blobName = blobName; + } + + uploadFile(source: string) { + return new Promise((resolve, reject) => { + if (!fs.existsSync(source)) { + reject(''); + } else { + resolve(''); + } + }); + } + + exists() { + return new Promise((resolve, reject) => { + if (fs.existsSync(this.blobName)) { + resolve(true); + } else { + reject({ message: 'The object doest not exist !' }); + } + }); + } +} + +export class ContainerClient { + private readonly containerName; + + constructor(containerName: string) { + this.containerName = containerName; + } + + getProperties() { + return new Promise(resolve => { + resolve(''); + }); + } + + getBlockBlobClient(blobName: string) { + return new BlockBlobClient(blobName); + } +} + +export class BlobServiceClient { + private readonly url; + private readonly credential; + + constructor(url: string, credential?: StorageSharedKeyCredential) { + this.url = url; + this.credential = credential; + } + + getContainerClient(containerName: string) { + return new ContainerClient(containerName); + } +} + +export class StorageSharedKeyCredential { + private readonly accountName; + private readonly accountKey; + + constructor(accountName: string, accountKey: string) { + this.accountName = accountName; + this.accountKey = accountKey; + } +} diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index dd4e4d8c35..2a065600a1 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.3.5", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -37,10 +37,12 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1.0", - "@backstage/backend-common": "^0.5.0", + "@azure/identity": "^1.2.2", + "@azure/storage-blob": "^12.4.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.3.0", + "@backstage/integration": "^0.3.1", "@google-cloud/storage": "^5.6.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", @@ -59,7 +61,7 @@ }, "devDependencies": { "@aws-sdk/types": "3.1.0", - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", "@types/js-yaml": "^3.12.5", diff --git a/packages/techdocs-common/src/stages/prepare/dir.test.ts b/packages/techdocs-common/src/stages/prepare/dir.test.ts index d082b1367b..630608e817 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.test.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.test.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { getVoidLogger, UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { DirectoryPreparer } from './dir'; import { checkoutGitRepository } from '../../helpers'; @@ -46,10 +46,18 @@ const createMockEntity = (annotations: {}) => { }; const mockConfig = new ConfigReader({}); +const mockUrlReader: jest.Mocked = { + read: jest.fn(), + readTree: jest.fn(), +}; describe('directory preparer', () => { it('should merge managed-by-location and techdocs-ref when techdocs-ref is relative', async () => { - const directoryPreparer = new DirectoryPreparer(mockConfig, logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -63,7 +71,11 @@ describe('directory preparer', () => { }); it('should merge managed-by-location and techdocs-ref when techdocs-ref is absolute', async () => { - const directoryPreparer = new DirectoryPreparer(mockConfig, logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -77,7 +89,11 @@ describe('directory preparer', () => { }); it('should merge managed-by-location and techdocs-ref when managed-by-location is a git repository', async () => { - const directoryPreparer = new DirectoryPreparer(mockConfig, logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': diff --git a/packages/techdocs-common/src/stages/prepare/dir.ts b/packages/techdocs-common/src/stages/prepare/dir.ts index 537f670edc..ab277fda9a 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.ts @@ -18,17 +18,19 @@ import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import path from 'path'; import { parseReferenceAnnotation, checkoutGitRepository } from '../../helpers'; -import { InputError } from '@backstage/backend-common'; +import { UrlReader, InputError } from '@backstage/backend-common'; import parseGitUrl from 'git-url-parse'; import { Logger } from 'winston'; export class DirectoryPreparer implements PreparerBase { - private readonly config: Config; - private readonly logger: Logger; - - constructor(config: Config, logger: Logger) { + constructor( + private readonly config: Config, + private readonly logger: Logger, + private readonly reader: UrlReader, + ) { this.config = config; this.logger = logger; + this.reader = reader; } private async resolveManagedByLocationToDir(entity: Entity) { @@ -41,9 +43,12 @@ export class DirectoryPreparer implements PreparerBase { `Building docs for entity with type 'dir' and managed-by-location '${type}'`, ); switch (type) { + case 'url': { + const response = await this.reader.readTree(target); + return await response.dir(); + } case 'github': case 'gitlab': - case 'url': case 'azure/api': { const parsedGitLocation = parseGitUrl(target); const repoLocation = await checkoutGitRepository( @@ -56,7 +61,6 @@ export class DirectoryPreparer implements PreparerBase { path.join(repoLocation, parsedGitLocation.filepath), ); } - case 'file': return path.dirname(target); default: diff --git a/packages/techdocs-common/src/stages/prepare/preparers.ts b/packages/techdocs-common/src/stages/prepare/preparers.ts index 4625d7b0e4..de825d5d41 100644 --- a/packages/techdocs-common/src/stages/prepare/preparers.ts +++ b/packages/techdocs-common/src/stages/prepare/preparers.ts @@ -35,17 +35,22 @@ export class Preparers implements PreparerBuilder { ): Promise { const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(config, logger); + const urlPreparer = new UrlPreparer(reader, logger); + preparers.register('url', urlPreparer); + + /** + * Dir preparer is a syntactic sugar for users to define techdocs-ref annotation. + * When using dir preparer, the docs will be fetched using URL Reader. + */ + const directoryPreparer = new DirectoryPreparer(config, logger, reader); preparers.register('dir', directoryPreparer); + // Common git preparers will be deprecated soon. const commonGitPreparer = new CommonGitPreparer(config, logger); preparers.register('github', commonGitPreparer); preparers.register('gitlab', commonGitPreparer); preparers.register('azure/api', commonGitPreparer); - const urlPreparer = new UrlPreparer(reader, logger); - preparers.register('url', urlPreparer); - return preparers; } diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts new file mode 100644 index 0000000000..80f3cb0cf7 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -0,0 +1,147 @@ +/* + * 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 mockFs from 'mock-fs'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; +import { PublisherBase } from './types'; +import type { Entity } from '@backstage/catalog-model'; + +const createMockEntity = (annotations = {}) => { + return { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'test-component-name', + namespace: 'test-namespace', + annotations: { + ...annotations, + }, + }, + }; +}; + +const getEntityRootDir = (entity: Entity) => { + const { + kind, + metadata: { namespace, name }, + } = entity; + const entityRootDir = `${namespace}/${kind}/${name}`; + return entityRootDir; +}; + +const logger = getVoidLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); +jest.spyOn(logger, 'error').mockReturnValue(logger); + +let publisher: PublisherBase; + +beforeEach(async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); +}); + +describe('AzureBlobStoragePublish', () => { + describe('publish', () => { + it('should publish a directory', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + expect( + await publisher.publish({ + entity, + directory: entityRootDir, + }), + ).toBeUndefined(); + mockFs.restore(); + }); + + it('should fail to publish a directory', async () => { + const wrongPathToGeneratedDirectory = 'wrong/path/to/generatedDirectory'; + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + await publisher + .publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }) + .catch(error => + expect(error.message).toContain( + 'Unable to upload file(s) to Azure Blob Storage. Error Failed to read template directory: ENOENT, no such file or directory', + ), + ); + mockFs.restore(); + }); + }); + + describe('hasDocsBeenGenerated', () => { + it('should return true if docs has been generated', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': 'file-content', + }, + }); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); + mockFs.restore(); + }); + + it('should return false if docs has not been generated', async () => { + const entity = createMockEntity(); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false); + }); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts new file mode 100644 index 0000000000..a02639e63f --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -0,0 +1,259 @@ +/* + * 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 platformPath from 'path'; +import express from 'express'; +import { + BlobServiceClient, + BlobUploadCommonResponse, + StorageSharedKeyCredential, +} from '@azure/storage-blob'; +import { DefaultAzureCredential } from '@azure/identity'; +import { Logger } from 'winston'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import limiterFactory from 'p-limit'; +import JSON5 from 'json5'; + +// The number of batches that may be ongoing at the same time. +const BATCH_CONCURRENCY = 3; + +export class AzureBlobStoragePublish implements PublisherBase { + static async fromConfig( + config: Config, + logger: Logger, + ): Promise { + let containerName = ''; + try { + containerName = config.getString( + 'techdocs.publisher.azureBlobStorage.containerName', + ); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " + + 'techdocs.publisher.azureBlobStorage.containerName is required.', + ); + } + + let accountName = ''; + try { + accountName = config.getString( + 'techdocs.publisher.azureBlobStorage.credentials.accountName', + ); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " + + 'techdocs.publisher.azureBlobStorage.credentials.accountName is required.', + ); + } + + // Credentials is an optional config. If missing, default Azure Blob Storage environment variables will be used. + // https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad-app + const accountKey = config.getOptionalString( + 'techdocs.publisher.azureBlobStorage.credentials.accountKey', + ); + + let credential; + if (accountKey) { + credential = new StorageSharedKeyCredential(accountName, accountKey); + } else { + credential = new DefaultAzureCredential(); + } + + const storageClient = new BlobServiceClient( + `https://${accountName}.blob.core.windows.net`, + credential, + ); + + await storageClient + .getContainerClient(containerName) + .getProperties() + .then(() => { + logger.info( + `Successfully connected to the Azure Blob Storage container ${containerName}.`, + ); + }) + .catch(reason => { + logger.error( + `Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` + + 'Make sure that the Azure project and container exist and the access key is setup correctly ' + + 'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + throw new Error( + `from Azure Blob Storage client library: ${reason.message}`, + ); + }); + + return new AzureBlobStoragePublish(storageClient, containerName, logger); + } + + constructor( + private readonly storageClient: BlobServiceClient, + private readonly containerName: string, + private readonly logger: Logger, + ) { + this.storageClient = storageClient; + this.containerName = containerName; + this.logger = logger; + } + + /** + * Upload all the files from the generated `directory` to the Azure Blob Storage container. + * Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html + */ + async publish({ entity, directory }: PublishRequest): Promise { + try { + // Note: Azure Blob Storage manages creation of parent directories if they do not exist. + // So collecting path of only the files is good enough. + const allFilesToUpload = await getFileTreeRecursively(directory); + + const uploadPromises: Array> = []; + + // Bound the number of concurrent batches. We want a bit of concurrency for + // performance reasons, but not so much that we starve the connection pool + // or start thrashing. + const limiter = limiterFactory(BATCH_CONCURRENCY); + + const promises = allFilesToUpload.map(filePath => { + // Remove the absolute path prefix of the source directory + // Path of all files to upload, relative to the root of the source directory + // e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png'] + const relativeFilePath = filePath.replace(`${directory}/`, ''); + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const destination = platformPath.normalize( + `${entityRootDir}/${relativeFilePath}`, + ); // Azure Blob Storage Container file relative path + + return limiter(async () => { + await uploadPromises.push( + this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(destination) + .uploadFile(filePath), + ); + }); + }); + + await Promise.all(promises).then(() => { + this.logger.info( + `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, + ); + }); + return; + } catch (e) { + const errorMessage = `Unable to upload file(s) to Azure Blob Storage. Error ${e.message}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + } + + private download(containerName: string, path: string): Promise { + return new Promise((resolve, reject) => { + const fileStreamChunks: Array = []; + this.storageClient + .getContainerClient(containerName) + .getBlockBlobClient(path) + .download() + .then(res => { + const body = res.readableStreamBody; + if (!body) { + reject(new Error(`Unable to parse the response data`)); + return; + } + body + .on('error', e => { + this.logger.error(e.message); + reject(e.message); + }) + .on('data', chunk => { + fileStreamChunks.push(chunk); + }) + .on('end', () => { + resolve(Buffer.concat(fileStreamChunks)); + }); + }); + }); + } + + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + try { + return await new Promise(resolve => { + const download = this.download( + this.containerName, + `${entityRootDir}/techdocs_metadata.json`, + ); + resolve(JSON5.parse(download.toString())); + }); + } catch (e) { + this.logger.error(e.message); + throw e; + } + } + + /** + * Express route middleware to serve static files on a route in techdocs-backend. + */ + docsRouter(): express.Handler { + return (req, res) => { + // Trim the leading forward slash + // filePath example - /default/Component/documented-component/index.html + const filePath = req.path.replace(/^\//, ''); + // Files with different extensions (CSS, HTML) need to be served with different headers + const fileExtension = platformPath.extname(filePath); + const responseHeaders = getHeadersForFileExtension(fileExtension); + + try { + this.download(this.containerName, filePath).then(fileContent => { + // Inject response headers + for (const [headerKey, headerValue] of Object.entries( + responseHeaders, + )) { + res.setHeader(headerKey, headerValue); + } + res.send(fileContent); + }); + } catch (e) { + this.logger.error(e.message); + res.status(404).send(e.message); + } + }; + } + + /** + * A helper function which checks if index.html of an Entity's docs site is available. This + * can be used to verify if there are any pre-generated docs available to serve. + */ + async hasDocsBeenGenerated(entity: Entity): Promise { + return new Promise(resolve => { + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(`${entityRootDir}/index.html`) + .exists() + .then((response: boolean) => { + resolve(response); + }) + .catch(() => { + resolve(false); + }); + }); + } +} diff --git a/packages/techdocs-common/src/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts index efd56285ef..cd1a17ab5f 100644 --- a/packages/techdocs-common/src/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -13,34 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -/* eslint-disable no-restricted-syntax */ -import fs from 'fs-extra'; -import path from 'path'; import { getVoidLogger, PluginEndpointDiscovery, } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; +import mockFs from 'mock-fs'; +import * as os from 'os'; import { LocalPublish } from './local'; -jest.mock('fs-extra', () => { - const fsOriginal = jest.requireActual('fs-extra'); - return { - ...fsOriginal, - access: jest.fn().mockImplementation((path, checkType, callback) => { - if ( - path.includes('http://localhost:7000/static') && - checkType === fs.constants.F_OK - ) { - callback(); - } else { - callback(new Error()); - } - }), - }; -}); - const createMockEntity = (annotations = {}) => { return { apiVersion: 'version', @@ -56,43 +37,33 @@ const createMockEntity = (annotations = {}) => { const logger = getVoidLogger(); +const tmpDir = + os.platform() === 'win32' ? 'C:\\tmp\\generatedDir' : '/tmp/generatedDir'; + describe('local publisher', () => { it('should publish generated documentation dir', async () => { - const testDiscovery: jest.Mocked = { - getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000'), - getExternalBaseUrl: jest.fn(), - }; - - const mockConfig = new ConfigReader({ - techdocs: { - requestUrl: 'http://localhost:7000', - storageUrl: 'http://localhost:7000/static/docs', + mockFs({ + [tmpDir]: { + 'index.html': '', }, }); + const testDiscovery: jest.Mocked = { + getBaseUrl: jest + .fn() + .mockResolvedValue('http://localhost:7000/api/techdocs'), + getExternalBaseUrl: jest.fn(), + }; + + const mockConfig = new ConfigReader({}); + const publisher = new LocalPublish(mockConfig, logger, testDiscovery); const mockEntity = createMockEntity(); - const tempDir = fs.mkdtempSync(`${__dirname}/test-component-folder-`); - expect(tempDir).toBeTruthy(); - fs.closeSync(fs.openSync(path.join(tempDir, '/mock-file'), 'w')); - await publisher.publish({ entity: mockEntity, directory: tempDir }); - - const publishDir = path.resolve( - __dirname, - `../../../../../plugins/techdocs-backend/static/docs/${mockEntity.metadata.name}`, - ); - const resultDir = path.resolve( - __dirname, - `../../../../../plugins/techdocs-backend/static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`, - ); - - expect(fs.existsSync(resultDir)).toBeTruthy(); - expect(fs.existsSync(path.join(resultDir, '/mock-file'))).toBeTruthy(); + await publisher.publish({ entity: mockEntity, directory: tmpDir }); expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true); - fs.removeSync(publishDir); - fs.removeSync(tempDir); + mockFs.restore(); }); }); diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index d07472e17d..e349a2119a 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -13,18 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import fetch from 'cross-fetch'; +import { + PluginEndpointDiscovery, + resolvePackagePath, +} from '@backstage/backend-common'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; import express from 'express'; import fs from 'fs-extra'; -import path from 'path'; import os from 'os'; +import path from 'path'; import { Logger } from 'winston'; -import { Entity, EntityName } from '@backstage/catalog-model'; -import { - resolvePackagePath, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; -import { Config } from '@backstage/config'; import { PublisherBase, PublishRequest, @@ -52,17 +51,14 @@ try { * called "static" at the root of techdocs-backend plugin. */ export class LocalPublish implements PublisherBase { - private readonly config: Config; - private readonly logger: Logger; - private readonly discovery: PluginEndpointDiscovery; - // TODO: Use a static fromConfig method to create a LocalPublish instance, similar to aws/gcs publishers. // Move the logic of setting staticDocsDir based on config over to fromConfig, // and set the value as a class parameter. constructor( - config: Config, - logger: Logger, - discovery: PluginEndpointDiscovery, + // @ts-ignore + private readonly config: Config, + private readonly logger: Logger, + private readonly discovery: PluginEndpointDiscovery, ) { this.config = config; this.logger = logger; @@ -107,34 +103,25 @@ export class LocalPublish implements PublisherBase { }); } - fetchTechDocsMetadata(entityName: EntityName): Promise { - return new Promise((resolve, reject) => { - this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { - const storageUrl = new URL( - new URL(this.config.getString('techdocs.storageUrl')).pathname, - techdocsApiUrl, - ).toString(); + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { + const metadataPath = path.join( + staticDocsDir, + entityName.namespace, + entityName.kind, + entityName.name, + 'techdocs_metadata.json', + ); - const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; - const metadataURL = `${storageUrl}/${entityRootDir}/techdocs_metadata.json`; - fetch(metadataURL) - .then(response => - response - .json() - .then(techdocsMetadata => resolve(techdocsMetadata)) - .catch(err => { - reject( - `Unable to parse metadata JSON for ${entityRootDir}. Error: ${err}`, - ); - }), - ) - .catch(err => { - reject( - `Unable to fetch metadata for ${entityRootDir}. Error ${err}`, - ); - }); - }); - }); + try { + return await fs.readJson(metadataPath); + } catch (err) { + this.logger.error( + `Unable to read techdocs_metadata.json at ${metadataPath}. Error: ${err}`, + ); + throw new Error(err.message); + } } docsRouter(): express.Handler { @@ -143,24 +130,21 @@ export class LocalPublish implements PublisherBase { async hasDocsBeenGenerated(entity: Entity): Promise { const namespace = entity.metadata.namespace ?? 'default'; - return new Promise(resolve => { - this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { - const storageUrl = new URL( - new URL(this.config.getString('techdocs.storageUrl')).pathname, - techdocsApiUrl, - ).toString(); - const entityRootDir = `${namespace}/${entity.kind}/${entity.metadata.name}`; - const indexHtmlUrl = `${storageUrl}/${entityRootDir}/index.html`; - // Check if the file exists - fs.access(indexHtmlUrl, fs.constants.F_OK, err => { - if (err) { - resolve(false); - } else { - resolve(true); - } - }); - }); - }); + const indexHtmlPath = path.join( + staticDocsDir, + namespace, + entity.kind, + entity.metadata.name, + 'index.html', + ); + + // Check if the file exists + try { + fs.access(indexHtmlPath, fs.constants.F_OK); + return true; + } catch (err) { + return false; + } } } diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index 89f2c0ffdd..d1faf82b78 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -22,6 +22,7 @@ import { Publisher } from './publish'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; const logger = getVoidLogger(); const discovery: jest.Mocked = { @@ -30,6 +31,10 @@ const discovery: jest.Mocked = { }; describe('Publisher', () => { + beforeEach(() => { + jest.resetModules(); // clear the cache + }); + it('should create local publisher by default', async () => { const mockConfig = new ConfigReader({ techdocs: { @@ -105,4 +110,55 @@ describe('Publisher', () => { }); expect(publisher).toBeInstanceOf(AwsS3Publish); }); + + it('should create Azure Blob Storage publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(AzureBlobStoragePublish); + }); + + it('should create Azure Blob Storage publisher from environment variables', async () => { + process.env.AZURE_TENANT_ID = 'AZURE_TENANT_ID'; + process.env.AZURE_CLIENT_ID = 'AZURE_CLIENT_ID'; + process.env.AZURE_CLIENT_SECRET = 'AZURE_CLIENT_SECRET'; + + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(AzureBlobStoragePublish); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index 82232c2fd1..a4e33a2d9c 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -21,6 +21,7 @@ import { PublisherType, PublisherBase } from './types'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; type factoryOptions = { logger: Logger; @@ -47,6 +48,11 @@ export class Publisher { case 'awsS3': logger.info('Creating AWS S3 Bucket publisher for TechDocs'); return AwsS3Publish.fromConfig(config, logger); + case 'azureBlobStorage': + logger.info( + 'Creating Azure Blob Storage Container publisher for TechDocs', + ); + return AzureBlobStoragePublish.fromConfig(config, logger); case 'local': logger.info('Creating Local publisher for TechDocs'); return new LocalPublish(config, logger, discovery); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index db6a075d43..5e953deb81 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -19,7 +19,11 @@ import express from 'express'; /** * Key for all the different types of TechDocs publishers that are supported. */ -export type PublisherType = 'local' | 'googleGcs' | 'awsS3'; +export type PublisherType = + | 'local' + | 'googleGcs' + | 'awsS3' + | 'azureBlobStorage'; export type PublishRequest = { entity: Entity; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 8c5a7c1f39..10f0e89c14 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.3", + "@backstage/cli": "^0.5.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/theme/package.json b/packages/theme/package.json index 54f43a1443..24edf81bb6 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.4.1" + "@backstage/cli": "^0.5.0" }, "files": [ "dist" diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 5b233ac303..fd13343ca8 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -58,7 +58,7 @@ export const lightTheme = createTheme({ infoBackground: '#ebf5ff', errorText: '#CA001B', infoText: '#004e8a', - warningText: '#FEFEFE', + warningText: '#000000', linkHover: '#2196F3', link: '#0A6EBE', gold: yellow.A700, @@ -120,7 +120,7 @@ export const darkTheme = createTheme({ infoBackground: '#ebf5ff', errorText: '#CA001B', infoText: '#004e8a', - warningText: '#FEFEFE', + warningText: '#000000', linkHover: '#2196F3', link: '#0A6EBE', gold: yellow.A700, diff --git a/plugins/api-docs/dev/example-api.yaml b/plugins/api-docs/dev/example-api.yaml new file mode 100644 index 0000000000..a314b4e255 --- /dev/null +++ b/plugins/api-docs/dev/example-api.yaml @@ -0,0 +1,124 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: petstore + description: The petstore API + tags: + - store + - rest +spec: + type: openapi + lifecycle: experimental + owner: team-c + definition: | + openapi: "3.0.0" + info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT + servers: + - url: http://petstore.swagger.io/v1 + paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/plugins/api-docs/dev/index.tsx b/plugins/api-docs/dev/index.tsx index 812a5585d4..4bf0215f32 100644 --- a/plugins/api-docs/dev/index.tsx +++ b/plugins/api-docs/dev/index.tsx @@ -14,7 +14,23 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { ApiExplorerPage, apiDocsPlugin } from '../src/plugin'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import petstoreApiEntity from './example-api.yaml'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + (({ + async getEntities() { + return { items: [petstoreApiEntity] }; + }, + } as unknown) as typeof catalogApiRef.T), + }) + .registerPlugin(apiDocsPlugin) + .addPage({ element: }) + .render(); diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index d9c0dc0968..4bd7bd1727 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -32,7 +32,7 @@ "@asyncapi/react-component": "^0.18.2", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", @@ -49,7 +49,7 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx index d59699ab9a..0107472acf 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx @@ -16,6 +16,7 @@ import { ApiEntity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -54,7 +55,7 @@ paths: get: summary: List all artists responses: - "200": + "200": description: Success `; const apiEntity: ApiEntity = { @@ -74,14 +75,16 @@ paths: type: 'openapi', title: 'OpenAPI', rawLanguage: 'yaml', - component: definition => ( - + component: definitionString => ( + ), }); const { getByText } = await renderInTestApp( - + + + , ); @@ -110,7 +113,9 @@ paths: const { getByText } = await renderInTestApp( - + + + , ); diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx index 19370bfc18..0a4386e275 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx @@ -15,6 +15,7 @@ */ import { ApiEntity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { CardTab, TabbedCard, useApi } from '@backstage/core'; import { Alert } from '@material-ui/lab'; import React from 'react'; @@ -22,29 +23,31 @@ import { apiDocsConfigRef } from '../../config'; import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget'; type Props = { + /** @deprecated The entity is now grabbed from context instead */ apiEntity?: ApiEntity; }; -export const ApiDefinitionCard = ({ apiEntity }: Props) => { +export const ApiDefinitionCard = (_: Props) => { + const entity = useEntity().entity as ApiEntity; const config = useApi(apiDocsConfigRef); const { getApiDefinitionWidget } = config; - if (!apiEntity) { + if (!entity) { return Could not fetch the API; } - const definitionWidget = getApiDefinitionWidget(apiEntity); + const definitionWidget = getApiDefinitionWidget(entity); if (definitionWidget) { return ( - + - {definitionWidget.component(apiEntity.spec.definition)} + {definitionWidget.component(entity.spec.definition)} @@ -53,13 +56,13 @@ export const ApiDefinitionCard = ({ apiEntity }: Props) => { return ( + , ]} diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index 10495bb957..67462b630c 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -16,7 +16,7 @@ import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index a602877525..df404ce7cd 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -15,7 +15,7 @@ */ import { Content, ContentHeader, SupportButton, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Button } from '@material-ui/core'; import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 149f7470c3..101c28332a 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -14,7 +14,13 @@ * limitations under the License. */ -import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model'; +import { + ApiEntityV1alpha1, + Entity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { Table, TableColumn, @@ -22,47 +28,77 @@ import { TableState, useQueryParamState, } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import { Chip } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React from 'react'; import { ApiTypeTitle } from '../ApiDefinitionCard'; -import { EntityLink } from '../EntityLink'; -const columns: TableColumn[] = [ +type EntityRow = { + entity: ApiEntityV1alpha1; + resolved: { + name: string; + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'resolved.name', highlight: true, - render: (entity: any) => ( - {entity.metadata.name} + render: ({ entity }) => ( + + ), + }, + { + title: 'System', + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( + ), }, { title: 'Owner', - field: 'spec.owner', - }, - { - title: 'Lifecycle', - field: 'spec.lifecycle', - }, - { - title: 'Type', - field: 'spec.type', - render: (entity: Entity) => ( - + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + ), }, + { + title: 'Lifecycle', + field: 'entity.spec.lifecycle', + }, + { + title: 'Type', + field: 'entity.spec.type', + render: ({ entity }) => , + }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', }, { title: 'Tags', - field: 'metadata.tags', + field: 'entity.metadata.tags', cellStyle: { padding: '0px 16px 0px 20px', }, - render: (entity: Entity) => ( + render: ({ entity }) => ( <> {entity.metadata.tags && entity.metadata.tags.map(t => ( @@ -123,8 +159,36 @@ export const ApiExplorerTable = ({ ); } + const rows = entities.map(entity => { + const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'system', + }); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity: entity as ApiEntityV1alpha1, + resolved: { + name: formatEntityRefTitle(entity, { + defaultKind: 'API', + }), + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), + partOfSystemRelations, + }, + }; + }); + return ( - isLoading={loading} columns={columns} options={{ @@ -134,7 +198,7 @@ export const ApiExplorerTable = ({ padding: 'dense', showEmptyDataSourceMessage: !loading, }} - data={entities} + data={rows} filters={filters} initialState={queryParamState} onStateChange={setQueryParamState} diff --git a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx index 7db62433eb..30716a61fb 100644 --- a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx +++ b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx @@ -14,37 +14,73 @@ * limitations under the License. */ -import { ApiEntity } from '@backstage/catalog-model'; +import { + ApiEntity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { Table, TableColumn } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import React from 'react'; import { ApiTypeTitle } from '../ApiDefinitionCard'; -import { EntityLink } from '../EntityLink'; -const columns: TableColumn[] = [ +type EntityRow = { + entity: ApiEntity; + resolved: { + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'entity.metadata.name', highlight: true, - render: (entity: any) => ( - {entity.metadata.name} + render: ({ entity }) => ( + {entity.metadata.name} + ), + }, + { + title: 'System', + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( + ), }, { title: 'Owner', - field: 'spec.owner', + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + + ), }, { title: 'Lifecycle', - field: 'spec.lifecycle', + field: 'entity.spec.lifecycle', }, { title: 'Type', - field: 'spec.type', - render: (entity: ApiEntity) => , + field: 'entity.spec.type', + render: ({ entity }) => , }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', width: 'auto', }, ]; @@ -65,8 +101,40 @@ export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { tableStyle.height = 'calc(100% - 10px)'; } + const rows = entities + // TODO: For now we skip all APIs that we can't find without a warning! + .filter(e => e !== undefined) + .map(entity => { + const partOfSystemRelations = getEntityRelations( + entity, + RELATION_PART_OF, + { + kind: 'system', + }, + ); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity: entity as ApiEntity, + resolved: { + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), + partOfSystemRelations, + }, + }; + }); + return ( - + columns={columns} title={title} style={tableStyle} @@ -77,8 +145,7 @@ export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { actionsColumnIndex: -1, padding: 'dense', }} - // TODO: For now we skip all APIs that we can't find without a warning! - data={entities.filter(e => e !== undefined) as ApiEntity[]} + data={rows} /> ); }; diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index a4a1e15511..051b9588a0 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -14,9 +14,18 @@ * limitations under the License. */ -import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; +import { + Entity, + RELATION_CONSUMES_API, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -63,7 +72,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -99,10 +110,27 @@ describe('', () => { }, spec: { type: 'openapi', - owner: 'Test', lifecycle: 'production', definition: '...', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ type: 'openapi', @@ -112,7 +140,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -121,6 +151,7 @@ describe('', () => { expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/OpenAPI/)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/MySystem/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx index 0bd4919554..8a808a6488 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_CONSUMES_API, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { ApisTable } from './ApisTable'; @@ -37,11 +38,13 @@ const ApisCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ConsumedApisCard = ({ entity, variant = 'gridItem' }: Props) => { +export const ConsumedApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_CONSUMES_API, diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index 1f42ff9060..482534f58a 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -14,9 +14,18 @@ * limitations under the License. */ -import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; +import { + Entity, + RELATION_OWNED_BY, + RELATION_PART_OF, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -63,7 +72,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -99,10 +110,27 @@ describe('', () => { }, spec: { type: 'openapi', - owner: 'Test', lifecycle: 'production', definition: '...', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ type: 'openapi', @@ -112,7 +140,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -120,6 +150,7 @@ describe('', () => { expect(getByText(/Provided APIs/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/OpenAPI/)).toBeInTheDocument(); + expect(getByText(/MySystem/)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx index 618f2dc1f6..ed6e893a03 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { ApisTable } from './ApisTable'; @@ -37,11 +38,13 @@ const ApisCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ProvidedApisCard = ({ entity, variant = 'gridItem' }: Props) => { +export const ProvidedApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_PROVIDES_API, diff --git a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx index 1b62a56d10..bd9b0c765f 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx @@ -14,35 +14,71 @@ * limitations under the License. */ -import { ComponentEntity } from '@backstage/catalog-model'; +import { + ComponentEntity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { Table, TableColumn } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import React from 'react'; -import { EntityLink } from '../EntityLink'; -const columns: TableColumn[] = [ +type EntityRow = { + entity: ComponentEntity; + resolved: { + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'entity.metadata.name', highlight: true, - render: (entity: any) => ( - {entity.metadata.name} + render: ({ entity }) => ( + {entity.metadata.name} + ), + }, + { + title: 'System', + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( + ), }, { title: 'Owner', - field: 'spec.owner', + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + + ), }, { title: 'Lifecycle', - field: 'spec.lifecycle', + field: 'entity.spec.lifecycle', }, { title: 'Type', - field: 'spec.type', + field: 'entity.spec.type', }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', width: 'auto', }, ]; @@ -68,8 +104,40 @@ export const ComponentsTable = ({ tableStyle.height = 'calc(100% - 10px)'; } + const rows = entities + // TODO: For now we skip all Components that we can't find without a warning! + .filter(e => e !== undefined) + .map(entity => { + const partOfSystemRelations = getEntityRelations( + entity, + RELATION_PART_OF, + { + kind: 'system', + }, + ); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); + + return { + entity: entity as ComponentEntity, + resolved: { + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), + partOfSystemRelations, + }, + }; + }); + return ( - + columns={columns} title={title} style={tableStyle} @@ -80,8 +148,7 @@ export const ComponentsTable = ({ actionsColumnIndex: -1, padding: 'dense', }} - // TODO: For now we skip all APIs that we can't find without a warning! - data={entities.filter(e => e !== undefined) as ComponentEntity[]} + data={rows} /> ); }; diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 606ff7e77b..f45a48842d 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -14,9 +14,18 @@ * limitations under the License. */ -import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; +import { + Entity, + RELATION_API_CONSUMED_BY, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -62,7 +71,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -104,14 +115,33 @@ describe('', () => { }, spec: { type: 'service', - owner: 'Test', lifecycle: 'production', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); const { getByText } = await renderInTestApp( - + + + , ); @@ -119,6 +149,7 @@ describe('', () => { expect(getByText(/Consumers/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/MySystem/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx index 0431367aa2..56d2fb977d 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_API_CONSUMED_BY, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { MissingConsumesApisEmptyState } from '../EmptyState'; @@ -37,14 +38,13 @@ const ComponentsCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ConsumingComponentsCard = ({ - entity, - variant = 'gridItem', -}: Props) => { +export const ConsumingComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_API_CONSUMED_BY, diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index d1cec1722a..4c55393d13 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -14,9 +14,18 @@ * limitations under the License. */ -import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; +import { + Entity, + RELATION_API_PROVIDED_BY, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -62,7 +71,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -104,14 +115,33 @@ describe('', () => { }, spec: { type: 'service', - owner: 'Test', lifecycle: 'production', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); const { getByText } = await renderInTestApp( - + + + , ); @@ -119,6 +149,7 @@ describe('', () => { expect(getByText(/Providers/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/MySystem/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx index 9e405a3af3..959ff3feed 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_API_PROVIDED_BY, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { MissingProvidesApisEmptyState } from '../EmptyState'; @@ -37,14 +38,13 @@ const ComponentsCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ProvidingComponentsCard = ({ - entity, - variant = 'gridItem', -}: Props) => { +export const ProvidingComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_API_PROVIDED_BY, diff --git a/plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx b/plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx deleted file mode 100644 index 9221b96604..0000000000 --- a/plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 { Entity } from '@backstage/catalog-model'; -import { renderInTestApp } from '@backstage/test-utils'; -import React from 'react'; -import { EntityLink } from './EntityLink'; - -describe('', () => { - it('provides link to entity page', async () => { - const entity: Entity = { - apiVersion: 'v1', - kind: 'Component', - metadata: { - name: 'my-name', - namespace: 'my-namespace', - }, - }; - const { getByText } = await renderInTestApp( - inner, - ); - expect(getByText(/inner/i)).toBeInTheDocument(); - expect(getByText(/inner/i)).toHaveAttribute( - 'href', - '/catalog/my-namespace/component/my-name', - ); - }); -}); diff --git a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx b/plugins/api-docs/src/components/EntityLink/EntityLink.tsx deleted file mode 100644 index 98b2103882..0000000000 --- a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 { Entity } from '@backstage/catalog-model'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; -import { Link } from '@material-ui/core'; -import React, { PropsWithChildren } from 'react'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; - -type Props = { - entity: Entity; -}; - -// TODO: Could be useful for others too, as part of the catalog plugin -export const EntityLink = ({ entity, children }: PropsWithChildren) => { - return ( - - {children} - - ); -}; diff --git a/plugins/api-docs/src/components/useRelatedEntities.ts b/plugins/api-docs/src/components/useRelatedEntities.ts index 847ec30578..5c01d38189 100644 --- a/plugins/api-docs/src/components/useRelatedEntities.ts +++ b/plugins/api-docs/src/components/useRelatedEntities.ts @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useAsyncRetry } from 'react-use'; // TODO: Maybe this hook is interesting for others too? diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index f09aeb1038..e7484f77c3 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -15,4 +15,13 @@ */ export * from './components'; -export { plugin } from './plugin'; +export { + apiDocsPlugin, + apiDocsPlugin as plugin, + ApiExplorerPage, + EntityApiDefinitionCard, + EntityConsumedApisCard, + EntityConsumingComponentsCard, + EntityProvidedApisCard, + EntityProvidingComponentsCard, +} from './plugin'; diff --git a/plugins/api-docs/src/plugin.test.ts b/plugins/api-docs/src/plugin.test.ts index 054e804382..eaafd36b3d 100644 --- a/plugins/api-docs/src/plugin.test.ts +++ b/plugins/api-docs/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { apiDocsPlugin } from './plugin'; describe('api-docs', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(apiDocsPlugin).toBeDefined(); }); }); diff --git a/plugins/api-docs/src/plugin.ts b/plugins/api-docs/src/plugin.ts index 06db03f2e1..429fd60095 100644 --- a/plugins/api-docs/src/plugin.ts +++ b/plugins/api-docs/src/plugin.ts @@ -15,14 +15,22 @@ */ import { ApiEntity } from '@backstage/catalog-model'; -import { createApiFactory, createPlugin } from '@backstage/core'; -import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage'; +import { + createApiFactory, + createPlugin, + createRoutableExtension, + createComponentExtension, +} from '@backstage/core'; +import { ApiExplorerPage as Page } from './components/ApiExplorerPage/ApiExplorerPage'; import { defaultDefinitionWidgets } from './components/ApiDefinitionCard'; import { rootRoute } from './routes'; import { apiDocsConfigRef } from './config'; -export const plugin = createPlugin({ +export const apiDocsPlugin = createPlugin({ id: 'api-docs', + routes: { + root: rootRoute, + }, apis: [ createApiFactory({ api: apiDocsConfigRef, @@ -38,6 +46,63 @@ export const plugin = createPlugin({ }), ], register({ router }) { - router.addRoute(rootRoute, ApiExplorerPage); + router.addRoute(rootRoute, Page); }, }); + +export const ApiExplorerPage = apiDocsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ApiExplorerPage').then(m => m.ApiExplorerPage), + mountPoint: rootRoute, + }), +); + +export const EntityApiDefinitionCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ApiDefinitionCard').then(m => m.ApiDefinitionCard), + }, + }), +); + +export const EntityConsumedApisCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ApisCards').then(m => m.ConsumedApisCard), + }, + }), +); + +export const EntityConsumingComponentsCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ComponentsCards').then( + m => m.ConsumingComponentsCard, + ), + }, + }), +); + +export const EntityProvidedApisCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ApisCards').then(m => m.ProvidedApisCard), + }, + }), +); + +export const EntityProvidingComponentsCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ComponentsCards').then( + m => m.ProvidingComponentsCard, + ), + }, + }), +); diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index 90a4eac95d..514e32633a 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-app-backend +## 0.3.5 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] + - @backstage/backend-common@0.5.1 + - @backstage/config-loader@0.5.0 + ## 0.3.4 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index f55da9042a..f397ea1475 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", - "@backstage/config-loader": "^0.4.0", + "@backstage/backend-common": "^0.5.1", + "@backstage/config-loader": "^0.5.0", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -40,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/supertest": "^2.0.8", "msw": "^0.20.5", "supertest": "^4.0.2" diff --git a/plugins/app-backend/src/lib/config.ts b/plugins/app-backend/src/lib/config.ts index 99590c1e2d..0607956894 100644 --- a/plugins/app-backend/src/lib/config.ts +++ b/plugins/app-backend/src/lib/config.ts @@ -84,13 +84,21 @@ export async function readConfigs(options: ReadOptions): Promise { const schemaPath = resolvePath(appDistDir, '.config-schema.json'); if (await fs.pathExists(schemaPath)) { const serializedSchema = await fs.readJson(schemaPath); - const schema = await loadConfigSchema({ serialized: serializedSchema }); - const frontendConfigs = await schema.process( - [{ data: config.get() as JsonObject, context: 'app' }], - { visibility: ['frontend'] }, - ); - appConfigs.push(...frontendConfigs); + try { + const schema = await loadConfigSchema({ serialized: serializedSchema }); + + const frontendConfigs = await schema.process( + [{ data: config.get() as JsonObject, context: 'app' }], + { visibility: ['frontend'] }, + ); + appConfigs.push(...frontendConfigs); + } catch (error) { + throw new Error( + 'Invalid schema embedded in the app bundle, to fix this issue you need ' + + `to correct the schema and then rebuild the app bundle. ${error}`, + ); + } } return appConfigs; diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 0df0fa1938..e3ff6aaa43 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-auth-backend +## 0.2.12 + +### Patch Changes + +- d7b1d317f: Fixed serialization issue with caching of public keys in AWS ALB auth provider +- 39b05b9ae: Use .text instead of .json for ALB key response +- 4eaa06057: Fix AWS ALB issuer check +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.2.11 ### Patch Changes diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 439b60e3ce..b1d9a7cf85 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.2.11", + "version": "0.2.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-client": "^0.3.5", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", @@ -65,7 +65,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 77620c1cea..0e446bf0f7 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-backend +## 0.5.5 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- a91aa6bf2: Support supplying a custom catalog descriptor file parser +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.5.4 ### Patch Changes diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index eaf1f69938..cc0a9e412a 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.5.4", + "version": "0.5.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,10 @@ "dependencies": { "@aws-sdk/client-organizations": "^3.2.0", "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", + "@backstage/integration": "^0.3.1", "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", "@types/ldapjs": "^1.0.9", @@ -57,7 +58,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.6", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 76fc63c9b3..b03183cf34 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -124,7 +124,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // produce tens of thousands of entities, and those are too large batch // sizes to reasonably send to the database. const batches = Object.values(requestsByKindAndNamespace) - .map(requests => chunk(requests, BATCH_SIZE)) + .map(request => chunk(request, BATCH_SIZE)) .flat(); // Bound the number of concurrent batches. We want a bit of concurrency for diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 046b6bdf76..a789e669d0 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { UrlReader } from '@backstage/backend-common'; +import { NotAllowedError, UrlReader } from '@backstage/backend-common'; import { Entity, EntityPolicy, @@ -102,7 +102,7 @@ export class LocationReaders implements LocationReader { } else { output.errors.push({ location: item.location, - error: new Error( + error: new NotAllowedError( `Entity of kind ${item.entity.kind} is not allowed from location ${item.location.type} ${item.location.target}`, ), }); diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts index 6eb7a45c63..28200021cc 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts @@ -65,7 +65,10 @@ describe('AwsOrganizationCloudAccountProcessor', () => { }); it('filters out accounts not in specified location target', async () => { - const location = { type: 'aws-cloud-accounts', target: 'o-1vl18kc5a3' }; + const locationTest = { + type: 'aws-cloud-accounts', + target: 'o-1vl18kc5a3', + }; listAccounts.mockImplementation(() => Promise.resolve({ Accounts: [ @@ -83,11 +86,11 @@ describe('AwsOrganizationCloudAccountProcessor', () => { NextToken: undefined, }), ); - await processor.readLocation(location, false, emit); + await processor.readLocation(locationTest, false, emit); expect(emit).toBeCalledTimes(1); expect(emit).toBeCalledWith({ type: 'entity', - location, + location: locationTest, entity: { apiVersion: 'backstage.io/v1alpha1', kind: 'Resource', diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index c75a46874d..ef94c54010 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -66,8 +66,8 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { async validateEntityKind(entity: Entity): Promise { for (const validator of this.validators) { - const result = await validator.check(entity); - if (result) { + const results = await validator.check(entity); + if (results) { return true; } } diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts index 998dfb62a2..476510d757 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.test.ts @@ -15,30 +15,73 @@ */ import { LocationSpec } from '@backstage/catalog-model'; -import { toAbsoluteUrl } from './LocationEntityProcessor'; +import { ConfigReader } from '@backstage/config'; +import { + ScmIntegrations, + ScmIntegrationRegistry, +} from '@backstage/integration'; import path from 'path'; +import { toAbsoluteUrl } from './LocationEntityProcessor'; describe('LocationEntityProcessor', () => { describe('toAbsoluteUrl', () => { it('handles files', () => { + const integrations = ({} as unknown) as ScmIntegrationRegistry; const base: LocationSpec = { type: 'file', target: `some${path.sep}path${path.sep}catalog-info.yaml`, }; - expect(toAbsoluteUrl(base, `.${path.sep}c`)).toBe( + expect(toAbsoluteUrl(integrations, base, `.${path.sep}c`)).toBe( `some${path.sep}path${path.sep}c`, ); - expect(toAbsoluteUrl(base, `${path.sep}c`)).toBe(`${path.sep}c`); + expect(toAbsoluteUrl(integrations, base, `${path.sep}c`)).toBe( + `${path.sep}c`, + ); }); it('handles urls', () => { + const integrations = ScmIntegrations.fromConfig(new ConfigReader({})); const base: LocationSpec = { type: 'url', target: 'http://a.com/b/catalog-info.yaml', }; - expect(toAbsoluteUrl(base, './c/d')).toBe('http://a.com/b/c/d'); - expect(toAbsoluteUrl(base, 'c/d')).toBe('http://a.com/b/c/d'); - expect(toAbsoluteUrl(base, 'http://b.com/z')).toBe('http://b.com/z'); + jest.spyOn(integrations, 'resolveUrl'); + + expect(toAbsoluteUrl(integrations, base, './c/d')).toBe( + 'http://a.com/b/c/d', + ); + expect(toAbsoluteUrl(integrations, base, 'c/d')).toBe( + 'http://a.com/b/c/d', + ); + expect(toAbsoluteUrl(integrations, base, 'http://b.com/z')).toBe( + 'http://b.com/z', + ); + + expect(integrations.resolveUrl).toBeCalledTimes(3); + }); + + it('handles azure urls specifically', () => { + const integrations = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + azure: [{ host: 'dev.azure.com' }], + }, + }), + ); + + expect( + toAbsoluteUrl( + integrations, + { + type: 'url', + target: + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml', + }, + './a.yaml', + ), + ).toBe( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', + ); }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts index 1c7e13f1c4..f4c8c82957 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LocationEntityProcessor.ts @@ -15,11 +15,16 @@ */ import { Entity, LocationEntity, LocationSpec } from '@backstage/catalog-model'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import path from 'path'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; -import path from 'path'; -export function toAbsoluteUrl(base: LocationSpec, target: string): string { +export function toAbsoluteUrl( + integrations: ScmIntegrationRegistry, + base: LocationSpec, + target: string, +): string { try { if (base.type === 'file') { if (target.startsWith('.')) { @@ -27,13 +32,19 @@ export function toAbsoluteUrl(base: LocationSpec, target: string): string { } return target; } - return new URL(target, base.target).toString(); + return integrations.resolveUrl({ url: target, base: base.target }); } catch (e) { return target; } } -export class LocationRefProcessor implements CatalogProcessor { +type Options = { + integrations: ScmIntegrationRegistry; +}; + +export class LocationEntityProcessor implements CatalogProcessor { + constructor(private readonly options: Options) {} + async postProcessEntity( entity: Entity, location: LocationSpec, @@ -47,7 +58,7 @@ export class LocationRefProcessor implements CatalogProcessor { emit( result.inputError( location, - `LocationRefProcessor cannot handle ${type} type location with target ${location.target} that ends with a path separator`, + `LocationEntityProcessor cannot handle ${type} type location with target ${location.target} that ends with a path separator`, ), ); } @@ -61,7 +72,11 @@ export class LocationRefProcessor implements CatalogProcessor { } for (const maybeRelativeTarget of targets) { - const target = toAbsoluteUrl(location, maybeRelativeTarget); + const target = toAbsoluteUrl( + this.options.integrations, + location, + maybeRelativeTarget, + ); emit(result.location({ type, target }, false)); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index a7b00d7065..05dd327f0e 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -23,7 +23,7 @@ export { CodeOwnersProcessor } from './CodeOwnersProcessor'; export { FileReaderProcessor } from './FileReaderProcessor'; export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor'; -export { LocationRefProcessor } from './LocationEntityProcessor'; +export { LocationEntityProcessor } from './LocationEntityProcessor'; export { MicrosoftGraphOrgReaderProcessor } from './MicrosoftGraphOrgReaderProcessor'; export { PlaceholderProcessor } from './PlaceholderProcessor'; export type { PlaceholderResolver } from './PlaceholderProcessor'; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 938a406e56..f7544ef42e 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -26,6 +26,7 @@ import { Validators, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; import lodash from 'lodash'; import { Logger } from 'winston'; import { @@ -46,8 +47,8 @@ import { HigherOrderOperation, HigherOrderOperations, LdapOrgReaderProcessor, + LocationEntityProcessor, LocationReaders, - LocationRefProcessor, MicrosoftGraphOrgReaderProcessor, PlaceholderProcessor, PlaceholderResolver, @@ -280,6 +281,7 @@ export class CatalogBuilder { private buildProcessors(): CatalogProcessor[] { const { config, logger, reader } = this.env; + const integrations = ScmIntegrations.fromConfig(config); this.checkDeprecatedReaderProcessors(); @@ -306,7 +308,7 @@ export class CatalogBuilder { MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }), new UrlReaderProcessor({ reader, logger }), new CodeOwnersProcessor({ reader, logger }), - new LocationRefProcessor(), + new LocationEntityProcessor({ integrations }), new AnnotateLocationEntityProcessor(), ); } diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 4533a541f0..c81c29fc3e 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@graphql-modules/core": "^0.7.17", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 2d75ae6af4..c6e4995c70 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-import +## 0.3.6 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/integration@0.3.1 + - @backstage/plugin-catalog@0.2.14 + ## 0.3.5 ### Patch Changes diff --git a/plugins/catalog-import/dev/index.tsx b/plugins/catalog-import/dev/index.tsx index 812a5585d4..c9b46a7ed4 100644 --- a/plugins/catalog-import/dev/index.tsx +++ b/plugins/catalog-import/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { catalogImportPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(catalogImportPlugin).render(); diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 2f9784a23e..7f3cc3d1e3 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.3.5", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,8 +32,8 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/integration": "^0.3.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/integration": "^0.3.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -49,7 +49,8 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/catalog-client": "^0.3.5", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx index 96b1bf2298..9fb170c85c 100644 --- a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx +++ b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx @@ -14,32 +14,35 @@ * limitations under the License. */ -import React, { useCallback, useState } from 'react'; -import { - Button, - CircularProgress, - Grid, - Link, - List, - ListItem, - Typography, - Divider, -} from '@material-ui/core'; -import { useGithubRepos } from '../util/useGithubRepos'; -import { ConfigSpec } from './ImportComponentPage'; +import { Entity } from '@backstage/catalog-model'; import { errorApiRef, RouteRef, StructuredMetadataTable, useApi, } from '@backstage/core'; -import { PartialEntity } from '../util/types'; +import { + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog-react'; +import { + Button, + CircularProgress, + Divider, + Grid, + Link, + List, + ListItem, + Typography, +} from '@material-ui/core'; +import React, { useCallback, useState } from 'react'; import { generatePath, resolvePath } from 'react-router'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; import { Link as RouterLink } from 'react-router-dom'; import * as YAML from 'yaml'; +import { PartialEntity } from '../util/types'; import { urlType } from '../util/urls'; +import { useGithubRepos } from '../util/useGithubRepos'; +import { ConfigSpec } from './ImportComponentPage'; const getEntityCatalogPath = ({ entity, diff --git a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx index 91a5208bee..38b1065325 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx @@ -13,18 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { RegisterComponentForm } from './ImportComponentForm'; +import { CatalogClient } from '@backstage/catalog-client'; import { ApiProvider, ApiRegistry, DiscoveryApi, errorApiRef, } from '@backstage/core'; -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; import { catalogImportApiRef, CatalogImportClient } from '../api'; -import { fireEvent, waitFor, screen } from '@testing-library/react'; +import { RegisterComponentForm } from './ImportComponentForm'; describe('', () => { let apis: ApiRegistry; diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx index c233be5a8c..b3ec050ea3 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.tsx @@ -15,6 +15,7 @@ */ import { errorApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { BackstageTheme } from '@backstage/theme'; import { Button, @@ -26,11 +27,10 @@ import { makeStyles } from '@material-ui/core/styles'; import React from 'react'; import { useForm } from 'react-hook-form'; import { useMountedState } from 'react-use'; -import { ComponentIdValidators } from '../util/validate'; -import { useGithubRepos } from '../util/useGithubRepos'; -import { ConfigSpec } from './ImportComponentPage'; -import { catalogApiRef } from '@backstage/plugin-catalog'; import { urlType } from '../util/urls'; +import { useGithubRepos } from '../util/useGithubRepos'; +import { ComponentIdValidators } from '../util/validate'; +import { ConfigSpec } from './ImportComponentPage'; const useStyles = makeStyles(theme => ({ form: { @@ -71,8 +71,8 @@ export const RegisterComponentForm = ({ const onSubmit = async (formData: Record) => { const { componentLocation: target } = formData; - async function saveCatalogFileConfig(target: string) { - const data = await catalogApi.addLocation({ target }); + async function saveCatalogFileConfig(targetString: string) { + const data = await catalogApi.addLocation({ target: targetString }); saveConfig({ type: 'file', location: data.location.target, @@ -80,12 +80,12 @@ export const RegisterComponentForm = ({ }); } - async function trySaveRepositoryConfig(target: string) { - const existingCatalog = await checkForExistingCatalogInfo(target); + async function trySaveRepositoryConfig(targetString: string) { + const existingCatalog = await checkForExistingCatalogInfo(targetString); if (existingCatalog.exists) { - const targetUrl = target.endsWith('/') - ? `${target}${existingCatalog.url}` - : `${target}/${existingCatalog.url}`; + const targetUrl = targetString.endsWith('/') + ? `${targetString}${existingCatalog.url}` + : `${targetString}/${existingCatalog.url}`; await saveCatalogFileConfig(targetUrl); } else { saveConfig({ diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx index 932f9aafa0..f6ef07e1b2 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx @@ -13,22 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { msw, renderInTestApp } from '@backstage/test-utils'; -import { ImportComponentPage } from './ImportComponentPage'; +import { CatalogClient } from '@backstage/catalog-client'; import { ApiProvider, ApiRegistry, configApiRef, errorApiRef, } from '@backstage/core'; -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; -import { catalogImportApiRef, CatalogImportClient } from '../api'; - +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { msw, renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen, waitFor } from '@testing-library/react'; - import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import React from 'react'; +import { catalogImportApiRef, CatalogImportClient } from '../api'; +import { ImportComponentPage } from './ImportComponentPage'; let codeSearchMockResponse: () => Promise<{ data: { diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx index e284e95456..adae137a50 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx @@ -46,20 +46,20 @@ function manifestGenerationAvailable(configApi: ConfigApi): boolean { function repositories(configApi: ConfigApi): string[] { const integrations = configApi.getConfig('integrations'); - const repositories = []; + const repos = []; if (integrations.has('github')) { - repositories.push('GitHub'); + repos.push('GitHub'); } if (integrations.has('bitbucket')) { - repositories.push('Bitbucket'); + repos.push('Bitbucket'); } if (integrations.has('gitlab')) { - repositories.push('GitLab'); + repos.push('GitLab'); } if (integrations.has('azure')) { - repositories.push('Azure'); + repos.push('Azure'); } - return repositories; + return repos; } export const ImportComponentPage = ({ diff --git a/plugins/catalog-import/src/index.ts b/plugins/catalog-import/src/index.ts index ff7857cacd..7b4fc17121 100644 --- a/plugins/catalog-import/src/index.ts +++ b/plugins/catalog-import/src/index.ts @@ -14,5 +14,10 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + catalogImportPlugin, + catalogImportPlugin as plugin, + CatalogImportPage, +} from './plugin'; export { Router } from './components/Router'; +export * from './api'; diff --git a/plugins/catalog-import/src/plugin.test.ts b/plugins/catalog-import/src/plugin.test.ts index 2f5964946c..7ebe194b36 100644 --- a/plugins/catalog-import/src/plugin.test.ts +++ b/plugins/catalog-import/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { catalogImportPlugin } from './plugin'; describe('catalog-import', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(catalogImportPlugin).toBeDefined(); }); }); diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts index 829be22045..42210d0ed1 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -21,6 +21,7 @@ import { discoveryApiRef, githubAuthApiRef, configApiRef, + createRoutableExtension, } from '@backstage/core'; import { catalogImportApiRef } from './api/CatalogImportApi'; import { CatalogImportClient } from './api/CatalogImportClient'; @@ -30,7 +31,7 @@ export const rootRouteRef = createRouteRef({ title: 'catalog-import', }); -export const plugin = createPlugin({ +export const catalogImportPlugin = createPlugin({ id: 'catalog-import', apis: [ createApiFactory({ @@ -44,4 +45,14 @@ export const plugin = createPlugin({ new CatalogImportClient({ discoveryApi, githubAuthApi, configApi }), }), ], + routes: { + importPage: rootRouteRef, + }, }); + +export const CatalogImportPage = catalogImportPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/catalog-react/.eslintrc.js b/plugins/catalog-react/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/catalog-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/catalog-react/README.md b/plugins/catalog-react/README.md new file mode 100644 index 0000000000..65ab45b9b2 --- /dev/null +++ b/plugins/catalog-react/README.md @@ -0,0 +1,14 @@ +# Catalog React + +WORK IN PROGRESS + +This is shared code of the frontend part of the default catalog plugin. + +It will implement the core API for handling your catalog of software, and +supplies components that can be reused by third-party plugins. + +## Links + +- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog) +- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json new file mode 100644 index 0000000000..94dc835d14 --- /dev/null +++ b/plugins/catalog-react/package.json @@ -0,0 +1,58 @@ +{ + "name": "@backstage/plugin-catalog-react", + "version": "0.0.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" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/plugin-catalog-common-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-client": "^0.3.5", + "@backstage/catalog-model": "^0.7.0", + "@backstage/core": "^0.5.0", + "@material-ui/core": "^4.11.0", + "@types/react": "^16.9", + "react": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.5.0", + "@backstage/dev-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.6", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/react-hooks": "^3.3.0", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2", + "react-test-renderer": "^16.13.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/catalog-react/src/api.ts b/plugins/catalog-react/src/api.ts new file mode 100644 index 0000000000..9379fa7fb3 --- /dev/null +++ b/plugins/catalog-react/src/api.ts @@ -0,0 +1,24 @@ +/* + * 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 { CatalogApi } from '@backstage/catalog-client'; +import { createApiRef } from '@backstage/core'; + +export const catalogApiRef = createApiRef({ + id: 'plugin.catalog.service', + description: + 'Used by the Catalog plugin to make requests to accompanying backend', +}); diff --git a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx b/plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx similarity index 94% rename from plugins/catalog/src/components/EntityProvider/EntityProvider.tsx rename to plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx index 9135b54638..45cdd56d04 100644 --- a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx +++ b/plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import React, { ReactNode } from 'react'; -import { EntityContext } from '../../hooks/useEntity'; +import { EntityContext } from '../../hooks'; type EntityProviderProps = { entity: Entity; diff --git a/plugins/catalog/src/components/EntityProvider/index.ts b/plugins/catalog-react/src/components/EntityProvider/index.ts similarity index 100% rename from plugins/catalog/src/components/EntityProvider/index.ts rename to plugins/catalog-react/src/components/EntityProvider/index.ts diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx rename to plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx similarity index 91% rename from plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx rename to plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx index 64b2bb8615..ae6f807275 100644 --- a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx @@ -31,9 +31,6 @@ type EntityRefLinkProps = { children?: React.ReactNode; }; -// TODO: This component is private for now, as it should probably belong into -// some kind of helper module for the catalog plugin to avoid a dependency on -// the catalog plugin itself. export const EntityRefLink = ({ entityRef, defaultKind, diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx rename to plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx similarity index 96% rename from plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx rename to plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx index 3c8beaec6f..fb99fad176 100644 --- a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx @@ -22,7 +22,6 @@ type EntityRefLinksProps = { defaultKind?: string; }; -// TODO: Move into a shared helper package export const EntityRefLinks = ({ entityRefs, defaultKind, diff --git a/plugins/catalog/src/components/EntityRefLink/format.test.ts b/plugins/catalog-react/src/components/EntityRefLink/format.test.ts similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/format.test.ts rename to plugins/catalog-react/src/components/EntityRefLink/format.test.ts diff --git a/plugins/catalog/src/components/EntityRefLink/format.ts b/plugins/catalog-react/src/components/EntityRefLink/format.ts similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/format.ts rename to plugins/catalog-react/src/components/EntityRefLink/format.ts diff --git a/plugins/catalog/src/components/EntityRefLink/index.ts b/plugins/catalog-react/src/components/EntityRefLink/index.ts similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/index.ts rename to plugins/catalog-react/src/components/EntityRefLink/index.ts diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts new file mode 100644 index 0000000000..fcfa8207b3 --- /dev/null +++ b/plugins/catalog-react/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './EntityRefLink'; +export * from './EntityProvider'; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts new file mode 100644 index 0000000000..77de70b8d4 --- /dev/null +++ b/plugins/catalog-react/src/hooks/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { EntityContext, useEntity, useEntityFromUrl } from './useEntity'; +export { useEntityCompoundName } from './useEntityCompoundName'; diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog-react/src/hooks/useEntity.ts similarity index 94% rename from plugins/catalog/src/hooks/useEntity.ts rename to plugins/catalog-react/src/hooks/useEntity.ts index 289c4e26aa..faeeca11ed 100644 --- a/plugins/catalog/src/hooks/useEntity.ts +++ b/plugins/catalog-react/src/hooks/useEntity.ts @@ -18,8 +18,8 @@ import { errorApiRef, useApi } from '@backstage/core'; import { createContext, useContext, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useAsync } from 'react-use'; -import { useEntityCompoundName } from '../components/useEntityCompoundName'; -import { catalogApiRef } from '../plugin'; +import { useEntityCompoundName } from './useEntityCompoundName'; +import { catalogApiRef } from '../api'; type EntityLoadingStatus = { entity?: Entity; diff --git a/plugins/catalog/src/components/useEntityCompoundName.ts b/plugins/catalog-react/src/hooks/useEntityCompoundName.ts similarity index 100% rename from plugins/catalog/src/components/useEntityCompoundName.ts rename to plugins/catalog-react/src/hooks/useEntityCompoundName.ts diff --git a/plugins/catalog-react/src/index.ts b/plugins/catalog-react/src/index.ts new file mode 100644 index 0000000000..af3eca4e0a --- /dev/null +++ b/plugins/catalog-react/src/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export type { CatalogApi } from '@backstage/catalog-client'; +export { catalogApiRef } from './api'; +export * from './components'; +export * from './hooks'; +export { + catalogRouteRef, + entityRoute, + entityRouteParams, + entityRouteRef, + rootRoute, +} from './routes'; +export * from './utils'; diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog-react/src/routes.ts similarity index 92% rename from plugins/catalog/src/routes.ts rename to plugins/catalog-react/src/routes.ts index 2983b464ac..0fced32f59 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog-react/src/routes.ts @@ -19,6 +19,7 @@ import { createRouteRef } from '@backstage/core'; const NoIcon = () => null; +// TODO(Rugvip): Move these route refs back to the catalog plugin once we're all ported to using external routes export const rootRoute = createRouteRef({ icon: NoIcon, path: '', diff --git a/plugins/catalog-react/src/setupTests.ts b/plugins/catalog-react/src/setupTests.ts new file mode 100644 index 0000000000..aea2220869 --- /dev/null +++ b/plugins/catalog-react/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/catalog/src/components/getEntityRelations.test.ts b/plugins/catalog-react/src/utils/getEntityRelations.test.ts similarity index 100% rename from plugins/catalog/src/components/getEntityRelations.test.ts rename to plugins/catalog-react/src/utils/getEntityRelations.test.ts diff --git a/plugins/catalog/src/components/getEntityRelations.ts b/plugins/catalog-react/src/utils/getEntityRelations.ts similarity index 100% rename from plugins/catalog/src/components/getEntityRelations.ts rename to plugins/catalog-react/src/utils/getEntityRelations.ts diff --git a/plugins/catalog-react/src/utils/index.ts b/plugins/catalog-react/src/utils/index.ts new file mode 100644 index 0000000000..2efb35703e --- /dev/null +++ b/plugins/catalog-react/src/utils/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { getEntityRelations } from './getEntityRelations'; +export { isOwnerOf } from './isOwnerOf'; diff --git a/plugins/catalog/src/components/isOwnerOf.test.ts b/plugins/catalog-react/src/utils/isOwnerOf.test.ts similarity index 100% rename from plugins/catalog/src/components/isOwnerOf.test.ts rename to plugins/catalog-react/src/utils/isOwnerOf.test.ts diff --git a/plugins/catalog/src/components/isOwnerOf.ts b/plugins/catalog-react/src/utils/isOwnerOf.ts similarity index 83% rename from plugins/catalog/src/components/isOwnerOf.ts rename to plugins/catalog-react/src/utils/isOwnerOf.ts index 455e06dc13..d135e4030a 100644 --- a/plugins/catalog/src/components/isOwnerOf.ts +++ b/plugins/catalog-react/src/utils/isOwnerOf.ts @@ -34,13 +34,13 @@ export function isOwnerOf(owner: Entity, owned: Entity) { const owners = getEntityRelations(owned, RELATION_OWNED_BY); - for (const owner of owners) { + for (const ownerItem of owners) { if ( possibleOwners.find( o => - owner.kind.toLowerCase() === o.kind.toLowerCase() && - owner.namespace.toLowerCase() === o.namespace.toLowerCase() && - owner.name.toLowerCase() === o.name.toLowerCase(), + ownerItem.kind.toLowerCase() === o.kind.toLowerCase() && + ownerItem.namespace.toLowerCase() === o.namespace.toLowerCase() && + ownerItem.name.toLowerCase() === o.name.toLowerCase(), ) !== undefined ) { return true; diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index efacf6fbad..3920d8696d 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-catalog +## 0.2.14 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- 0b1182346: Add `EntityRefLinks` that shows one or multiple entity ref links. + + Change the about card and catalog table to use `EntityRefLinks` due to the + nature of relations to support multiple relations per type. + +- Updated dependencies [9dd057662] + - @backstage/plugin-scaffolder@0.4.1 + ## 0.2.13 ### Patch Changes diff --git a/plugins/catalog/dev/index.tsx b/plugins/catalog/dev/index.tsx index 812a5585d4..34f23071b0 100644 --- a/plugins/catalog/dev/index.tsx +++ b/plugins/catalog/dev/index.tsx @@ -14,7 +14,31 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { + catalogPlugin, + CatalogIndexPage, + CatalogEntityPage, + EntityLayout, +} from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerPlugin(catalogPlugin) + .addPage({ + path: '/catalog', + title: 'Catalog', + element: , + }) + .addPage({ + path: '/catalog/:namespace/:kind/:name', + element: , + children: ( + + +

Overview

+
+
+ ), + }) + .render(); diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 0887930726..bf30e28571 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,7 +33,8 @@ "@backstage/catalog-client": "^0.3.5", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-scaffolder": "^0.4.0", + "@backstage/plugin-catalog-react": "^0.0.1", + "@backstage/plugin-scaffolder": "^0.4.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -51,7 +52,7 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@microsoft/microsoft-graph-types": "^1.25.0", diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index 82652b3986..75b7827111 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -19,10 +19,12 @@ import { RELATION_OWNED_BY, RELATION_PART_OF, } from '@backstage/catalog-model'; +import { + EntityRefLinks, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import { Chip, Grid, makeStyles, Typography } from '@material-ui/core'; import React from 'react'; -import { EntityRefLinks } from '../EntityRefLink'; -import { getEntityRelations } from '../getEntityRelations'; import { AboutField } from './AboutField'; const useStyles = makeStyles({ diff --git a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx index e78cedbe2d..efacfa4320 100644 --- a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx +++ b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx @@ -15,10 +15,10 @@ */ import { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CircularProgress, useTheme } from '@material-ui/core'; import React from 'react'; import { useAsync } from 'react-use'; -import { catalogApiRef } from '../../plugin'; export const AllServicesCount = () => { const theme = useTheme(); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index b92fc3d4bc..0cc2108985 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -23,11 +23,11 @@ import { identityApiRef, storageApiRef, } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; import { ButtonGroup, CatalogFilter } from './CatalogFilter'; describe('Catalog Filter', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 28fb1b08c7..42c9cf1117 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -28,11 +28,11 @@ import { ProfileInfo, storageApiRef, } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; import { CatalogPage } from './CatalogPage'; describe('CatalogPage', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index afb812ed06..1298d5e190 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -22,6 +22,7 @@ import { SupportButton, useApi, } from '@backstage/core'; +import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; import { Button, makeStyles } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; @@ -30,14 +31,12 @@ import React, { useCallback, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { useStarredEntities } from '../../hooks/useStarredEntities'; -import { catalogApiRef } from '../../plugin'; import { ButtonGroup, CatalogFilter } from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; +import { useOwnUser } from '../useOwnUser'; import CatalogLayout from './CatalogLayout'; import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; -import { useOwnUser } from '../useOwnUser'; -import { isOwnerOf } from '../isOwnerOf'; const useStyles = makeStyles(theme => ({ contentWrapper: { diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 18c61c2a04..30a19145f2 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -20,6 +20,12 @@ import { RELATION_PART_OF, } from '@backstage/catalog-model'; import { Table, TableColumn, TableProps } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import { Chip } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; import OpenInNew from '@material-ui/icons/OpenInNew'; @@ -28,19 +34,15 @@ import React from 'react'; import { findLocationForEntityMeta } from '../../data/utils'; import { useStarredEntities } from '../../hooks/useStarredEntities'; import { createEditLink } from '../createEditLink'; -import { - EntityRefLink, - EntityRefLinks, - formatEntityRefTitle, -} from '../EntityRefLink'; import { favouriteEntityIcon, favouriteEntityTooltip, } from '../FavouriteEntity/FavouriteEntity'; -import { getEntityRelations } from '../getEntityRelations'; -type EntityRow = Entity & { - row: { +type EntityRow = { + entity: Entity; + resolved: { + name: string; partOfSystemRelationTitle?: string; partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; @@ -51,47 +53,47 @@ type EntityRow = Entity & { const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'resolved.name', highlight: true, - render: entity => ( - {entity.metadata.name} + render: ({ entity }) => ( + ), }, { title: 'System', - field: 'row.partOfSystemRelationTitle', - render: entity => ( + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( ), }, { title: 'Owner', - field: 'row.ownedByRelationsTitle', - render: entity => ( + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( ), }, { title: 'Lifecycle', - field: 'spec.lifecycle', + field: 'entity.spec.lifecycle', }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', }, { title: 'Tags', - field: 'metadata.tags', + field: 'entity.metadata.tags', cellStyle: { padding: '0px 16px 0px 20px', }, - render: entity => ( + render: ({ entity }) => ( <> {entity.metadata.tags && entity.metadata.tags.map(t => ( @@ -133,9 +135,9 @@ export const CatalogTable = ({ ); } - const actions: TableProps['actions'] = [ - (rowData: Entity) => { - const location = findLocationForEntityMeta(rowData.metadata); + const actions: TableProps['actions'] = [ + ({ entity }) => { + const location = findLocationForEntityMeta(entity.metadata); return { icon: () => , tooltip: 'View', @@ -145,8 +147,8 @@ export const CatalogTable = ({ }, }; }, - (rowData: Entity) => { - const location = findLocationForEntityMeta(rowData.metadata); + ({ entity }) => { + const location = findLocationForEntityMeta(entity.metadata); return { icon: () => , tooltip: 'Edit', @@ -156,36 +158,40 @@ export const CatalogTable = ({ }, }; }, - (rowData: Entity) => { - const isStarred = isStarredEntity(rowData); + ({ entity }) => { + const isStarred = isStarredEntity(entity); return { cellStyle: { paddingLeft: '1em' }, icon: () => favouriteEntityIcon(isStarred), tooltip: favouriteEntityTooltip(isStarred), - onClick: () => toggleStarredEntity(rowData), + onClick: () => toggleStarredEntity(entity), }; }, ]; - const rows = entities.map(e => { - const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, { + const rows = entities.map(entity => { + const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { kind: 'system', }); - const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); return { - ...e, - row: { + entity, + resolved: { + name: formatEntityRefTitle(entity, { + defaultKind: 'Component', + }), ownedByRelationsTitle: ownedByRelations .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), ownedByRelations, - partOfSystemRelationTitle: - partOfSystemRelations.length > 0 - ? formatEntityRefTitle(partOfSystemRelations[0], { - defaultKind: 'system', - }) - : undefined, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), partOfSystemRelations, }, }; diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 5d1f010740..7356ccc27b 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -13,22 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { EntityLayout } from './EntityLayout'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; import { AlertApi, alertApiRef, ApiProvider, ApiRegistry, } from '@backstage/core'; -import { renderInTestApp, withLogCollector } from '@backstage/test-utils'; +import { catalogApiRef, EntityContext } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent } from '@testing-library/react'; +import React from 'react'; import { act } from 'react-dom/test-utils'; -import { Routes, Route } from 'react-router'; -import { Entity } from '@backstage/catalog-model'; -import { EntityContext } from '../../hooks/useEntity'; -import { catalogApiRef } from '../../plugin'; -import { CatalogApi } from '@backstage/catalog-client'; +import { Route, Routes } from 'react-router'; +import { EntityLayout } from './EntityLayout'; const mockEntityData = { loading: false, @@ -60,34 +59,11 @@ describe('EntityLayout', () => { , ); + expect(rendered.getByText('my-entity')).toBeInTheDocument(); expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument(); expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument(); }); - it('throws if any other component is a child of TabbedLayout', async () => { - const { error } = await withLogCollector(async () => { - await expect( - renderInTestApp( - - -
tabbed-test-content
-
-
This will cause app to throw
-
, - ), - ).rejects.toThrow(/Child of EntityLayout must be an EntityLayout.Route/); - }); - - expect(error).toEqual([ - expect.stringMatching( - /Child of EntityLayout must be an EntityLayout.Route/, - ), - expect.stringMatching( - /The above error occurred in the component/, - ), - ]); - }); - it('navigates when user clicks different tab', async () => { const rendered = await renderInTestApp( diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 09b8d02c60..c4cd4c5c71 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -14,63 +14,31 @@ * limitations under the License. */ -import React, { - Children, - Fragment, - PropsWithChildren, - ReactNode, - isValidElement, - useContext, - useState, -} from 'react'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { - attachComponentData, Content, Header, HeaderLabel, Page, Progress, + TabbedLayout, } from '@backstage/core'; +import { + EntityContext, + useEntityCompoundName, +} from '@backstage/plugin-catalog-react'; import { Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; +import { + default as React, + PropsWithChildren, + useContext, + useState, +} from 'react'; import { useNavigate } from 'react-router'; -import { EntityContext } from '../../hooks/useEntity'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; -import { useEntityCompoundName } from '../useEntityCompoundName'; -import { TabbedLayout } from './TabbedLayout'; - -type SubRoute = { - path: string; - title: string; - children: JSX.Element; -}; - -const Route: (props: SubRoute) => null = () => null; - -// This causes all mount points that are discovered within this route to use the path of the route itself -attachComponentData(Route, 'core.gatherMountPoints', true); - -export function createSubRoutesFromChildren(children: ReactNode): SubRoute[] { - return Children.toArray(children).flatMap(child => { - if (!isValidElement(child)) { - return []; - } - - if (child.type === Fragment) { - return createSubRoutesFromChildren(child.props.children); - } - - if (child.type !== Route) { - throw new Error('Child of EntityLayout must be an EntityLayout.Route'); - } - - const { path, title, children } = child.props; - return [{ path, title, children }]; - }); -} const EntityLayoutTitle = ({ entity, @@ -130,7 +98,6 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => { const { kind, namespace, name } = useEntityCompoundName(); const { entity, loading, error } = useContext(EntityContext); - const routes = createSubRoutesFromChildren(children); const { headerTitle, headerType } = headerProps( kind, namespace, @@ -172,7 +139,7 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => { {loading && } - {entity && } + {entity && {children}} {error && ( @@ -189,4 +156,4 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => { ); }; -EntityLayout.Route = Route; +EntityLayout.Route = TabbedLayout.Route; diff --git a/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx index 98b8295047..97a4263e0d 100644 --- a/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx +++ b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { + EntityContext, + useEntityFromUrl, +} from '@backstage/plugin-catalog-react'; import React, { ReactNode } from 'react'; -import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity'; export const EntityLoaderProvider = ({ children }: { children: ReactNode }) => { const { entity, loading, error } = useEntityFromUrl(); diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index eb75593ffc..1f42cc7cc1 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -19,11 +19,14 @@ import { Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { PropsWithChildren, useContext, useState } from 'react'; import { useNavigate } from 'react-router'; -import { EntityContext } from '../../hooks/useEntity'; +import { + EntityContext, + useEntityCompoundName, +} from '@backstage/plugin-catalog-react'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; -import { useEntityCompoundName } from '../useEntityCompoundName'; + import { Tabbed } from './Tabbed'; const EntityPageTitle = ({ diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 292cd37ede..20b9aaba4f 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; -import { EntityContext } from '../../hooks/useEntity'; import { Entity } from '@backstage/catalog-model'; -import { EntitySwitch } from './EntitySwitch'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { render } from '@testing-library/react'; +import React from 'react'; import { isKind } from './conditions'; +import { EntitySwitch } from './EntitySwitch'; describe('EntitySwitch', () => { it('should switch child when entity switches', () => { diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx index f36bc29cee..a3ede42288 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx @@ -14,16 +14,16 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { - ReactNode, - PropsWithChildren, Children, Fragment, - useMemo, isValidElement, + PropsWithChildren, + ReactNode, + useMemo, } from 'react'; -import { useEntity } from '../../hooks/useEntity'; -import { Entity } from '@backstage/catalog-model'; const EntitySwitchCase = (_: { if?: (entity: Entity) => boolean; @@ -35,8 +35,8 @@ type SwitchCase = { children: JSX.Element; }; -function createSwitchCasesFromChildren(children: ReactNode): SwitchCase[] { - return Children.toArray(children).flatMap(child => { +function createSwitchCasesFromChildren(childrenNode: ReactNode): SwitchCase[] { + return Children.toArray(childrenNode).flatMap(child => { if (!isValidElement(child)) { return []; } diff --git a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx index d105979600..9be1c995e8 100644 --- a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx +++ b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx @@ -23,11 +23,11 @@ import { identityApiRef, storageApiRef, } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; import { ResultsFilter } from './ResultsFilter'; describe('Results Filter', () => { diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index 6a4593fb80..3e51b74dc2 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -15,15 +15,18 @@ */ import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { Content } from '@backstage/core'; +import { + entityRoute, + rootRoute, + useEntity, +} from '@backstage/plugin-catalog-react'; import { Link, Typography } from '@material-ui/core'; import React, { ComponentType } from 'react'; import { Navigate, Route, Routes, useParams } from 'react-router'; -import { useEntity } from '../hooks/useEntity'; -import { entityRoute, rootRoute } from '../routes'; import { CatalogPage } from './CatalogPage'; +import { EntityLoaderProvider } from './EntityLoaderProvider'; import { EntityNotFound } from './EntityNotFound'; import { EntityPageLayout } from './EntityPageLayout'; -import { EntityLoaderProvider } from './EntityLoaderProvider'; const DefaultEntityPage = () => ( diff --git a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx index 2c8fcfd6e9..06ea1eee27 100644 --- a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx +++ b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx @@ -16,6 +16,10 @@ import { Entity, ORIGIN_LOCATION_ANNOTATION } from '@backstage/catalog-model'; import { alertApiRef, configApiRef, Progress, useApi } from '@backstage/core'; +import { + catalogApiRef, + formatEntityRefTitle, +} from '@backstage/plugin-catalog-react'; import { Button, Dialog, @@ -31,8 +35,6 @@ import Alert from '@material-ui/lab/Alert'; import React from 'react'; import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; -import { catalogApiRef } from '../../plugin'; -import { formatEntityRefTitle } from '../EntityRefLink'; type Props = { open: boolean; diff --git a/plugins/catalog/src/components/useOwnUser.ts b/plugins/catalog/src/components/useOwnUser.ts index 9aa4bda99c..29d8a0d11f 100644 --- a/plugins/catalog/src/components/useOwnUser.ts +++ b/plugins/catalog/src/components/useOwnUser.ts @@ -15,10 +15,10 @@ */ import { UserEntity } from '@backstage/catalog-model'; +import { identityApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; -import { identityApiRef, useApi } from '@backstage/core'; -import { catalogApiRef } from '../plugin'; /** * Get the catalog User entity (if any) that matches the logged-in user. diff --git a/plugins/catalog/src/extensions.tsx b/plugins/catalog/src/extensions.tsx deleted file mode 100644 index dcb9ba2aff..0000000000 --- a/plugins/catalog/src/extensions.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 { createRoutableExtension } from '@backstage/core'; -import { catalogRouteRef, entityRouteRef } from './routes'; -import { plugin } from './plugin'; - -export const CatalogIndexPage = plugin.provide( - createRoutableExtension({ - component: () => - import('./components/CatalogPage').then(m => m.CatalogPage), - mountPoint: catalogRouteRef, - }), -); - -export const CatalogEntityPage = plugin.provide( - createRoutableExtension({ - component: () => - import('./components/CatalogEntityPage/CatalogEntityPage').then( - m => m.CatalogEntityPage, - ), - mountPoint: entityRouteRef, - }), -); diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx index edda8eecb5..69ae9b83d6 100644 --- a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -16,9 +16,9 @@ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAsyncFn } from 'react-use'; -import { catalogApiRef } from '../plugin'; import { filterGroupsContext, FilterGroupsContext } from './context'; import { EntityFilterFn, diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx index e9af063160..685fc751d2 100644 --- a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx +++ b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx @@ -15,10 +15,10 @@ */ import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi } from '@backstage/test-utils'; import { act, renderHook } from '@testing-library/react-hooks'; import React from 'react'; -import { catalogApiRef } from '../plugin'; import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; import { FilterGroup, FilterGroupStatesReady } from './types'; import { useEntityFilterGroup } from './useEntityFilterGroup'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 2152933daf..3ef79680c4 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -14,15 +14,14 @@ * limitations under the License. */ -export * from '@backstage/catalog-client'; export { AboutCard } from './components/AboutCard'; -export { EntityPageLayout } from './components/EntityPageLayout'; export { EntityLayout } from './components/EntityLayout'; -export { EntityProvider } from './components/EntityProvider'; +export { EntityPageLayout } from './components/EntityPageLayout'; export * from './components/EntitySwitch'; export { Router } from './components/Router'; -export { useEntityCompoundName } from './components/useEntityCompoundName'; -export { EntityContext, useEntity } from './hooks/useEntity'; -export { catalogApiRef, plugin } from './plugin'; -export * from './routes'; -export * from './extensions'; +export { + catalogPlugin, + catalogPlugin as plugin, + CatalogIndexPage, + CatalogEntityPage, +} from './plugin'; diff --git a/plugins/catalog/src/plugin.test.ts b/plugins/catalog/src/plugin.test.ts index 427efc39e1..d2cf0e4bf5 100644 --- a/plugins/catalog/src/plugin.test.ts +++ b/plugins/catalog/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { catalogPlugin } from './plugin'; describe('catalog', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(catalogPlugin).toBeDefined(); }); }); diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 0c2b3b18c5..c19925775d 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -14,22 +14,20 @@ * limitations under the License. */ -import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { CatalogClient } from '@backstage/catalog-client'; import { createApiFactory, - createApiRef, createPlugin, discoveryApiRef, + createRoutableExtension, } from '@backstage/core'; -import { catalogRouteRef, entityRouteRef } from './routes'; +import { + catalogApiRef, + catalogRouteRef, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; -export const catalogApiRef = createApiRef({ - id: 'plugin.catalog.service', - description: - 'Used by the Catalog plugin to make requests to accompanying backend', -}); - -export const plugin = createPlugin({ +export const catalogPlugin = createPlugin({ id: 'catalog', apis: [ createApiFactory({ @@ -43,3 +41,21 @@ export const plugin = createPlugin({ catalogEntity: entityRouteRef, }, }); + +export const CatalogIndexPage = catalogPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/CatalogPage').then(m => m.CatalogPage), + mountPoint: catalogRouteRef, + }), +); + +export const CatalogEntityPage = catalogPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/CatalogEntityPage/CatalogEntityPage').then( + m => m.CatalogEntityPage, + ), + mountPoint: entityRouteRef, + }), +); diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index fbc6605419..f128540784 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/circleci/src/state/useBuilds.ts b/plugins/circleci/src/state/useBuilds.ts index c172a88985..7968379ee4 100644 --- a/plugins/circleci/src/state/useBuilds.ts +++ b/plugins/circleci/src/state/useBuilds.ts @@ -15,14 +15,14 @@ */ import { errorApiRef, useApi } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { BuildSummary, GitType } from 'circleci-api'; +import { getOr } from 'lodash/fp'; import { useCallback, useEffect, useState } from 'react'; import { useAsyncRetry } from 'react-use'; import { circleCIApiRef } from '../api'; import type { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; -import { useEntity } from '@backstage/plugin-catalog'; import { CIRCLECI_ANNOTATION } from '../constants'; -import { getOr } from 'lodash/fp'; const makeReadableStatus = (status: string | undefined) => { if (!status) return ''; diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 0826ac972a..3e5bbef3a9 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -32,7 +32,6 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index b666c66864..78d4c69da4 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-cost-insights +## 0.7.0 + +### Minor Changes + +- 19172f5a9: add alert hooks + +## 0.6.0 + +### Minor Changes + +- fac91bcc5: Add support for additional breakdowns of daily cost data. + This changes the type of Cost.groupedCosts returned by CostInsightsApi.getGroupDailyCost. + +### Patch Changes + +- 8b7ef9f8b: Allow expand functionality to top panel product chart tooltip. + ## 0.5.7 ### Patch Changes diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index bd573d36de..022e778150 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.5.7", + "version": "0.7.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -55,7 +55,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx new file mode 100644 index 0000000000..31a4d7e0e9 --- /dev/null +++ b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx @@ -0,0 +1,159 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import pluralize from 'pluralize'; +import { MigrationAlertCard } from '../components/MigrationAlertCard'; +import { CostInsightsApi } from '../api'; +import { + Alert, + AlertForm, + AlertOptions, + AlertStatus, + AlertSnoozeFormData, + ChangeStatistic, + Entity, +} from '../types'; +import { MigrationDismissForm, MigrationDismissFormData } from '../forms'; + +export interface MigrationData { + startDate: string; + endDate: string; + change: ChangeStatistic; + services: Array; +} + +export interface MigrationAlert extends Alert { + api: CostInsightsApi; + data: MigrationData; +} + +/** + * This is an example of an Alert implementation using optional event hooks. + * + * Event hooks can be used to enable users to dismiss, snooze, or accept an action item + * - or any combination thereof. Defining a hook will generate default UI - button, dialog and + * form. Cost Insights does not preserve client side alert state - each hook is expected to return a new set of alerts. + * + * Snoozed, accepted, etc. alerts should define a corresponding status property. Alerts will be aggregated + * by status in a collapsed view below Alert Insights section and a badge will appear in Action Items + * showing the total alerts of that status. + * + * Customizing Alerts + * Default forms can be overridden in two ways - by setting a form property to null or defining a custom component. + * + * If a form property is set to null, the Dialog will not render a form. This can be useful in scenarios + * where data isn't needed from the user such as when a user accepts an action item's recommendation. + * + * If a form property is set to a React component, the Dialog will render the form component in place of the default form. + * Form components must return valid form elements, and accept a ref and onSubmit event handler. + * Custom forms must implement the corresponding event hook. See /forms for example implementations. + */ + +export class KubernetesMigrationAlert implements MigrationAlert { + api: CostInsightsApi; + data: MigrationData; + + subtitle = + 'Services running on Kubernetes are estimated to save 50% or more compared to Compute Engine.'; + + // Dialog will not render a form if form property set to null. + AcceptForm = null; + // Overrides default Dismiss form with a custom form component. + DismissForm: AlertForm< + MigrationAlert, + MigrationDismissFormData + > = MigrationDismissForm; + + constructor(api: CostInsightsApi, data: MigrationData) { + this.api = api; + this.data = data; + } + + get title() { + return `Consider migrating ${pluralize( + 'service', + this.data.services.length, + true, + )} to Kubernetes.`; + } + + get element() { + const subheader = `${pluralize( + 'Service', + this.data.services.length, + true, + )}, sorted by cost`; + return ( + + ); + } + + /* Fires when the onSubmit event is raised on a Dismiss form. Displays custom dismiss form. */ + async onDismissed( + options: AlertOptions, + ): Promise { + const alerts = await this.api.getAlerts(options.group); + return new Promise(resolve => + setTimeout(resolve, 750, [ + ...alerts.filter(a => a.title !== this.title), + { + title: this.title, + subtitle: this.subtitle, + status: AlertStatus.Dismissed, + }, + ]), + ); + } + + /* Fires when the onSubmit event is raised on a Snooze form. Displays default snooze form. */ + async onSnoozed( + options: AlertOptions, + ): Promise { + const alerts = await this.api.getAlerts(options.group); + return new Promise(resolve => + setTimeout(resolve, 750, [ + ...alerts.filter(a => a.title !== this.title), + { + title: this.title, + subtitle: this.subtitle, + status: AlertStatus.Snoozed, + }, + ]), + ); + } + + /* Fires when the Accept button is clicked. Dialog does not render a form. See KubernetesMigrationAlert.AcceptForm */ + async onAccepted(options: AlertOptions): Promise { + const alerts = await this.api.getAlerts(options.group); + return new Promise(resolve => + setTimeout(resolve, 750, [ + ...alerts.filter(a => a.title !== this.title), + { + title: this.title, + subtitle: this.subtitle, + status: AlertStatus.Accepted, + }, + ]), + ); + } +} diff --git a/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx new file mode 100644 index 0000000000..67eb1b5071 --- /dev/null +++ b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard'; +import { Alert, ProjectGrowthData } from '../types'; + +/** + * The alert below is an example of an Alert implementation; the CostInsightsApi permits returning + * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi + * fetches alert data from the backend, then creates Alert classes with the data. + */ + +export class ProjectGrowthAlert implements Alert { + data: ProjectGrowthData; + + url = '/cost-insights/investigating-growth'; + subtitle = + 'Cost growth outpacing business growth is unsustainable long-term.'; + + constructor(data: ProjectGrowthData) { + this.data = data; + } + + get title() { + return `Investigate cost growth in project ${this.data.project}`; + } + + get element() { + return ; + } +} diff --git a/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx new file mode 100644 index 0000000000..e889e0e3d4 --- /dev/null +++ b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; +import { Alert, AlertStatus, UnlabeledDataflowData } from '../types'; + +/** + * The alert below is an example of an Alert implementation; the CostInsightsApi permits returning + * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi + * fetches alert data from the backend, then creates Alert classes with the data. + */ + +export class UnlabeledDataflowAlert implements Alert { + data: UnlabeledDataflowData; + status?: AlertStatus; + + url = '/cost-insights/labeling-jobs'; + title = 'Add labels to workflows'; + subtitle = + 'Labels show in billing data, enabling cost insights for each workflow.'; + + constructor(data: UnlabeledDataflowData) { + this.data = data; + } + + get element() { + return ; + } +} diff --git a/plugins/cost-insights/src/alerts/index.ts b/plugins/cost-insights/src/alerts/index.ts new file mode 100644 index 0000000000..b55c012a60 --- /dev/null +++ b/plugins/cost-insights/src/alerts/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ProjectGrowthAlert } from './ProjectGrowthAlert'; +export { UnlabeledDataflowAlert } from './UnlabeledDataflowAlert'; +export { KubernetesMigrationAlert } from './KubernetesMigrationAlert'; +export type { MigrationAlert } from './KubernetesMigrationAlert'; +export type { MigrationData } from './KubernetesMigrationAlert'; diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index fa9a1d23f5..2e70170fc1 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -31,7 +31,8 @@ import { import { ProjectGrowthAlert, UnlabeledDataflowAlert, -} from '../src/utils/alerts'; + KubernetesMigrationAlert, +} from '../src/alerts'; import { aggregationFor, changeOf, @@ -177,9 +178,38 @@ export class ExampleCostInsightsClient implements CostInsightsApi { ], }; + const today = dayjs(); const alerts: Alert[] = await this.request({ group }, [ new ProjectGrowthAlert(projectGrowthData), new UnlabeledDataflowAlert(unlabeledDataflowData), + new KubernetesMigrationAlert(this, { + startDate: today.subtract(30, 'day').format(DEFAULT_DATE_FORMAT), + endDate: today.format(DEFAULT_DATE_FORMAT), + change: { + ratio: 0, + amount: 0, + }, + services: [ + { + id: 'service-a', + aggregation: [20_000, 10_000], + change: { + ratio: -0.5, + amount: -10_000, + }, + entities: {}, + }, + { + id: 'service-b', + aggregation: [30_000, 15_000], + change: { + ratio: -0.5, + amount: -15_000, + }, + entities: {}, + }, + ], + }), ]); return alerts; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx similarity index 87% rename from plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx rename to plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx index d896bf0d47..b72f050e5b 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx @@ -16,9 +16,9 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; -import { AlertActionCard } from './AlertActionCard'; +import { ActionItemCard } from './ActionItemCard'; import { MockScrollProvider } from '../../utils/tests'; -import { ProjectGrowthAlert } from '../../utils/alerts'; +import { ProjectGrowthAlert } from '../../alerts'; import { ProjectGrowthData } from '../../types'; const data: ProjectGrowthData = { @@ -31,11 +31,11 @@ const data: ProjectGrowthData = { }; const alert = new ProjectGrowthAlert(data); -describe('', () => { +describe('', () => { it('Renders an alert', async () => { const rendered = await renderInTestApp( - , + 1} /> , ); diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx new file mode 100644 index 0000000000..3c95fdaa2f --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { MouseEventHandler } from 'react'; +import classnames from 'classnames'; +import { Card, CardHeader } from '@material-ui/core'; +import { useScroll } from '../../hooks'; +import { Alert } from '../../types'; +import { useActionItemCardStyles as useStyles } from '../../utils/styles'; + +type ActionItemCardProps = { + alert: Alert; + number?: number; + avatar?: JSX.Element; + disableScroll?: boolean; +}; + +export const ActionItemCard = ({ + alert, + avatar, + number, + disableScroll = false, +}: ActionItemCardProps) => { + const classes = useStyles(); + const rootClasses = classnames(classes.root, { + [classes.activeRoot]: !disableScroll, + }); + const [, setScroll] = useScroll(); + + const onActionItemClick: MouseEventHandler = () => { + if (!disableScroll && number) { + setScroll(`alert-${number}`); + } + }; + + return ( + + + + ); +}; diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx new file mode 100644 index 0000000000..677ba99c6a --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ActionItems } from './ActionItems'; +import { MockScrollProvider } from '../../utils/tests'; + +function renderInContext(children: JSX.Element) { + return render({children}); +} + +describe('', () => { + it('should not display status buttons if there are no active alerts', () => { + const { queryByRole } = renderInContext( + , + ); + expect(queryByRole('button', { name: 'snoozed' })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: 'accepted' })).not.toBeInTheDocument(); + expect( + queryByRole('button', { name: 'dismissed' }), + ).not.toBeInTheDocument(); + }); + + it('should display status buttons with correct badge number', () => { + const { getByText, getByRole, getAllByText } = renderInContext( + , + ); + expect(getAllByText('1')).toHaveLength(2); // should be a badge of 1 and action item number of 1 + expect(getByText('2')).toBeInTheDocument(); + expect(getByText('3')).toBeInTheDocument(); + expect(getByRole('button', { name: 'snoozed' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'accepted' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'dismissed' })).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx new file mode 100644 index 0000000000..4fbe3470b1 --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { Fragment, MouseEventHandler } from 'react'; +import { + Avatar, + Badge, + Box, + IconButtonProps, + IconButton, + Paper, + Divider, + Tooltip, +} from '@material-ui/core'; +import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; +import { default as AcceptIcon } from '@material-ui/icons/Check'; +import { default as DismissIcon } from '@material-ui/icons/Delete'; +import { ActionItemCard } from './ActionItemCard'; +import { Alert, AlertStatus } from '../../types'; +import { useScroll, ScrollType } from '../../hooks'; +import { useActionItemCardStyles as useStyles } from '../../utils/styles'; + +type AlertStatusButtonProps = { + title: string; + amount: number; + icon: JSX.Element; + onClick: MouseEventHandler; +} & IconButtonProps; + +const AlertStatusButton = ({ + title, + amount, + icon, + onClick, + ...buttonProps +}: AlertStatusButtonProps) => ( + + + {icon} + + +); + +type ActionItemsProps = { + active: Alert[]; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; +}; + +export const ActionItems = ({ + active, + snoozed, + accepted, + dismissed, +}: ActionItemsProps) => { + const classes = useStyles(); + const [, setScroll] = useScroll(); + + const isSnoozedButtonDisplayed = !!snoozed.length; + const isAcceptedButtonDisplayed = !!accepted.length; + const isDismissedButtonDisplayed = !!dismissed.length; + const isStatusButtonGroupDisplayed = !!active.length; + + const onStatusButtonClick: MouseEventHandler = () => + setScroll(ScrollType.AlertSummary); + + return ( + <> + + {active.map((alert, index) => ( + + {index + 1}} + /> + {index < active.length - 1 && } + + ))} + + {isStatusButtonGroupDisplayed && ( + + {isAcceptedButtonDisplayed && ( + } + amount={accepted.length} + onClick={onStatusButtonClick} + /> + )} + {isSnoozedButtonDisplayed && ( + } + onClick={onStatusButtonClick} + /> + )} + {isDismissedButtonDisplayed && ( + } + amount={dismissed.length} + onClick={onStatusButtonClick} + /> + )} + + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/ActionItems/index.ts b/plugins/cost-insights/src/components/ActionItems/index.ts new file mode 100644 index 0000000000..b63e34b0af --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ActionItems } from './ActionItems'; +export { ActionItemCard } from './ActionItemCard'; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx deleted file mode 100644 index 78feade952..0000000000 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { Avatar, Card, CardHeader } from '@material-ui/core'; -import { useScroll } from '../../hooks'; -import { Alert } from '../../types'; -import { - useAlertActionCardHeader as useHeaderStyles, - useAlertActionCardStyles as useStyles, -} from '../../utils/styles'; - -type AlertActionCardProps = { - alert: Alert; - number: number; -}; - -export const AlertActionCard = ({ alert, number }: AlertActionCardProps) => { - const { scrollIntoView } = useScroll(`alert-${number}`); - const headerClasses = useHeaderStyles(); - const classes = useStyles(); - - return ( - - {number}} - title={alert.title} - subheader={alert.subtitle} - /> - - ); -}; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx deleted file mode 100644 index aaadbed09d..0000000000 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React, { Fragment } from 'react'; -import { Paper, Divider } from '@material-ui/core'; -import { AlertActionCard } from './AlertActionCard'; -import { Alert } from '../../types'; - -type AlertActionCardList = { - alerts: Array; -}; - -export const AlertActionCardList = ({ alerts }: AlertActionCardList) => ( - - {alerts.map((alert, index) => ( - - - {index < alerts.length - 1 && } - - ))} - -); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx new file mode 100644 index 0000000000..7061f0736b --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx @@ -0,0 +1,174 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { capitalize } from '@material-ui/core'; +import { AlertDialog } from './AlertDialog'; +import { render } from '@testing-library/react'; +import { Alert, AlertFormProps, AlertStatus } from '../../types'; + +type MockFormDataProps = AlertFormProps; + +function createForm(title: string) { + return React.forwardRef((props, ref) => ( +
+ You. {title}. Me. + + )); +} + +const snoozableAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onSnoozed: jest.fn(), +}; + +const dimissableAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), +}; + +const acceptableAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), +}; + +const customSnoozeAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: createForm('Snooze'), +}; + +const customDismissAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: createForm('Dismiss'), +}; + +const customAcceptAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onAccepted: jest.fn(), + AcceptForm: createForm('Accept'), +}; + +const nullAcceptAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onAccepted: jest.fn(), + AcceptForm: null, +}; + +const nullDismissAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onDismissed: jest.fn(), + DismissForm: null, +}; + +const nullSnoozeAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onSnoozed: jest.fn(), + SnoozeForm: null, +}; + +describe('', () => { + describe.each` + alert | status | action | text + ${acceptableAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${dimissableAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${snoozableAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Default forms', ({ alert, status, action: [action, actioned], text }) => { + it(`Displays a default ${action} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(text)).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); + + describe.each` + alert | status | action | text + ${customAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${customDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${customSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Custom forms', ({ alert, status, action: [action, actioned] }) => { + it(`Displays a custom ${capitalize(action)} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(`You. ${capitalize(action)}. Me.`)).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); + + describe.each` + alert | status | action | text + ${nullAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${nullDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${nullSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Null forms', ({ alert, status, action: [action, actioned], text }) => { + it(`Does NOT display a ${capitalize(action)} form`, () => { + const { getByText, getByRole, queryByText } = render( + , + ); + expect(queryByText(text)).not.toBeInTheDocument(); + expect(getByRole('button', { name: action })).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx new file mode 100644 index 0000000000..ab6f9fb9f0 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx @@ -0,0 +1,170 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { + capitalize, + Box, + Button, + Divider, + Dialog, + DialogActions, + IconButton, + DialogContent, + Typography, +} from '@material-ui/core'; +import { default as CloseIcon } from '@material-ui/icons/Close'; +import { useAlertDialogStyles as useStyles } from '../../utils/styles'; +import { Alert, AlertStatus, Maybe } from '../../types'; +import { choose, formOf } from '../../utils/alerts'; + +const DEFAULT_FORM_ID = 'alert-form'; + +type AlertDialogProps = { + open: boolean; + group: string; + alert: Maybe; + status: Maybe; + onClose: () => void; + onSubmit: (data: any) => void; +}; + +export const AlertDialog = ({ + open, + group, + alert, + status, + onClose, + onSubmit, +}: AlertDialogProps) => { + const classes = useStyles(); + const [isSubmitDisabled, setSubmitDisabled] = useState(true); + const formRef = useRef>(null); + + useEffect(() => { + setSubmitDisabled(open); + }, [open]); + + function disableSubmit(isDisabled: boolean) { + setSubmitDisabled(isDisabled); + } + + function onDialogClose() { + onClose(); + setSubmitDisabled(true); + } + + const [action, actioned] = choose( + status, + [ + ['snooze', 'snoozed'], + ['accept', 'accepted'], + ['dismiss', 'dismissed'], + ], + ['', ''], + ); + + const TransitionProps = { + mountOnEnter: true, + unmountOnExit: true, + onEntered() { + if (formRef.current) { + formRef.current.id = DEFAULT_FORM_ID; + } + }, + }; + + const Form = formOf(alert, status); + + return ( + + + + + + + + + + {capitalize(action)} this action item? + + + + This action item will be {actioned} for all of {group}. + + + + + + {alert?.title} + + {alert?.subtitle} + + {Form && ( +
+ )} +
+ + + {Form ? ( + + ) : ( + + )} + +
+ ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx new file mode 100644 index 0000000000..98f75b1d41 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { AlertInsights } from './AlertInsights'; +import { MockScrollProvider, MockLoadingProvider } from '../../utils/tests'; + +function renderInContext(children: JSX.Element) { + return render( + + {children} + , + ); +} + +describe('', () => { + it('should display the correct header if there are active action items', () => { + const { getByText, queryByText } = renderInContext( + , + ); + expect( + getByText( + 'This section outlines suggested action items your team can address to improve cloud costs.', + ), + ).toBeInTheDocument(); + expect(queryByText('Hidden Action Item')).not.toBeInTheDocument(); + }); + + it('should display alert summary if there are hidden action items', async () => { + const { getByText, getByRole } = renderInContext( + , + ); + + expect( + getByText( + "All of your team's action items are hidden. Maybe it's time to give them another look?", + ), + ).toBeInTheDocument(); + expect(getByText('Hidden Action Item')).toBeInTheDocument(); + + fireEvent.click(getByRole('button', { name: 'expand' })); + await waitFor(() => getByRole('img', { name: 'snoozed' })); + + expect(getByText('Vol. 4')).toBeInTheDocument(); + expect(getByText('Sabotage')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx index 4606906f04..7360c99cf7 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -14,31 +14,214 @@ * limitations under the License. */ -import React from 'react'; -import { Grid } from '@material-ui/core'; -import { AlertInsightsSection } from './AlertInsightsSection'; +import React, { useEffect, useState } from 'react'; +import pluralize from 'pluralize'; +import { Box, Grid, Snackbar } from '@material-ui/core'; +import { default as MuiAlert } from '@material-ui/lab/Alert'; +import { AlertDialog } from './AlertDialog'; +import { AlertStatusSummary } from './AlertStatusSummary'; +import { AlertStatusSummaryButton } from './AlertStatusSummaryButton'; import { AlertInsightsHeader } from './AlertInsightsHeader'; -import { Alert } from '../../types'; +import { AlertInsightsSection } from './AlertInsightsSection'; +import { + useScroll, + useLoading, + ScrollType, + MapLoadingToProps, +} from '../../hooks'; +import { DefaultLoadingAction } from '../../utils/loading'; +import { Alert, AlertOptions, AlertStatus, Maybe } from '../../types'; +import { + isStatusSnoozed, + isStatusAccepted, + isStatusDismissed, + sumOfAllAlerts, +} from '../../utils/alerts'; +import { ScrollAnchor } from '../../utils/scroll'; -const title = "Your team's action items"; -const subtitle = - 'This section outlines suggested action items your team can address to improve cloud costs.'; +type MapLoadingtoAlerts = (isLoading: boolean) => void; + +const mapLoadingToAlerts: MapLoadingToProps = ({ + dispatch, +}) => (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsAlerts]: isLoading }); type AlertInsightsProps = { - alerts: Array; + group: string; + active: Alert[]; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; + onChange: (alerts: Alert[]) => void; }; -export const AlertInsights = ({ alerts }: AlertInsightsProps) => ( - - - - - - {alerts.map((alert, index) => ( - - +export const AlertInsights = ({ + group, + active, + snoozed, + accepted, + dismissed, + onChange, +}: AlertInsightsProps) => { + const [scroll] = useScroll(); + const [alert, setAlert] = useState>(null); + const dispatchLoadingAlerts = useLoading(mapLoadingToAlerts); + const [status, setStatus] = useState>(null); + // Allow users to pass null values for data. + const [data, setData] = useState>(undefined); + const [error, setError] = useState>(null); + const [isDialogOpen, setDialogOpen] = useState(false); + const [isSummaryOpen, setSummaryOpen] = useState(false); + const [isSnackbarOpen, setSnackbarOpen] = useState(false); + + useEffect(() => { + async function callAlertHook( + options: AlertOptions, + callback: (options: AlertOptions) => Promise, + ) { + setAlert(null); + setStatus(null); + setData(undefined); + setDialogOpen(false); + dispatchLoadingAlerts(true); + try { + const alerts: Alert[] = await callback(options); + onChange(alerts); + } catch (e) { + setError(e); + } finally { + dispatchLoadingAlerts(false); + } + } + + const options: AlertOptions = { data, group }; + const onSnoozed = alert?.onSnoozed?.bind(alert); + const onAccepted = alert?.onAccepted?.bind(alert); + const onDismissed = alert?.onDismissed?.bind(alert); + + if (data !== undefined) { + if (isStatusSnoozed(status) && onSnoozed) { + callAlertHook(options, onSnoozed); + } else if (isStatusAccepted(status) && onAccepted) { + callAlertHook(options, onAccepted); + } else if (isStatusDismissed(status) && onDismissed) { + callAlertHook(options, onDismissed); + } + } + }, [group, data, alert, status, onChange, dispatchLoadingAlerts]); + + useEffect(() => { + if (scroll === ScrollType.AlertSummary) { + setSummaryOpen(true); + } + }, [scroll]); + + useEffect(() => { + setDialogOpen(!!status); + }, [status]); + + useEffect(() => { + setSnackbarOpen(!!error); + }, [error]); + + function onSnooze(alertToSnooze: Alert) { + setAlert(alertToSnooze); + setStatus(AlertStatus.Snoozed); + } + + function onAccept(alertToAccept: Alert) { + setAlert(alertToAccept); + setStatus(AlertStatus.Accepted); + } + + function onDismiss(alertToDismiss: Alert) { + setAlert(alertToDismiss); + setStatus(AlertStatus.Dismissed); + } + + function onSnackbarClose() { + setError(null); + } + + function onDialogClose() { + setAlert(null); + setStatus(null); + } + + function onDialogFormSubmit(formData: any) { + setData(formData); + } + + function onSummaryButtonClick() { + setSummaryOpen(prevOpen => !prevOpen); + } + + const total = [accepted, snoozed, dismissed].reduce(sumOfAllAlerts, 0); + + const isAlertStatusSummaryDisplayed = !!total; + const isAlertInsightSectionDisplayed = !!active.length; + + return ( + + + + + {isAlertInsightSectionDisplayed && ( + + {active.map((activeAlert, index) => ( + + + + ))} - ))} + )} + {isAlertStatusSummaryDisplayed && ( + + + + + {pluralize('Hidden Action Item', total)} + + + + + )} + + + + {error?.message} + + - -); + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx index b58f6973ae..86a4334d67 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Box, Typography } from '@material-ui/core'; import { useCostInsightsStyles as useStyles } from '../../utils/styles'; -import { useScroll } from '../../hooks'; +import { ScrollAnchor } from '../../utils/scroll'; import { DefaultNavigation } from '../../utils/navigation'; type AlertInsightsHeaderProps = { @@ -30,10 +30,10 @@ export const AlertInsightsHeader = ({ subtitle, }: AlertInsightsHeaderProps) => { const classes = useStyles(); - const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader); + return ( - + {title}{' '} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx index 54d350298c..937e099356 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -17,38 +17,115 @@ import React from 'react'; import { AlertInsightsSection } from './AlertInsightsSection'; import { render } from '@testing-library/react'; import { Alert } from '../../types'; -import { MockScrollProvider } from '../..'; +import { MockScrollProvider } from '../../utils/tests'; const mockAlert: Alert = { - element:
, subtitle: 'Wherefore was I to this keen mockery born? When at your hands did I deserve this scorn?', title: 'Mock alert', url: '/cost-insights/test', }; +function renderInContext(children: JSX.Element) { + return render({children}); +} + describe('', () => { it('Renders alert without exploding', () => { - const { getByText } = render( - - - , + const { getByText, queryByText } = renderInContext( + , ); expect(getByText(mockAlert.title)).toBeInTheDocument(); expect(getByText(mockAlert.subtitle)).toBeInTheDocument(); expect(getByText('View Instructions')).toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + expect(queryByText('Accept')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); }); it('Hides instructions button if url is not provided', () => { - const alert = { + const alert: Alert = { ...mockAlert, url: undefined, }; - const { queryByText } = render( - - - , + const { queryByText } = renderInContext( + , ); expect(queryByText('View Instructions')).not.toBeInTheDocument(); }); + + it('Displays a snooze button if a hook is provided', () => { + const alert: Alert = { + ...mockAlert, + onSnoozed: jest.fn(), + }; + + const { queryByText, getByText } = renderInContext( + , + ); + + expect(getByText('Snooze')).toBeInTheDocument(); + expect(queryByText('Accept')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); + }); + + it('Displays a dismiss button if a hook is provided', () => { + const alert: Alert = { + ...mockAlert, + onDismissed: jest.fn(), + }; + + const { queryByText, getByText } = renderInContext( + , + ); + + expect(getByText('Dismiss')).toBeInTheDocument(); + expect(queryByText('Accept')).not.toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + }); + + it('Displays an accept button if a hook is provided', () => { + const alert: Alert = { + ...mockAlert, + onAccepted: jest.fn(), + }; + + const { queryByText, getByText } = renderInContext( + , + ); + + expect(getByText('Accept')).toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); + }); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx index 7e371c8001..d52687ae19 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -15,30 +15,84 @@ */ import React from 'react'; import { Box, Button } from '@material-ui/core'; +import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; +import { default as AcceptIcon } from '@material-ui/icons/Check'; +import { default as DismissIcon } from '@material-ui/icons/Delete'; import { AlertInsightsSectionHeader } from './AlertInsightsSectionHeader'; import { Alert } from '../../types'; +import { + isSnoozeEnabled, + isAcceptEnabled, + isDismissEnabled, +} from '../../utils/alerts'; type AlertInsightsSectionProps = { alert: Alert; number: number; + onSnooze: (alert: Alert) => void; + onAccept: (alert: Alert) => void; + onDismiss: (alert: Alert) => void; }; export const AlertInsightsSection = ({ alert, number, + onSnooze, + onAccept, + onDismiss, }: AlertInsightsSectionProps) => { + const isSnoozeButtonDisplayed = isSnoozeEnabled(alert); + const isAcceptButtonDisplayed = isAcceptEnabled(alert); + const isDismissButtonDisplayed = isDismissEnabled(alert); + const isButtonGroupDisplayed = + isSnoozeButtonDisplayed || + isAcceptButtonDisplayed || + isDismissButtonDisplayed; + return ( - - - {alert.url && ( - - + + + {isButtonGroupDisplayed && ( + + {isAcceptButtonDisplayed && ( + + + + )} + {isSnoozeButtonDisplayed && ( + + + + )} + {isDismissButtonDisplayed && ( + + )} )} {alert.element} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx index 3613e3adc8..30b8985934 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx @@ -15,34 +15,46 @@ */ import React from 'react'; -import { Avatar, Box, Grid, Typography } from '@material-ui/core'; +import { Avatar, Box, Button, Grid, Typography } from '@material-ui/core'; import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles'; -import { useScroll } from '../../hooks'; +import { ScrollAnchor } from '../../utils/scroll'; +import { Alert } from '../../types'; type AlertInsightsSectionHeaderProps = { + alert: Alert; number: number; - title: string; - subtitle: string; }; export const AlertInsightsSectionHeader = ({ + alert, number, - title, - subtitle, }: AlertInsightsSectionHeaderProps) => { - const { ScrollAnchor } = useScroll(`alert-${number}`); const classes = useStyles(); + + const isViewInstructionsButtonDisplayed = !!alert.url; + return ( - - + + - {number} - - - {title} - {subtitle} + + + {number} + + + {alert.title} + {alert.subtitle} + + + {isViewInstructionsButtonDisplayed && ( + + + + )} ); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx new file mode 100644 index 0000000000..830db19fcd --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { AlertStatusSummary } from './AlertStatusSummary'; +import { render } from '@testing-library/react'; +import { Alert, AlertStatus } from '../../types'; +import { MockScrollProvider } from '../../utils/tests'; + +const mockSnoozed: Alert = { + title: 'snoozed-title', + subtitle: 'snoozed-subtitle', + status: AlertStatus.Snoozed, +}; + +const mockAccepted: Alert = { + title: 'accepted-title', + subtitle: 'accepted-subtitle', + status: AlertStatus.Accepted, +}; + +const mockDismissed: Alert = { + title: 'dismissed-title', + subtitle: 'dismissed-subtitle', + status: AlertStatus.Dismissed, +}; + +describe('', () => { + it('should display alerts', () => { + const { getByText, getByRole } = render( + + + , + ); + [mockSnoozed, mockAccepted, mockDismissed].forEach(a => { + expect(getByText(a.title)).toBeInTheDocument(); + expect(getByText(a.subtitle)).toBeInTheDocument(); + expect(getByRole('img', { name: a.status })).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx new file mode 100644 index 0000000000..caee99dc5e --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Fragment } from 'react'; +import { Avatar, Box, Collapse, Divider, Tooltip } from '@material-ui/core'; +import { default as AcceptIcon } from '@material-ui/icons/Check'; +import { default as DismissIcon } from '@material-ui/icons/Delete'; +import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; +import { ActionItemCard } from '../ActionItems'; +import { Alert, AlertStatus } from '../../types'; +import { useActionItemCardStyles as useStyles } from '../../utils/styles'; + +type AlertGroupProps = { + alerts: Alert[]; + status: AlertStatus; + title: string; + icon: JSX.Element; +}; + +const AlertGroup = ({ alerts, status, title, icon }: AlertGroupProps) => { + const classes = useStyles(); + return ( + + {alerts.map((alert, index) => ( + + + {icon} + + } + /> + {index < alerts.length - 1 && } + + ))} + + ); +}; + +type AlertStatusSummaryProps = { + open: boolean; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; +}; + +export const AlertStatusSummary = ({ + open, + snoozed, + accepted, + dismissed, +}: AlertStatusSummaryProps) => { + const isSnoozedListDisplayed = !!snoozed.length; + const isAcceptedListDisplayed = !!accepted.length; + const isDismissedListDisplayed = !!dismissed.length; + + return ( + + {isAcceptedListDisplayed && ( + + } + /> + )} + {isSnoozedListDisplayed && ( + + } + /> + )} + {isDismissedListDisplayed && ( + + } + /> + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx new file mode 100644 index 0000000000..ed8c7fabbb --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, MouseEventHandler, PropsWithChildren } from 'react'; +import classnames from 'classnames'; +import { Button } from '@material-ui/core'; +import { default as ExpandMoreIcon } from '@material-ui/icons/ExpandMore'; +import { useAlertStatusSummaryButtonStyles as useStyles } from '../../utils/styles'; + +type AlertStatusSummaryButtonProps = { + onClick: MouseEventHandler; +}; + +export const AlertStatusSummaryButton = ({ + children, + onClick, +}: PropsWithChildren) => { + const classes = useStyles(); + const [clicked, setClicked] = useState(false); + const iconClassName = classnames(classes.icon, { + [classes.clicked]: clicked, + }); + + const handleOnClick: MouseEventHandler = e => { + setClicked(prevClicked => !prevClicked); + onClick(e); + }; + + return ( + + ); +}; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx index c4402893f3..1963b45f48 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -18,7 +18,7 @@ import React, { PropsWithChildren } from 'react'; import { Box, Typography } from '@material-ui/core'; import { useBarChartLabelStyles } from '../../utils/styles'; -type BarChartLabel = { +type BarChartLabelProps = { x: number; y: number; height: number; @@ -33,7 +33,7 @@ export const BarChartLabel = ({ width, details, children, -}: PropsWithChildren) => { +}: PropsWithChildren) => { const classes = useBarChartLabelStyles(); const translateX = width * -0.5; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx index 11a295965e..8157e05444 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { ButtonBase } from '@material-ui/core'; import { useBarChartStepperStyles as useStyles } from '../../utils/styles'; -export type BarChartSteps = { +export type BarChartStepsProps = { steps: number; activeStep: number; onClick: (index: number) => void; @@ -28,7 +28,7 @@ export const BarChartSteps = ({ steps, activeStep, onClick, -}: BarChartSteps) => { +}: BarChartStepsProps) => { const classes = useStyles(); const handleOnClick = (index: number) => ( event: React.MouseEvent, diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx index 5a25216909..1e2bd8bb6b 100644 --- a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx @@ -41,13 +41,13 @@ type CostInsightsNavigationProps = { const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => { const classes = useStyles(); - const { scrollIntoView } = useScroll(navigation); + const [, setScroll] = useScroll(); return ( setScroll(navigation)} > {icon} { const classes = useSubtleTypographyStyles(); @@ -54,16 +67,24 @@ export const CostInsightsPage = () => { const config = useConfig(); const groups = useGroups(); const lastCompleteBillingDate = useLastCompleteBillingDate(); + const [alerts, setAlerts] = useState([]); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); const [products, setProducts] = useState>(null); const [dailyCost, setDailyCost] = useState>(null); const [metricData, setMetricData] = useState>(null); - const [alerts, setAlerts] = useState>(null); const [error, setError] = useState>(null); const { pageFilters, setPageFilters } = useFilters(p => p); + const active = useMemo(() => alerts.filter(isAlertActive), [alerts]); + const snoozed = useMemo(() => alerts.filter(isAlertSnoozed), [alerts]); + const accepted = useMemo(() => alerts.filter(isAlertAccepted), [alerts]); + const dismissed = useMemo(() => alerts.filter(isAlertDismissed), [alerts]); + + const isActionItemsDisplayed = !!active.length; + const isAlertInsightsDisplayed = !!alerts.length; + const { loadingActions, loadingGroups, @@ -177,8 +198,8 @@ export const CostInsightsPage = () => { ); } - // These should be defined, alerts can be an empty array but that's truthy - if (!dailyCost || !alerts) { + + if (!dailyCost) { return ( {`Error: Could not fetch cost insights data for team ${pageFilters.group}`} ); @@ -228,7 +249,7 @@ export const CostInsightsPage = () => { @@ -249,19 +270,22 @@ export const CostInsightsPage = () => { owner={pageFilters.group} groups={groups} hasCostData={!!dailyCost.aggregation.length} - alerts={alerts.length} + alerts={active.length} /> - {!!alerts.length && ( - <> - - - - - - - - )} + + + + + + + + @@ -276,14 +300,21 @@ export const CostInsightsPage = () => { - - {!!alerts?.length && ( + + - + - )} - - {!alerts.length && } + + + {!isAlertInsightsDisplayed && } ; }; export const CostOverviewCard = ({ @@ -47,8 +48,12 @@ export const CostOverviewCard = ({ metricData, }: CostOverviewCardProps) => { const theme = useTheme(); + const styles = useOverviewTabsStyles(theme); const config = useConfig(); const [tabIndex, setTabIndex] = useState(0); + const { setDuration, setProject, setMetric, ...filters } = useFilters( + mapFiltersToProps, + ); // Reset tabIndex if breakdowns available change useEffect(() => { @@ -59,15 +64,9 @@ export const CostOverviewCard = ({ } }, [dailyCostData, tabIndex, setTabIndex]); - const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); - const { setDuration, setProject, setMetric, ...filters } = useFilters( - mapFiltersToProps, - ); - const metric = filters.metric ? findAlways(config.metrics, m => m.kind === filters.metric) : null; - const styles = useOverviewTabsStyles(theme); const breakdownTabs = Object.keys(dailyCostData.groupedCosts ?? {}).map( key => ({ @@ -109,7 +108,7 @@ export const CostOverviewCard = ({ return ( - + {dailyCostData.groupedCosts && } diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx new file mode 100644 index 0000000000..56d636d8aa --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box } from '@material-ui/core'; +import { InfoCard } from '@backstage/core'; +import { MigrationBarChartLegend } from './MigrationBarChartLegend'; +import { MigrationBarChart } from './MigrationBarChart'; +import { MigrationData } from '../../alerts'; + +type MigrationAlertProps = { + data: MigrationData; + title: string; + subheader: string; + currentProduct: string; + comparedProduct: string; +}; + +export const MigrationAlertCard = ({ + data, + title, + subheader, + currentProduct, + comparedProduct, +}: MigrationAlertProps) => { + return ( + + + + + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx new file mode 100644 index 0000000000..60aa49b2bc --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { BarChart } from '../BarChart'; +import { + BarChartOptions, + CostInsightsTheme, + Entity, + ResourceData, +} from '../../types'; +import { useTheme } from '@material-ui/core'; + +type MigrationBarChartProps = { + currentProduct: string; + comparedProduct: string; + services: Array; +}; + +export const MigrationBarChart = ({ + currentProduct, + comparedProduct, + services, +}: MigrationBarChartProps) => { + const theme = useTheme(); + + const options: BarChartOptions = { + previousFill: theme.palette.magenta, + currentFill: theme.palette.yellow, + previousName: comparedProduct, + currentName: currentProduct, + }; + + const resources: ResourceData[] = services.map(service => ({ + name: service.id, + previous: service.aggregation[0], + current: service.aggregation[1], + })); + + return ; +}; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx new file mode 100644 index 0000000000..a84e865e28 --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, useTheme } from '@material-ui/core'; +import { CostGrowth } from '../CostGrowth'; +import { LegendItem } from '../LegendItem'; +import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types'; +import { monthOf } from '../../utils/formatters'; + +export type MigrationBarChartLegendProps = { + change: ChangeStatistic; + startDate: string; + currentProduct: string; + comparedProduct: string; +}; + +export const MigrationBarChartLegend = ({ + currentProduct, + comparedProduct, + change, + startDate, +}: MigrationBarChartLegendProps) => { + const theme = useTheme(); + return ( + + + + {currentProduct} + + + + + {comparedProduct} + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/index.ts b/plugins/cost-insights/src/components/MigrationAlertCard/index.ts similarity index 90% rename from plugins/cost-insights/src/components/AlertActionCardList/index.ts rename to plugins/cost-insights/src/components/MigrationAlertCard/index.ts index a0ce118482..4feb749c27 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/index.ts +++ b/plugins/cost-insights/src/components/MigrationAlertCard/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { AlertActionCardList } from './AlertActionCardList'; +export { MigrationAlertCard } from './MigrationAlertCard'; diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx index 0ef35bf04c..5702e67172 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -82,12 +82,7 @@ export const ProductInsights = ({ ); useEffect(() => { - async function getAllProductInsights( - group: string, - project: Maybe, - products: Product[], - lastCompleteBillingDate: string, - ) { + async function getAllProductInsights() { try { dispatchLoadingProducts(true); const responses = await Promise.allSettled( @@ -101,10 +96,10 @@ export const ProductInsights = ({ ), ).then(settledResponseOf); - const initialStates = initialStatesOf(products, responses).sort( + const updatedInitialStates = initialStatesOf(products, responses).sort( totalAggregationSort, ); - setStates(initialStates); + setStates(updatedInitialStates); } catch (e) { setError(e); } finally { @@ -112,7 +107,7 @@ export const ProductInsights = ({ } } - getAllProductInsights(group, project, products, lastCompleteBillingDate); + getAllProductInsights(); }, [ client, group, @@ -125,8 +120,8 @@ export const ProductInsights = ({ useEffect( function handleOnLoaded() { if (onceRef.current) { - const products = initialStates.map(state => state.product); - onLoaded(products); + const initialProducts = initialStates.map(state => state.product); + onLoaded(initialProducts); } else { onceRef.current = true; } diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index bd3585f739..a2f87b1d86 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -77,17 +77,6 @@ const renderProductInsightsCardInTestApp = async ( ); describe('', () => { - it('Renders the scroll anchors', async () => { - const rendered = await renderProductInsightsCardInTestApp( - mockProductCost, - MockComputeEngine, - Duration.P30D, - ); - expect( - rendered.queryByTestId(`scroll-test-compute-engine`), - ).toBeInTheDocument(); - }); - it('Should render the right subheader for products with cost data', async () => { const entity = { ...mockProductCost, diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 291044c977..d46737cafb 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -34,9 +34,9 @@ import { MapLoadingToProps, useLastCompleteBillingDate, useLoading, - useScroll, } from '../../hooks'; import { findAnyKey } from '../../utils/assert'; +import { ScrollAnchor } from '../../utils/scroll'; type LoadingProps = (isLoading: boolean) => void; @@ -60,7 +60,6 @@ export const ProductInsightsCard = ({ }: PropsWithChildren) => { const classes = useStyles(); const mountedRef = useRef(false); - const { ScrollAnchor } = useScroll(product.kind); const [error, setError] = useState>(null); const dispatchLoading = useLoading(mapLoadingToProps); const lastCompleteBillingDate = useLastCompleteBillingDate(); @@ -108,7 +107,7 @@ export const ProductInsightsCard = ({ if (error || !entity) { return ( - + {error ? error.message @@ -124,7 +123,7 @@ export const ProductInsightsCard = ({ subheader={subheader} headerProps={headerProps} > - + {entities.length ? ( ; + +export const AlertAcceptForm = forwardRef< + HTMLFormElement, + AlertAcceptFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const [checked, setChecked] = useState(false); + + const onFormSubmit: FormEventHandler = e => { + e.preventDefault(); + onSubmit(null); + }; + + const onChecked = (_: ChangeEvent, isChecked: boolean) => { + setChecked(isChecked); + disableSubmit(!isChecked); + }; + + return ( + + + + } + /> + + + ); +}); diff --git a/plugins/cost-insights/src/forms/AlertDismissForm.tsx b/plugins/cost-insights/src/forms/AlertDismissForm.tsx new file mode 100644 index 0000000000..4271785958 --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertDismissForm.tsx @@ -0,0 +1,152 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + ChangeEvent, + useEffect, + useState, + forwardRef, + FormEventHandler, +} from 'react'; +import { + Box, + Collapse, + FormControl, + FormControlLabel, + TextField, + Typography, + Radio, + RadioGroup, +} from '@material-ui/core'; +import { + Alert, + AlertFormProps, + AlertDismissReason, + AlertDismissOptions, + AlertDismissFormData, + Maybe, +} from '../types'; +import { useAlertDialogStyles as useStyles } from '../utils/styles'; + +export type AlertDismissFormProps = AlertFormProps; + +export const AlertDismissForm = forwardRef< + HTMLFormElement, + AlertDismissFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const classes = useStyles(); + const [other, setOther] = useState>(null); + const [feedback, setFeedback] = useState>(null); + const [reason, setReason] = useState( + AlertDismissReason.Resolved, + ); + + const onFormSubmit: FormEventHandler = e => { + e.preventDefault(); + if (reason) { + onSubmit({ + other: other, + reason: reason, + feedback: feedback, + }); + } + }; + + const onReasonChange = (_: ChangeEvent, value: string) => { + setReason(value as AlertDismissReason); + }; + + const onOtherChange = (e: ChangeEvent) => { + return e.target.value + ? setOther(e.target.value as AlertDismissReason) + : setOther(null); + }; + + const onFeedbackChange = (e: ChangeEvent) => { + return e.target.value + ? setFeedback(e.target.value as AlertDismissReason) + : setFeedback(null); + }; + + useEffect(() => { + function validateDismissForm() { + if (reason === AlertDismissReason.Other) { + if (other) { + disableSubmit(false); + } else { + disableSubmit(true); + } + } else if (reason) { + disableSubmit(false); + } else { + disableSubmit(true); + } + } + + validateDismissForm(); + }, [reason, other, disableSubmit]); + + return ( +
+ + + Reason for dismissing? + + + + {AlertDismissOptions.map(option => ( + } + /> + ))} + + + + + + + + + Any other feedback you can provide? + + + + + ); +}); diff --git a/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx new file mode 100644 index 0000000000..743b5ddac1 --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + ChangeEvent, + useEffect, + useState, + forwardRef, + FormEventHandler, +} from 'react'; +import dayjs from 'dayjs'; +import { + Box, + FormControl, + FormControlLabel, + RadioGroup, + Radio, + Typography, +} from '@material-ui/core'; +import { + Alert, + AlertFormProps, + Duration, + DEFAULT_DATE_FORMAT, + Maybe, + AlertSnoozeFormData, + AlertSnoozeOptions, +} from '../types'; +import { useAlertDialogStyles as useStyles } from '../utils/styles'; +import { intervalsOf } from '../utils/duration'; + +export type AlertSnoozeFormProps = AlertFormProps; + +export const AlertSnoozeForm = forwardRef< + HTMLFormElement, + AlertSnoozeFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const classes = useStyles(); + const [duration, setDuration] = useState>(Duration.P7D); + + useEffect(() => disableSubmit(false), [disableSubmit]); + + const onFormSubmit: FormEventHandler = e => { + e.preventDefault(); + if (duration) { + const repeatInterval = 1; + const today = dayjs().format(DEFAULT_DATE_FORMAT); + onSubmit({ + intervals: intervalsOf(duration, today, repeatInterval), + }); + } + }; + + const onSnoozeDurationChange = ( + _: ChangeEvent, + value: string, + ) => { + setDuration(value as Duration); + }; + + return ( +
+ + + For how long? + + + + {AlertSnoozeOptions.map(option => ( + } + /> + ))} + + + + + ); +}); diff --git a/plugins/cost-insights/src/forms/MigrationDismissForm.tsx b/plugins/cost-insights/src/forms/MigrationDismissForm.tsx new file mode 100644 index 0000000000..ce71af3a37 --- /dev/null +++ b/plugins/cost-insights/src/forms/MigrationDismissForm.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + useEffect, + useState, + forwardRef, + FormEventHandler, + ChangeEvent, +} from 'react'; +import { + Checkbox, + FormControl, + FormControlLabel, + FormGroup, + Typography, +} from '@material-ui/core'; +import { AlertFormProps, Entity } from '../types'; +import { MigrationAlert } from '../alerts'; +import { findAlways } from '../utils/assert'; + +export type MigrationDismissFormData = { + services: Entity[]; +}; + +export type MigrationDismissFormProps = AlertFormProps< + MigrationAlert, + MigrationDismissFormData +>; + +export const MigrationDismissForm = forwardRef< + HTMLFormElement, + MigrationDismissFormProps +>(({ onSubmit, disableSubmit, alert }, ref) => { + const [services, setServices] = useState(alert.data.services); + + const onFormSubmit: FormEventHandler = e => { + /* Remember to prevent default form behavior */ + e.preventDefault(); + onSubmit({ services: services }); + }; + + const onCheckboxChange = ( + e: ChangeEvent, + checked: boolean, + ) => { + if (checked) { + const service = findAlways( + alert.data.services, + s => s.id === e.target.value, + ); + setServices(prevServices => prevServices.concat(service)); + } else { + setServices(prevServices => + prevServices.filter(p => p.id !== e.target.value), + ); + } + }; + + /* Submit button is disabled by default. Use props.disableSubmit to toggle disabled state. */ + useEffect(() => { + if (services.length) { + disableSubmit(false); + } else { + disableSubmit(true); + } + }, [services, disableSubmit]); + + return ( + /* All custom forms must accept a ref and implement an onSubmit handler. */ +
+ + + Or choose which services to dismiss this alert for. + + + {alert.data.services.map((service, index) => ( + p.id === service.id)} + onChange={onCheckboxChange} + /> + } + /> + ))} + + + + ); +}); diff --git a/plugins/cost-insights/src/forms/index.ts b/plugins/cost-insights/src/forms/index.ts new file mode 100644 index 0000000000..5b8384a293 --- /dev/null +++ b/plugins/cost-insights/src/forms/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AlertAcceptForm } from './AlertAcceptForm'; +export { AlertSnoozeForm } from './AlertSnoozeForm'; +export { AlertDismissForm } from './AlertDismissForm'; +export { MigrationDismissForm } from './MigrationDismissForm'; +export type { MigrationDismissFormData } from './MigrationDismissForm'; diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx index 0a1abbdad3..137762cc2d 100644 --- a/plugins/cost-insights/src/hooks/useScroll.tsx +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -18,105 +18,44 @@ import React, { SetStateAction, useState, useContext, - useEffect, - useRef, PropsWithChildren, } from 'react'; -import { CSSProperties } from '@material-ui/styles'; import { Maybe } from '../types'; export type ScrollTo = Maybe; export type ScrollContextProps = { - scrollTo: ScrollTo; - setScrollTo: Dispatch>; + scroll: ScrollTo; + setScroll: Dispatch>; }; -export type ScrollUtils = { - ScrollAnchor: (props: Omit) => JSX.Element; - scrollIntoView: () => void; -}; - -export interface ScrollAnchorProps extends ScrollIntoViewOptions { - id: ScrollTo; - top?: number; - left?: number; -} - export const ScrollContext = React.createContext< ScrollContextProps | undefined >(undefined); -export const ScrollAnchor = ({ - id, - top, - left, - behavior, - block, - inline, -}: ScrollAnchorProps) => { - const divRef = useRef(null); - const context = useContext(ScrollContext); - - if (!context) { - assertNever(); - } - - const { scrollTo, setScrollTo } = context; - - const styles: CSSProperties = { - position: 'absolute', - height: 0, - width: 0, - top: top || 0, - left: left || 0, - }; - - useEffect(() => { - function scrollIntoView() { - const options = { - behavior: behavior || 'auto', - block: block || 'start', - inline: inline || 'nearest', - }; - - if (divRef.current && scrollTo === id) { - divRef.current.scrollIntoView(options); - setScrollTo(null); - } - } - - scrollIntoView(); - }, [scrollTo, setScrollTo, id, behavior, block, inline]); - - return
; -}; - export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => { - const [scrollTo, setScrollTo] = useState(null); - + const [scroll, setScroll] = useState(null); return ( - + {children} ); }; -export function useScroll(id: ScrollTo): ScrollUtils { +export enum ScrollType { + AlertSummary = 'alert-status-summary', +} + +export function useScroll() { const context = useContext(ScrollContext); if (!context) { assertNever(); } - return { - ScrollAnchor: props => , - scrollIntoView: () => context.setScrollTo(id), - }; + return [context.scroll, context.setScroll] as const; } function assertNever(): never { - throw new Error( - `Cannot use useScroll or ScrollAnchor outside ScrollProvider`, - ); + throw new Error(`Cannot use useScroll outside ScrollProvider`); } diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 7cd0456ad1..13ce929401 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -17,8 +17,8 @@ export { plugin } from './plugin'; export * from './client'; export * from './api'; +export { ProjectGrowthAlert, UnlabeledDataflowAlert } from './alerts'; export * from './components'; export { useCurrency } from './hooks'; export * from './types'; export * from './utils/tests'; -export * from './utils/alerts'; diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index 18b86c8cb8..eb8919b71a 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -13,23 +13,147 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { ForwardRefExoticComponent, RefAttributes } from 'react'; import { ChangeStatistic } from './ChangeStatistic'; +import { Duration } from './Duration'; import { Maybe } from './Maybe'; /** * Generic alert type with required fields for display. The `element` field will be rendered in * the Cost Insights "Action Items" section. This should use data fetched in the CostInsightsApi * implementation to render an InfoCard or other visualization. + * + * The alert type exposes hooks which can be used to enable and access various events, + * such as when a user dismisses or snoozes an alert. Default forms and buttons + * will be rendered if a hook is defined. + * + * Each default form can be overridden with a custom component. It must be implemented using + * React.forwardRef. See https://reactjs.org/docs/forwarding-refs + * + * Errors thrown within hooks will generate a snackbar error notification. */ + export type Alert = { title: string; subtitle: string; + element?: JSX.Element; + status?: AlertStatus; url?: string; buttonText?: string; // Default: View Instructions - element: JSX.Element; + SnoozeForm?: Maybe; + AcceptForm?: Maybe; + DismissForm?: Maybe; + onSnoozed?(options: AlertOptions): Promise; + onAccepted?(options: AlertOptions): Promise; + onDismissed?(options: AlertOptions): Promise; }; +export type AlertForm< + A extends Alert = any, + Data = any +> = ForwardRefExoticComponent< + AlertFormProps & RefAttributes +>; + +export interface AlertOptions { + data: T; + group: string; +} + +/** + * Default snooze form intervals are expressed using an ISO 8601 repeating interval string. + * For example, R1/P7D/2020-09-02 for 1 week or R1/P30D/2020-09-02 for 1 month. + * + * For example, if a user dismisses an alert on Monday January 01 for 1 week, + * it can be re-served on Monday, January 08. 7 calendar days from January 02, + * inclusive of the last day. + * + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ +export interface AlertSnoozeFormData { + intervals: string; +} + +export interface AlertDismissFormData { + other: Maybe; + reason: AlertDismissReason; + feedback: Maybe; +} + +// TODO: Convert enum to literal +export enum AlertStatus { + Snoozed = 'snoozed', + Accepted = 'accepted', + Dismissed = 'dismissed', +} + +export type AlertFormProps = { + alert: A; + onSubmit: (data: FormData) => void; + disableSubmit: (isDisabled: boolean) => void; +}; + +export interface AlertDismissOption { + label: string; + reason: string; +} + +export enum AlertDismissReason { + Other = 'other', + Resolved = 'resolved', + Expected = 'expected', + Seasonal = 'seasonal', + Migration = 'migration', + NotApplicable = 'not-applicable', +} + +export const AlertDismissOptions: AlertDismissOption[] = [ + { + reason: AlertDismissReason.Resolved, + label: 'This action item is now resolved.', + }, + { + reason: AlertDismissReason.Seasonal, + label: 'This is an expected increase at this time of year.', + }, + { + reason: AlertDismissReason.Migration, + label: 'This increase is from a migration in process.', + }, + { + reason: AlertDismissReason.Expected, + label: 'This is an expected increase due to our team’s priorities.', + }, + { + reason: AlertDismissReason.NotApplicable, + label: 'This action item doesn’t make sense for my team.', + }, + { + reason: AlertDismissReason.Other, + label: 'Other (please specify)', + }, +]; + +export type AlertSnoozeOption = { + label: string; + duration: Duration; +}; + +export const AlertSnoozeOptions: AlertSnoozeOption[] = [ + { + duration: Duration.P7D, + label: '1 Week', + }, + { + duration: Duration.P30D, + label: '1 Month', + }, + { + duration: Duration.P3M, + label: '1 Quarter', + }, +]; + export interface AlertCost { id: string; aggregation: [number, number]; diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index c0f03d5c27..e25f38e63d 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -21,6 +21,7 @@ * September 15. */ export enum Duration { + P7D = 'P7D', P30D = 'P30D', P90D = 'P90D', P3M = 'P3M', diff --git a/plugins/cost-insights/src/utils/alerts.test.tsx b/plugins/cost-insights/src/utils/alerts.test.tsx new file mode 100644 index 0000000000..d821fd9f1f --- /dev/null +++ b/plugins/cost-insights/src/utils/alerts.test.tsx @@ -0,0 +1,109 @@ +/* + * 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. + */ +import React, { ReactNode } from 'react'; +import { formOf } from './alerts'; +import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms'; +import { Alert, AlertStatus, AlertFormProps } from '../types'; + +type Props = AlertFormProps; + +const createMockForm = (children: ReactNode) => + React.forwardRef((props, ref) => ( +
+ {children} + + )); + +const snoozeDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), +}; + +const snoozeCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: createMockForm('Snooze'), +}; + +const snoozeNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: null, +}; + +const acceptDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), +}; + +const acceptCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), + AcceptForm: createMockForm('Accept'), +}; + +const acceptNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), + AcceptForm: null, +}; + +const dismissDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), +}; + +const dismissCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: createMockForm('Dismiss'), +}; + +const dismissNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: null, +}; + +describe('formOf', () => { + describe.each` + msg | alert | status | expected + ${'default snooze form'} | ${snoozeDefault} | ${AlertStatus.Snoozed} | ${AlertSnoozeForm} + ${'custom snooze form'} | ${snoozeCustom} | ${AlertStatus.Snoozed} | ${snoozeCustom.SnoozeForm} + ${'null snooze form'} | ${snoozeNull} | ${AlertStatus.Snoozed} | ${null} + ${'default accept form'} | ${acceptDefault} | ${AlertStatus.Accepted} | ${AlertAcceptForm} + ${'custom accept form'} | ${acceptCustom} | ${AlertStatus.Accepted} | ${acceptCustom.AcceptForm} + ${'null accept form'} | ${acceptNull} | ${AlertStatus.Accepted} | ${null} + ${'default dismiss form'} | ${dismissDefault} | ${AlertStatus.Dismissed} | ${AlertDismissForm} + ${'custom dismiss form'} | ${dismissCustom} | ${AlertStatus.Dismissed} | ${dismissCustom.DismissForm} + ${'null dismiss form'} | ${dismissNull} | ${AlertStatus.Dismissed} | ${null} + ${'no form or status'} | ${null} | ${null} | ${null} + `('Should render the correct form', ({ msg, alert, status, expected }) => { + it(`for ${msg}`, () => { + const result = formOf(alert, status); + expect(result).toBe(expected); + }); + }); +}); diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx index fbb148f38a..56403a664c 100644 --- a/plugins/cost-insights/src/utils/alerts.tsx +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -14,50 +14,118 @@ * limitations under the License. */ -import React from 'react'; -import { Alert, UnlabeledDataflowData, ProjectGrowthData } from '../types'; -import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; -import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard'; +import { Alert, AlertForm, AlertStatus, Maybe } from '../types'; +import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms'; + +const createAlertHandler = (status?: AlertStatus) => (alert: Alert) => + alert.status === status; +export const isAlertActive = (alert: Alert) => !hasProperty(alert, 'status'); +export const isAlertSnoozed = createAlertHandler(AlertStatus.Snoozed); +export const isAlertAccepted = createAlertHandler(AlertStatus.Accepted); +export const isAlertDismissed = createAlertHandler(AlertStatus.Dismissed); + +const createStatusHandler = (status: AlertStatus) => (s: Maybe) => + s === status; +export const isStatusSnoozed = createStatusHandler(AlertStatus.Snoozed); +export const isStatusAccepted = createStatusHandler(AlertStatus.Accepted); +export const isStatusDismissed = createStatusHandler(AlertStatus.Dismissed); + +const createAlertEventHandler = ( + onEvent: 'onSnoozed' | 'onAccepted' | 'onDismissed', +) => (alert: Maybe): boolean => hasProperty(alert, onEvent); +export const isSnoozeEnabled = createAlertEventHandler('onSnoozed'); +export const isAcceptEnabled = createAlertEventHandler('onAccepted'); +export const isDismissEnabled = createAlertEventHandler('onDismissed'); + +const createFormEnabledHandler = ( + Form: 'SnoozeForm' | 'AcceptForm' | 'DismissForm', +) => (alert: Maybe): boolean => { + if (!alert) return false; + if (alert[Form] === null) return false; + switch (Form) { + case 'SnoozeForm': + return isSnoozeEnabled(alert); + case 'AcceptForm': + return isAcceptEnabled(alert); + case 'DismissForm': + return isDismissEnabled(alert); + default: + return false; + } +}; +export const isSnoozeFormEnabled = createFormEnabledHandler('SnoozeForm'); +export const isAcceptFormEnabled = createFormEnabledHandler('AcceptForm'); +export const isDismissFormEnabled = createFormEnabledHandler('DismissForm'); /** - * The alerts below are examples of Alert implementation; the CostInsightsApi permits returning - * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi - * fetches alert data from the backend, then creates Alert classes with the data. + * Utility for determining if a form is disabled. + * When a form is disabled, the dialog button's type should convert from submit to button. + * @param alert + * @param status */ - -export class UnlabeledDataflowAlert implements Alert { - data: UnlabeledDataflowData; - - constructor(data: UnlabeledDataflowData) { - this.data = data; +export const isFormDisabled = ( + alert: Maybe, + status: Maybe, +): boolean => { + switch (status) { + case AlertStatus.Snoozed: + return alert?.SnoozeForm === null; + case AlertStatus.Accepted: + return alert?.AcceptForm === null; + case AlertStatus.Dismissed: + return alert?.DismissForm === null; + default: + return false; } +}; - title = 'Add labels to workflows'; - subtitle = - 'Labels show in billing data, enabling cost insights for each workflow.'; - url = '/cost-insights/labeling-jobs'; - - get element() { - return ; +export function formOf( + alert: Maybe, + status: Maybe, +): Maybe { + switch (status) { + case AlertStatus.Snoozed: { + const SnoozeForm = alert?.SnoozeForm ?? AlertSnoozeForm; + return isSnoozeFormEnabled(alert) ? SnoozeForm : null; + } + case AlertStatus.Accepted: { + const AcceptForm = alert?.AcceptForm ?? AlertAcceptForm; + return isAcceptFormEnabled(alert) ? AcceptForm : null; + } + case AlertStatus.Dismissed: { + const DismissForm = alert?.DismissForm ?? AlertDismissForm; + return isDismissFormEnabled(alert) ? DismissForm : null; + } + default: + return null; } } -export class ProjectGrowthAlert implements Alert { - data: ProjectGrowthData; - - constructor(data: ProjectGrowthData) { - this.data = data; - } - - get title() { - return `Investigate cost growth in project ${this.data.project}`; - } - - subtitle = - 'Cost growth outpacing business growth is unsustainable long-term.'; - url = '/cost-insights/investigating-growth'; - - get element() { - return ; +/** + * Utility for choosing from a fixed set of values for a given alert status. + * @param status + * @param values + */ +export function choose( + status: Maybe, + values: [T, T, T], + none: T, +): T { + switch (status) { + case AlertStatus.Snoozed: + return values[0]; + case AlertStatus.Accepted: + return values[1]; + case AlertStatus.Dismissed: + return values[2]; + default: + return none; } } + +export function hasProperty(alert: Maybe, prop: keyof Alert): boolean { + return prop in (alert ?? {}); +} + +export const sumOfAllAlerts = (sum: number, alerts: Alert[]) => + sum + alerts.length; diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts index 7cc03caa0b..9516dfe270 100644 --- a/plugins/cost-insights/src/utils/change.test.ts +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -76,13 +76,13 @@ describe('getPreviousPeriodTotalCost', () => { change: changeOf(MockAggregatedDailyCosts), trendline: trendlineOf(MockAggregatedDailyCosts), }; - const exclusiveEndDate = '2020-09-30'; + const inclusiveEndDate = '2020-09-30'; expect( getPreviousPeriodTotalCost( mockGroupDailyCost.aggregation, Duration.P30D, - exclusiveEndDate, + inclusiveEndDate, ), - ).toEqual(100_000); + ).toEqual(96_600); }); }); diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts index fc50439aad..950fbfeb82 100644 --- a/plugins/cost-insights/src/utils/change.ts +++ b/plugins/cost-insights/src/utils/change.ts @@ -22,14 +22,13 @@ import { GrowthType, MetricData, Duration, - DEFAULT_DATE_FORMAT, DateAggregation, } from '../types'; import dayjs, { OpUnitType } from 'dayjs'; -import duration from 'dayjs/plugin/duration'; +import durationPlugin from 'dayjs/plugin/duration'; import { inclusiveStartDateOf } from './duration'; -dayjs.extend(duration); +dayjs.extend(durationPlugin); // Used for displaying status colors export function growthOf(ratio: number, amount?: number) { @@ -73,10 +72,7 @@ export function getPreviousPeriodTotalCost( inclusiveEndDate: string, ): number { const dayjsDuration = dayjs.duration(duration); - const startDate = inclusiveStartDateOf( - duration, - dayjs(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT), - ); + const startDate = inclusiveStartDateOf(duration, inclusiveEndDate); // dayjs doesn't allow adding an ISO 8601 period to dates. const [amount, type]: [number, OpUnitType] = dayjsDuration.days() ? [dayjsDuration.days(), 'day'] diff --git a/plugins/cost-insights/src/utils/currency.ts b/plugins/cost-insights/src/utils/currency.ts index f1d67a14e4..8adbc4dcaa 100644 --- a/plugins/cost-insights/src/utils/currency.ts +++ b/plugins/cost-insights/src/utils/currency.ts @@ -20,6 +20,7 @@ export const rateOf = (cost: number, duration: Duration) => { switch (duration) { case Duration.P30D: return cost / 12; + case Duration.P7D: case Duration.P90D: case Duration.P3M: return cost / 4; diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 6eebead1d7..27447537cc 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -24,22 +24,21 @@ export const DEFAULT_DURATION = Duration.P30D; * Derive the start date of a given period, assuming two repeating intervals. * * @param duration see comment on Duration enum - * @param exclusiveEndDate from CostInsightsApi.getLastCompleteBillingDate + 1 day + * @param inclusiveEndDate from CostInsightsApi.getLastCompleteBillingDate */ export function inclusiveStartDateOf( duration: Duration, - exclusiveEndDate: string, + inclusiveEndDate: string, ): string { switch (duration) { + case Duration.P7D: case Duration.P30D: case Duration.P90D: - return moment(exclusiveEndDate) - .utc() + return moment(inclusiveEndDate) .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment(exclusiveEndDate) - .utc() + return moment(inclusiveEndDate) .startOf('quarter') .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); @@ -53,15 +52,12 @@ export function exclusiveEndDateOf( inclusiveEndDate: string, ): string { switch (duration) { + case Duration.P7D: case Duration.P30D: case Duration.P90D: - return moment(inclusiveEndDate) - .utc() - .add(1, 'day') - .format(DEFAULT_DATE_FORMAT); + return moment(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT); case Duration.P3M: return moment(quarterEndDate(inclusiveEndDate)) - .utc() .add(1, 'day') .format(DEFAULT_DATE_FORMAT); default: @@ -74,18 +70,24 @@ export function inclusiveEndDateOf( inclusiveEndDate: string, ): string { return moment(exclusiveEndDateOf(duration, inclusiveEndDate)) - .utc() .subtract(1, 'day') .format(DEFAULT_DATE_FORMAT); } // https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals -export function intervalsOf(duration: Duration, inclusiveEndDate: string) { - return `R2/${duration}/${exclusiveEndDateOf(duration, inclusiveEndDate)}`; +export function intervalsOf( + duration: Duration, + inclusiveEndDate: string, + repeating: number = 2, +) { + return `R${repeating}/${duration}/${exclusiveEndDateOf( + duration, + inclusiveEndDate, + )}`; } export function quarterEndDate(inclusiveEndDate: string): string { - const endDate = moment(inclusiveEndDate).utc(); + const endDate = moment(inclusiveEndDate); const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT); if (endOfQuarter === inclusiveEndDate) { return endDate.format(DEFAULT_DATE_FORMAT); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index 182c567644..8f2b94f997 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -16,7 +16,7 @@ import moment from 'moment'; import pluralize from 'pluralize'; -import { Duration, DEFAULT_DATE_FORMAT } from '../types'; +import { Duration } from '../types'; import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration'; export type Period = { @@ -92,11 +92,8 @@ export function formatPercent(n: number): string { } export function formatLastTwoLookaheadQuarters(inclusiveEndDate: string) { - const exclusiveEndDate = moment(inclusiveEndDate) - .add(1, 'day') - .format(DEFAULT_DATE_FORMAT); const start = moment( - inclusiveStartDateOf(Duration.P3M, exclusiveEndDate), + inclusiveStartDateOf(Duration.P3M, inclusiveEndDate), ).format('[Q]Q YYYY'); const end = moment(inclusiveEndDateOf(Duration.P3M, inclusiveEndDate)).format( '[Q]Q YYYY', diff --git a/plugins/cost-insights/src/utils/loading.ts b/plugins/cost-insights/src/utils/loading.ts index 3328f23cfc..d15101075d 100644 --- a/plugins/cost-insights/src/utils/loading.ts +++ b/plugins/cost-insights/src/utils/loading.ts @@ -29,6 +29,7 @@ export enum DefaultLoadingAction { CostInsightsInitial = 'cost-insights-initial', CostInsightsPage = 'cost-insights-page', CostInsightsProducts = 'cost-insights-products', + CostInsightsAlerts = 'cost-insights-alerts', } export const INITIAL_LOADING_ACTIONS = [ diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index 14ff9aecd1..d4ac4cd2df 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -36,7 +36,7 @@ import { getDefaultState as getDefaultLoadingState, } from '../utils/loading'; import { findAlways } from '../utils/assert'; -import { inclusiveStartDateOf } from './duration'; +import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; type mockAlertRenderer = (alert: T) => T; type mockEntityRenderer = (entity: T) => T; @@ -228,8 +228,9 @@ export function aggregationFor( baseline: number, ): DateAggregation[] { const { duration, endDate } = parseIntervals(intervals); + const inclusiveEndDate = inclusiveEndDateOf(duration, endDate); const days = dayjs(endDate).diff( - inclusiveStartDateOf(duration, endDate), + inclusiveStartDateOf(duration, inclusiveEndDate), 'day', ); @@ -244,7 +245,7 @@ export function aggregationFor( return [...Array(days).keys()].reduce( (values: DateAggregation[], i: number): DateAggregation[] => { const last = values.length ? values[values.length - 1].amount : baseline; - const date = dayjs(inclusiveStartDateOf(duration, endDate)) + const date = dayjs(inclusiveStartDateOf(duration, inclusiveEndDate)) .add(i, 'day') .format(DEFAULT_DATE_FORMAT); const amount = Math.max(0, last + nextDelta()); diff --git a/plugins/cost-insights/src/utils/scroll.tsx b/plugins/cost-insights/src/utils/scroll.tsx new file mode 100644 index 0000000000..06f01775df --- /dev/null +++ b/plugins/cost-insights/src/utils/scroll.tsx @@ -0,0 +1,65 @@ +/* + * 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. + */ +import React, { useEffect, useRef } from 'react'; +import { ScrollTo, useScroll } from '../hooks/useScroll'; + +/* + Utility component use in conjuction with useScroll that allows scrollable components to control behavior and offset. + 1. ScrollAnchor must be a direct child of a scrollable component. + 2. ScrollAnchor's parent position must be relative. + 3. ScrollAnchor's id must be unique. +*/ + +export interface ScrollAnchorProps extends ScrollIntoViewOptions { + id: ScrollTo; + top?: number; + left?: number; +} + +export const ScrollAnchor = ({ + id, + left = 0, + top = -20, + block = 'start', + inline = 'nearest', + behavior = 'smooth', +}: ScrollAnchorProps) => { + const divRef = useRef(null); + const [scroll, setScroll] = useScroll(); + + useEffect(() => { + function scrollIntoView() { + if (divRef.current && scroll === id) { + divRef.current.scrollIntoView({ + block, + inline, + behavior, + }); + setScroll(null); + } + } + + scrollIntoView(); + }, [scroll, setScroll, id, behavior, block, inline]); + + return ( +
+ ); +}; diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts index c04ceba8e3..acb900c490 100644 --- a/plugins/cost-insights/src/utils/styles.ts +++ b/plugins/cost-insights/src/utils/styles.ts @@ -479,8 +479,8 @@ export const useSelectStyles = makeStyles( }), ); -export const useAlertActionCardStyles = makeStyles( - (theme: BackstageTheme) => +export const useActionItemCardStyles = makeStyles( + (theme: CostInsightsTheme) => createStyles({ card: { boxShadow: 'none', @@ -489,15 +489,12 @@ export const useAlertActionCardStyles = makeStyles( backgroundColor: theme.palette.textVerySubtle, color: theme.palette.text.primary, }, - }), -); - -export const useAlertActionCardHeader = makeStyles( - (theme: CostInsightsTheme) => - createStyles({ root: { + minHeight: 80, paddingBottom: theme.spacing(2), borderRadius: theme.shape.borderRadius, + }, + activeRoot: { cursor: 'pointer', transition: theme.transitions.create('background', { duration: theme.transitions.duration.short, @@ -599,3 +596,31 @@ export const useEntityDialogStyles = makeStyles(theme => }, }), ); + +export const useAlertDialogStyles = makeStyles((theme: BackstageTheme) => + createStyles({ + content: { + padding: theme.spacing(0, 5, 2, 5), + }, + actions: { + padding: theme.spacing(2, 5), + }, + radio: { + margin: theme.spacing(-0.5, 0, -0.5, 0), + }, + icon: { + color: theme.palette.primary.dark, + margin: theme.spacing(2.5, 2.5, 0, 0), + padding: 0, + }, + }), +); + +export const useAlertStatusSummaryButtonStyles = makeStyles(() => ({ + icon: { + transform: 'transform 5s', + }, + clicked: { + transform: 'rotate(180deg)', + }, +})); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 852330acc3..aae2800573 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -163,8 +163,8 @@ export type MockScrollProviderProps = PropsWithChildren<{}>; export const MockScrollProvider = ({ children }: MockScrollProviderProps) => { const defaultContext: ScrollContextProps = { - scrollTo: null, - setScrollTo: jest.fn(), + scroll: null, + setScroll: jest.fn(), }; return ( diff --git a/plugins/explore-react/.eslintrc.js b/plugins/explore-react/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/explore-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/explore-react/README.md b/plugins/explore-react/README.md new file mode 100644 index 0000000000..4a881955df --- /dev/null +++ b/plugins/explore-react/README.md @@ -0,0 +1,4 @@ +# explore-react + +This package provides helpers to the `explore` plugin that can be imported by +any other plugin or app. diff --git a/plugins/explore-react/package.json b/plugins/explore-react/package.json new file mode 100644 index 0000000000..80d3d9d21e --- /dev/null +++ b/plugins/explore-react/package.json @@ -0,0 +1,48 @@ +{ + "name": "@backstage/plugin-explore-react", + "version": "0.0.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" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/explore-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli plugin:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "clean": "backstage-cli clean", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack" + }, + "dependencies": { + "@backstage/core": "^0.5.0" + }, + "devDependencies": { + "@backstage/cli": "^0.5.0", + "@backstage/dev-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.6", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/api-docs/src/components/EntityLink/index.ts b/plugins/explore-react/src/index.ts similarity index 93% rename from plugins/api-docs/src/components/EntityLink/index.ts rename to plugins/explore-react/src/index.ts index 44875e4634..d46f5d4f27 100644 --- a/plugins/api-docs/src/components/EntityLink/index.ts +++ b/plugins/explore-react/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { EntityLink } from './EntityLink'; +export * from './tools'; diff --git a/plugins/explore-react/src/setupTests.ts b/plugins/explore-react/src/setupTests.ts new file mode 100644 index 0000000000..825bcd4115 --- /dev/null +++ b/plugins/explore-react/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; diff --git a/plugins/explore-react/src/tools/api.test.ts b/plugins/explore-react/src/tools/api.test.ts new file mode 100644 index 0000000000..386d66ead2 --- /dev/null +++ b/plugins/explore-react/src/tools/api.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { exploreToolsConfigRef } from './api'; + +describe('api', () => { + // This is a dummy test to have at least one test in the package. + it('api ref exists', () => { + expect(exploreToolsConfigRef.id).toBe('plugin.explore.toolsconfig'); + }); +}); diff --git a/plugins/explore-react/src/tools/api.ts b/plugins/explore-react/src/tools/api.ts new file mode 100644 index 0000000000..5d96033546 --- /dev/null +++ b/plugins/explore-react/src/tools/api.ts @@ -0,0 +1,36 @@ +/* + * 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 { createApiRef } from '@backstage/core'; + +export const exploreToolsConfigRef = createApiRef({ + id: 'plugin.explore.toolsconfig', + description: 'Used to configure tools displayed in the explore plugin', +}); + +export type ExploreTool = { + title: string; + description?: string; + url: string; + image: string; + tags?: string[]; + lifecycle?: string; + newsTag?: string; +}; + +export interface ExploreToolsConfig { + getTools: () => Promise; +} diff --git a/plugins/explore-react/src/tools/index.ts b/plugins/explore-react/src/tools/index.ts new file mode 100644 index 0000000000..7f727881ca --- /dev/null +++ b/plugins/explore-react/src/tools/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { exploreToolsConfigRef } from './api'; +export type { ExploreTool, ExploreToolsConfig } from './api'; diff --git a/plugins/explore/README.md b/plugins/explore/README.md index d5453e594c..ec00cb4dee 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -1,7 +1,32 @@ -# Title +# explore Welcome to the explore plugin! +This plugin helps to visualize the domains and tools in your ecosystem. -## Sub-section 1 +## Getting started -## Sub-section 2 +To install the plugin, include the following import your `plugins.ts`: + +```typescript +export { explorePlugin } from '@backstage/plugin-explore'; +``` + +Register the route in `App.tsx`: + +```typescript +import { ExplorePage } from '@backstage/plugin-explore'; + +... + +} /> +``` + +Add a link to the sidebar in `Root.tsx`: + +```typescript +import LayersIcon from '@material-ui/icons/Layers'; + +... + + +``` diff --git a/plugins/explore/dev/index.tsx b/plugins/explore/dev/index.tsx index 812a5585d4..9a7b66a7b5 100644 --- a/plugins/explore/dev/index.tsx +++ b/plugins/explore/dev/index.tsx @@ -14,7 +14,59 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { exploreToolsConfigRef } from '@backstage/plugin-explore-react'; +import React from 'react'; +import { ExplorePage, explorePlugin } from '../src'; +import { exampleTools } from '../src/util/examples'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerPlugin(explorePlugin) + .registerApi({ + api: exploreToolsConfigRef, + deps: {}, + factory: () => ({ + async getTools() { + return exampleTools; + }, + }), + }) + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + ({ + async getEntities() { + const domainNames = [ + 'playback', + 'artists', + 'payments', + 'analytics', + 'songs', + 'devops', + ]; + + return { + items: domainNames.map( + (n, i) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: n, + description: `Everything about ${n}`, + tags: i % 2 === 0 ? [n] : undefined, + }, + spec: { + owner: `${n}@example.com`, + }, + } as Entity), + ), + }; + }, + } as CatalogApi), + }) + .addPage({ element: , title: 'Explore' }) + .render(); diff --git a/plugins/explore/package.json b/plugins/explore/package.json index e4acc34435..2afa686355 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -30,7 +30,10 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { + "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", + "@backstage/plugin-catalog-react": "^0.0.1", + "@backstage/plugin-explore-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -38,11 +41,11 @@ "classnames": "^2.2.6", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/explore/src/components/DomainCard/DomainCard.test.tsx b/plugins/explore/src/components/DomainCard/DomainCard.test.tsx new file mode 100644 index 0000000000..9d409051f6 --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCard.test.tsx @@ -0,0 +1,49 @@ +/* + * 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 { DomainEntity } from '@backstage/catalog-model'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { DomainCard } from './DomainCard'; + +describe('', () => { + it('renders a domain card', () => { + const entity: DomainEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'artists', + description: 'Everything about artists', + tags: ['a-tag'], + }, + spec: { + owner: 'guest', + }, + }; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('Everything about artists')).toBeInTheDocument(); + expect(getByText('a-tag')).toBeInTheDocument(); + expect(getByText('Explore').parentElement).toHaveAttribute( + 'href', + '/catalog/default/domain/artists', + ); + }); +}); diff --git a/plugins/explore/src/components/DomainCard/DomainCard.tsx b/plugins/explore/src/components/DomainCard/DomainCard.tsx new file mode 100644 index 0000000000..2c0126b94a --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCard.tsx @@ -0,0 +1,41 @@ +/* + * 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. + */ +import { DomainEntity } from '@backstage/catalog-model'; +import { ItemCard } from '@backstage/core'; +import { + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { generatePath } from 'react-router-dom'; + +type DomainCardProps = { + entity: DomainEntity; +}; + +export const DomainCard = ({ entity }: DomainCardProps) => ( + +); diff --git a/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx b/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx new file mode 100644 index 0000000000..bad3ca581b --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 { DomainEntity } from '@backstage/catalog-model'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { DomainCardGrid } from './DomainCardGrid'; + +describe('', () => { + it('renders a grid of domain cards', () => { + const entities: DomainEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'playback', + }, + spec: { + owner: 'guest', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'artists', + }, + spec: { + owner: 'guest', + }, + }, + ]; + const { getByText } = render(, { + wrapper: MemoryRouter, + }); + + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('playback')).toBeInTheDocument(); + }); +}); diff --git a/plugins/explore/src/components/DomainCard/DomainCardGrid.tsx b/plugins/explore/src/components/DomainCard/DomainCardGrid.tsx new file mode 100644 index 0000000000..b4a13aa39f --- /dev/null +++ b/plugins/explore/src/components/DomainCard/DomainCardGrid.tsx @@ -0,0 +1,33 @@ +/* + * 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. + */ +import { DomainEntity } from '@backstage/catalog-model'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { DomainCard } from '.'; + +type DomainCardGridProps = { + entities: DomainEntity[]; +}; + +export const DomainCardGrid = ({ entities }: DomainCardGridProps) => ( + + {entities.map((e, i) => ( + + + + ))} + +); diff --git a/plugins/explore/src/components/DomainCard/index.ts b/plugins/explore/src/components/DomainCard/index.ts new file mode 100644 index 0000000000..6acf10800a --- /dev/null +++ b/plugins/explore/src/components/DomainCard/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ +export { DomainCard } from './DomainCard'; +export { DomainCardGrid } from './DomainCardGrid'; diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx new file mode 100644 index 0000000000..4846565bdd --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -0,0 +1,93 @@ +/* + * 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 { DomainEntity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { DomainExplorerContent } from './DomainExplorerContent'; + +describe('', () => { + const catalogApi: jest.Mocked = { + addLocation: jest.fn(_a => new Promise(() => {})), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + + {children} + + + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders a grid of domains', async () => { + const entities: DomainEntity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'playback', + }, + spec: { + owner: 'guest', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: 'artists', + }, + spec: { + owner: 'guest', + }, + }, + ]; + catalogApi.getEntities.mockResolvedValue({ items: entities }); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(getByText('artists')).toBeInTheDocument(); + expect(getByText('playback')).toBeInTheDocument(); + }); + }); + + it('renders empty state', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => + expect(getByText('No domains to display')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx new file mode 100644 index 0000000000..ca00c525c9 --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx @@ -0,0 +1,67 @@ +/* + * 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. + */ +import { DomainEntity } from '@backstage/catalog-model'; +import { + Content, + ContentHeader, + EmptyState, + Progress, + SupportButton, + useApi, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Button } from '@material-ui/core'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { DomainCardGrid } from '../DomainCard'; + +export const DomainExplorerContent = () => { + const catalogApi = useApi(catalogApiRef); + const { value: entities, loading } = useAsync(async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'domain' }, + }); + + return response.items as DomainEntity[]; + }, [catalogApi]); + + return ( + + + Discover the domains in your ecosystem. + + + {loading && } + {!loading && (!entities || entities.length === 0) && ( + + Read more + + } + /> + )} + {!loading && entities && } + + ); +}; diff --git a/plugins/explore/src/components/DomainExplorerContent/index.ts b/plugins/explore/src/components/DomainExplorerContent/index.ts new file mode 100644 index 0000000000..4012332006 --- /dev/null +++ b/plugins/explore/src/components/DomainExplorerContent/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { DomainExplorerContent } from './DomainExplorerContent'; diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx similarity index 51% rename from plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx rename to plugins/explore/src/components/ExplorePage/ExplorePage.tsx index fe99e70575..3f9394a1ab 100644 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.tsx +++ b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * 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. @@ -13,28 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { configApiRef, Header, Page, useApi } from '@backstage/core'; import React from 'react'; -import { Content, Header, Page } from '@backstage/core'; -import { RollbarProjectTable } from '../RollbarProjectTable/RollbarProjectTable'; -import { useRollbarEntities } from '../../hooks/useRollbarEntities'; - -export const RollbarHome = () => { - const { entities, loading, error } = useRollbarEntities(); +import { ExploreTabs } from './ExploreTabs'; +export const ExplorePage = () => { + const configApi = useApi(configApiRef); + const organizationName = + configApi.getOptionalString('organization.name') ?? 'Backstage'; return ( - +
- - - + + ); }; diff --git a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx new file mode 100644 index 0000000000..0cf28f9f1c --- /dev/null +++ b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx @@ -0,0 +1,30 @@ +/* + * 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. + */ +import { TabbedLayout } from '@backstage/core'; +import React from 'react'; +import { DomainExplorerContent } from '../DomainExplorerContent'; +import { ToolExplorerContent } from '../ToolExplorerContent'; + +export const ExploreTabs = () => ( + + + + + + + + +); diff --git a/plugins/explore/src/components/ExplorePage/index.ts b/plugins/explore/src/components/ExplorePage/index.ts new file mode 100644 index 0000000000..b075c410c3 --- /dev/null +++ b/plugins/explore/src/components/ExplorePage/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { ExplorePage } from './ExplorePage'; diff --git a/plugins/explore/src/components/ExploreCard.test.js b/plugins/explore/src/components/ToolCard/ToolCard.test.tsx similarity index 70% rename from plugins/explore/src/components/ExploreCard.test.js rename to plugins/explore/src/components/ToolCard/ToolCard.test.tsx index 45652015a5..b3c1737ae6 100644 --- a/plugins/explore/src/components/ExploreCard.test.js +++ b/plugins/explore/src/components/ToolCard/ToolCard.test.tsx @@ -14,11 +14,10 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; - -import ExploreCard from './ExploreCard'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { ToolCard } from './ToolCard'; const minProps = { card: { @@ -30,37 +29,31 @@ const minProps = { }, }; -describe('', () => { +describe('', () => { it('renders without exploding', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText('Explore')).toBeInTheDocument(); }); it('renders props correctly', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText(minProps.card.title)).toBeInTheDocument(); expect(getByText(minProps.card.description)).toBeInTheDocument(); }); it('should link out', () => { - const rendered = render(wrapInTestApp()); + const rendered = render(wrapInTestApp()); const anchor = rendered.container.querySelector('a'); - expect(anchor.href).toBe(minProps.card.url); + expect(anchor).toHaveAttribute('href', minProps.card.url); }); it('renders default description when missing', () => { - const propsWithoutDescription = { - card: { - card: { - title: 'Title', - url: 'http://spotify.com/', - image: 'https://developer.spotify.com/assets/WebAPI_intro.png', - }, - }, + const card = { + title: 'Title', + url: 'http://spotify.com/', + image: 'https://developer.spotify.com/assets/WebAPI_intro.png', }; - const { getByText } = render( - wrapInTestApp(), - ); + const { getByText } = render(wrapInTestApp()); expect(getByText('Description missing')).toBeInTheDocument(); }); @@ -74,13 +67,13 @@ describe('', () => { }, }; const { queryByText } = render( - wrapInTestApp(), + wrapInTestApp(), ); expect(queryByText('GA')).not.toBeInTheDocument(); }); it('renders tags correctly', () => { - const { getByText } = render(wrapInTestApp()); + const { getByText } = render(wrapInTestApp()); expect(getByText(minProps.card.tags[0])).toBeInTheDocument(); expect(getByText(minProps.card.tags[1])).toBeInTheDocument(); }); diff --git a/plugins/explore/src/components/ExploreCard.tsx b/plugins/explore/src/components/ToolCard/ToolCard.tsx similarity index 91% rename from plugins/explore/src/components/ExploreCard.tsx rename to plugins/explore/src/components/ToolCard/ToolCard.tsx index 8d1348c116..c6d812b098 100644 --- a/plugins/explore/src/components/ExploreCard.tsx +++ b/plugins/explore/src/components/ToolCard/ToolCard.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import React from 'react'; -import classNames from 'classnames'; +import { ExploreTool } from '@backstage/plugin-explore-react'; +import { BackstageTheme } from '@backstage/theme'; import { Button, Card, @@ -23,10 +23,13 @@ import { CardContent, CardMedia, Chip, - Typography, makeStyles, + Typography, } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; +import classNames from 'classnames'; +import React from 'react'; + +// TODO: Align styling between Domain and ToolCard const useStyles = makeStyles(theme => ({ card: { @@ -65,22 +68,12 @@ const useStyles = makeStyles(theme => ({ }, })); -export type CardData = { - title: string; - description: string; - url: string; - image: string; - tags?: string[]; - lifecycle?: string; - newsTag?: string; -}; - type Props = { - card: CardData; + card: ExploreTool; objectFit?: 'cover' | 'contain'; }; -const ExploreCard = ({ card, objectFit }: Props) => { +export const ToolCard = ({ card, objectFit }: Props) => { const classes = useStyles(); const { title, description, url, image, lifecycle, newsTag, tags } = card; @@ -130,5 +123,3 @@ const ExploreCard = ({ card, objectFit }: Props) => { ); }; - -export default ExploreCard; diff --git a/plugins/explore/src/components/ToolCard/ToolCardGrid.tsx b/plugins/explore/src/components/ToolCard/ToolCardGrid.tsx new file mode 100644 index 0000000000..5b6fbd1834 --- /dev/null +++ b/plugins/explore/src/components/ToolCard/ToolCardGrid.tsx @@ -0,0 +1,46 @@ +/* + * 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 { ExploreTool } from '@backstage/plugin-explore-react'; +import { BackstageTheme } from '@backstage/theme'; +import { makeStyles } from '@material-ui/core'; +import React from 'react'; +import { ToolCard } from './ToolCard'; + +const useStyles = makeStyles(theme => ({ + container: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, 296px)', + gridGap: theme.spacing(3), + marginBottom: theme.spacing(6), + }, +})); + +type ToolCardGridProps = { + tools: ExploreTool[]; +}; + +export const ToolCardGrid = ({ tools }: ToolCardGridProps) => { + const classes = useStyles(); + + return ( +
+ {tools.map((card: ExploreTool, ix: any) => ( + + ))} +
+ ); +}; diff --git a/plugins/explore/src/components/ToolCard/index.ts b/plugins/explore/src/components/ToolCard/index.ts new file mode 100644 index 0000000000..84599aa163 --- /dev/null +++ b/plugins/explore/src/components/ToolCard/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ +export { ToolCard } from './ToolCard'; +export { ToolCardGrid } from './ToolCardGrid'; diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx new file mode 100644 index 0000000000..b0e1330ff3 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx @@ -0,0 +1,94 @@ +/* + * 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 { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + ExploreTool, + exploreToolsConfigRef, +} from '@backstage/plugin-explore-react'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { ToolExplorerContent } from './ToolExplorerContent'; + +describe('', () => { + const exploreToolsConfigApi: jest.Mocked = { + getTools: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + + + {children} + + + + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders a grid of tools', async () => { + const tools: ExploreTool[] = [ + { + title: 'Lighthouse', + description: + "Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website.", + url: '/lighthouse', + image: + 'https://raw.githubusercontent.com/GoogleChrome/lighthouse/8b3d7f052b2e64dd857e741d7395647f487697e7/assets/lighthouse-logo.png', + tags: ['web', 'seo', 'accessibility', 'performance'], + }, + { + title: 'Tech Radar', + description: + 'Tech Radar is a list of technologies, complemented by an assessment result, called ring assignment.', + url: '/tech-radar', + image: + 'https://storage.googleapis.com/wf-blogs-engineering-media/2018/09/fe13bb32-wf-tech-radar-hero-1024x597.png', + tags: ['standards', 'landscape'], + }, + ]; + exploreToolsConfigApi.getTools.mockResolvedValue(tools); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(getByText('Lighthouse')).toBeInTheDocument(); + expect(getByText('Tech Radar')).toBeInTheDocument(); + }); + }); + + it('renders empty state', async () => { + exploreToolsConfigApi.getTools.mockResolvedValue([]); + + const { getByText } = render(, { + wrapper: Wrapper, + }); + + await waitFor(() => + expect(getByText('No tools to display')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx new file mode 100644 index 0000000000..8b8b3f0a61 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx @@ -0,0 +1,52 @@ +/* + * 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. + */ +import { + Content, + ContentHeader, + EmptyState, + Progress, + SupportButton, + useApi, +} from '@backstage/core'; +import { exploreToolsConfigRef } from '@backstage/plugin-explore-react'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { ToolCardGrid } from '../ToolCard'; + +export const ToolExplorerContent = () => { + const exploreToolsConfigApi = useApi(exploreToolsConfigRef); + const { value: tools, loading } = useAsync(async () => { + return await exploreToolsConfigApi.getTools(); + }, [exploreToolsConfigApi]); + + return ( + + + Discover the tools in your ecosystem. + + + {loading && } + {!loading && (!tools || tools.length === 0) && ( + + )} + {!loading && tools && } + + ); +}; diff --git a/plugins/explore/src/components/ToolExplorerContent/index.ts b/plugins/explore/src/components/ToolExplorerContent/index.ts new file mode 100644 index 0000000000..8fb3072d46 --- /dev/null +++ b/plugins/explore/src/components/ToolExplorerContent/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { ToolExplorerContent } from './ToolExplorerContent'; diff --git a/plugins/explore/src/extensions.tsx b/plugins/explore/src/extensions.tsx new file mode 100644 index 0000000000..cdf43d3035 --- /dev/null +++ b/plugins/explore/src/extensions.tsx @@ -0,0 +1,27 @@ +/* + * 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 { createRoutableExtension } from '@backstage/core'; +import { explorePlugin } from './plugin'; +import { exploreRouteRef } from './routes'; + +export const ExplorePage = explorePlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ExplorePage').then(m => m.ExplorePage), + mountPoint: exploreRouteRef, + }), +); diff --git a/plugins/explore/src/index.ts b/plugins/explore/src/index.ts index ff7857cacd..70a00f5bbb 100644 --- a/plugins/explore/src/index.ts +++ b/plugins/explore/src/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ -export { plugin } from './plugin'; -export { Router } from './components/Router'; +export * from './extensions'; +export { explorePlugin } from './plugin'; +export * from './routes'; diff --git a/plugins/explore/src/plugin.test.ts b/plugins/explore/src/plugin.test.ts index d6503c038b..652a0590dc 100644 --- a/plugins/explore/src/plugin.test.ts +++ b/plugins/explore/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { explorePlugin } from './plugin'; describe('explore', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(explorePlugin).toBeDefined(); }); }); diff --git a/plugins/explore/src/plugin.ts b/plugins/explore/src/plugin.ts index 75ea892242..27573eaeb9 100644 --- a/plugins/explore/src/plugin.ts +++ b/plugins/explore/src/plugin.ts @@ -14,9 +14,27 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; +import { createApiFactory, createPlugin } from '@backstage/core'; +import { exploreToolsConfigRef } from '@backstage/plugin-explore-react'; +import { exploreRouteRef } from './routes'; +import { exampleTools } from './util/examples'; -export const rootRouteRef = createRouteRef({ path: '', title: 'Explore' }); -export const plugin = createPlugin({ +export const explorePlugin = createPlugin({ id: 'explore', + apis: [ + // Register a default for exploreToolsConfigRef, you may want to override + // the API locally in your app. + createApiFactory({ + api: exploreToolsConfigRef, + deps: {}, + factory: () => ({ + async getTools() { + return exampleTools; + }, + }), + }), + ], + routes: { + explore: exploreRouteRef, + }, }); diff --git a/plugins/rollbar/src/routes.ts b/plugins/explore/src/routes.ts similarity index 69% rename from plugins/rollbar/src/routes.ts rename to plugins/explore/src/routes.ts index b514dff3c8..c41a53128f 100644 --- a/plugins/rollbar/src/routes.ts +++ b/plugins/explore/src/routes.ts @@ -18,19 +18,7 @@ import { createRouteRef } from '@backstage/core'; const NoIcon = () => null; -export const rootRouteRef = createRouteRef({ +export const exploreRouteRef = createRouteRef({ icon: NoIcon, - path: '/rollbar', - title: 'Rollbar Home', -}); - -export const entityRouteRef = createRouteRef({ - path: '/rollbar/:optionalNamespaceAndName', - title: 'Rollbar', -}); - -export const catalogRouteRef = createRouteRef({ - icon: NoIcon, - path: '', - title: 'Rollbar', + title: 'Explore', }); diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/util/examples.ts similarity index 75% rename from plugins/explore/src/components/ExplorePluginPage.tsx rename to plugins/explore/src/util/examples.ts index f3e75e0011..d0a4cdd1f4 100644 --- a/plugins/explore/src/components/ExplorePluginPage.tsx +++ b/plugins/explore/src/util/examples.ts @@ -14,28 +14,7 @@ * limitations under the License. */ -import React from 'react'; -import { makeStyles, Typography } from '@material-ui/core'; -import { - Content, - ContentHeader, - Header, - Page, - SupportButton, -} from '@backstage/core'; -import ExploreCard, { CardData } from './ExploreCard'; -import { BackstageTheme } from '@backstage/theme'; - -const useStyles = makeStyles(theme => ({ - container: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, 296px)', - gridGap: theme.spacing(3), - marginBottom: theme.spacing(6), - }, -})); - -const toolsCards = [ +export const exampleTools = [ { title: 'New Relic', description: @@ -112,28 +91,3 @@ const toolsCards = [ tags: ['rollbar', 'monitoring', 'errors'], }, ]; - -export const ExplorePluginPage = () => { - const classes = useStyles(); - - return ( - -
- - - - Explore tools available in Backstage - - -
- {toolsCards.map((card: CardData, ix: any) => ( - - ))} -
-
- - ); -}; diff --git a/plugins/fossa/README.md b/plugins/fossa/README.md index 489aef90ab..6e53ced0cf 100644 --- a/plugins/fossa/README.md +++ b/plugins/fossa/README.md @@ -19,21 +19,21 @@ yarn add @backstage/plugin-fossa ```js // packages/app/src/plugins.ts -export { plugin as Fossa } from '@backstage/plugin-fossa'; +export { fossaPlugin } from '@backstage/plugin-fossa'; ``` -3. Add the `FossaCard` to the EntityPage: +3. Add the `EntityFossaCard` to the EntityPage: ```jsx // packages/app/src/components/catalog/EntityPage.tsx -import { FossaCard } from '@backstage/plugin-fossa'; +import { EntityFossaCard } from '@backstage/plugin-fossa'; const OverviewContent = ({ entity }: { entity: Entity }) => ( // ... - + // ... diff --git a/plugins/fossa/dev/index.tsx b/plugins/fossa/dev/index.tsx index 7846324b25..06c71c48b7 100644 --- a/plugins/fossa/dev/index.tsx +++ b/plugins/fossa/dev/index.tsx @@ -14,21 +14,28 @@ * limitations under the License. */ -import { createDevApp } from '@backstage/dev-utils'; -import { - Content, - createPlugin, - createRouteRef, - Header, - Page, -} from '@backstage/core'; -import React from 'react'; -import { Grid } from '@material-ui/core'; -import { FossaApi, fossaApiRef } from '../src/api'; -import { FossaCard } from '../src'; import { Entity } from '@backstage/catalog-model'; +import { Content, Header, Page } from '@backstage/core'; +import { createDevApp } from '@backstage/dev-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { EntityFossaCard } from '../src'; +import { FossaApi, fossaApiRef } from '../src/api'; import { FOSSA_PROJECT_NAME_ANNOTATION } from '../src/components/useProjectName'; +const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [FOSSA_PROJECT_NAME_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + createDevApp() .registerApi({ api: fossaApiRef, @@ -76,58 +83,51 @@ createDevApp() }, } as FossaApi), }) - .registerPlugin( - createPlugin({ - id: 'fossa-demo', - register({ router }) { - const entity = (name?: string) => - ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - annotations: { - [FOSSA_PROJECT_NAME_ANNOTATION]: name, - }, - name: name, - }, - } as Entity); - - const ExamplePage = () => ( - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - ); - - router.addRoute( - createRouteRef({ path: '/', title: 'Fossa' }), - ExamplePage, - ); - }, - }), - ) + .addPage({ + title: 'Entity Content', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }) .render(); diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 27f6ad0966..879f12c6f6 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -33,6 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,7 +44,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.tsx index d1ac06d365..4cc27f5d03 100644 --- a/plugins/fossa/src/components/FossaCard/FossaCard.tsx +++ b/plugins/fossa/src/components/FossaCard/FossaCard.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { EmptyState, InfoCard, @@ -22,15 +21,16 @@ import { Progress, useApi, } from '@backstage/core'; -import { useAsync } from 'react-use'; -import { Entity } from '@backstage/catalog-model'; -import { fossaApiRef } from '../../api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { Grid, Tooltip } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { fossaApiRef } from '../../api'; import { FOSSA_PROJECT_NAME_ANNOTATION, useProjectName, } from '../useProjectName'; -import { Grid, Tooltip } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ numberError: { @@ -65,13 +65,8 @@ const useStyles = makeStyles(theme => ({ }, })); -export const FossaCard = ({ - entity, - variant = 'gridItem', -}: { - entity: Entity; - variant?: string; -}) => { +export const FossaCard = () => { + const { entity } = useEntity(); const fossaApi = useApi(fossaApiRef); const projectTitle = useProjectName(entity); @@ -96,7 +91,7 @@ export const FossaCard = ({ ( - - } /> - +export const EntityFossaCard = fossaPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/FossaCard').then(m => m.FossaCard), + }, + }), ); diff --git a/plugins/fossa/src/index.ts b/plugins/fossa/src/index.ts index c35c534122..f1b645c29b 100644 --- a/plugins/fossa/src/index.ts +++ b/plugins/fossa/src/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { plugin } from './plugin'; -export * from './components'; +export { fossaPlugin } from './plugin'; +export { EntityFossaCard } from './extensions'; diff --git a/plugins/fossa/src/plugin.test.ts b/plugins/fossa/src/plugin.test.ts index 4f9b00a02b..9aed1387c5 100644 --- a/plugins/fossa/src/plugin.test.ts +++ b/plugins/fossa/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { fossaPlugin } from './plugin'; describe('fossa', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(fossaPlugin).toBeDefined(); }); }); diff --git a/plugins/fossa/src/plugin.ts b/plugins/fossa/src/plugin.ts index d1fe621018..cb1de3911f 100644 --- a/plugins/fossa/src/plugin.ts +++ b/plugins/fossa/src/plugin.ts @@ -22,7 +22,7 @@ import { } from '@backstage/core'; import { fossaApiRef, FossaClient } from './api'; -export const plugin = createPlugin({ +export const fossaPlugin = createPlugin({ id: 'fossa', apis: [ createApiFactory({ diff --git a/plugins/gcp-projects/dev/index.tsx b/plugins/gcp-projects/dev/index.tsx index 812a5585d4..b87d4b7d47 100644 --- a/plugins/gcp-projects/dev/index.tsx +++ b/plugins/gcp-projects/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { gcpProjectsPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(gcpProjectsPlugin).render(); diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 71f1d83c99..d48295e204 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -37,11 +37,11 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.2.0", + "react-router-dom": "^6.0.0-beta.0", "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx b/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx new file mode 100644 index 0000000000..158347a9ff --- /dev/null +++ b/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import { NewProjectPage } from '../NewProjectPage'; +import { ProjectDetailsPage } from '../ProjectDetailsPage'; +import { ProjectListPage } from '../ProjectListPage'; + +export const GcpProjectsPage = () => ( + + } /> + } /> + } /> + +); diff --git a/plugins/gcp-projects/src/components/GcpProjectsPage/index.ts b/plugins/gcp-projects/src/components/GcpProjectsPage/index.ts new file mode 100644 index 0000000000..a39db43c16 --- /dev/null +++ b/plugins/gcp-projects/src/components/GcpProjectsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { GcpProjectsPage } from './GcpProjectsPage'; diff --git a/plugins/gcp-projects/src/index.ts b/plugins/gcp-projects/src/index.ts index d67bc6a864..af3f0bc4d9 100644 --- a/plugins/gcp-projects/src/index.ts +++ b/plugins/gcp-projects/src/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + gcpProjectsPlugin, + gcpProjectsPlugin as plugin, + GcpProjectsPage, +} from './plugin'; export * from './api'; diff --git a/plugins/gcp-projects/src/plugin.test.ts b/plugins/gcp-projects/src/plugin.test.ts index 86e909995f..78eddd8603 100644 --- a/plugins/gcp-projects/src/plugin.test.ts +++ b/plugins/gcp-projects/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { gcpProjectsPlugin } from './plugin'; describe('gcp-projects', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(gcpProjectsPlugin).toBeDefined(); }); }); diff --git a/plugins/gcp-projects/src/plugin.ts b/plugins/gcp-projects/src/plugin.ts index 2baf3bce7d..9126716d55 100644 --- a/plugins/gcp-projects/src/plugin.ts +++ b/plugins/gcp-projects/src/plugin.ts @@ -17,29 +17,20 @@ import { createApiFactory, createPlugin, - createRouteRef, + createRoutableExtension, googleAuthApiRef, } from '@backstage/core'; import { gcpApiRef, GcpClient } from './api'; import { NewProjectPage } from './components/NewProjectPage'; import { ProjectDetailsPage } from './components/ProjectDetailsPage'; import { ProjectListPage } from './components/ProjectListPage'; +import { rootRouteRef, projectRouteRef, newProjectRouteRef } from './routes'; -export const rootRouteRef = createRouteRef({ - path: '/gcp-projects', - title: 'GCP Projects', -}); -export const projectRouteRef = createRouteRef({ - path: '/gcp-projects/project', - title: 'GCP Project Page', -}); -export const newProjectRouteRef = createRouteRef({ - path: '/gcp-projects/new', - title: 'GCP Project Page', -}); - -export const plugin = createPlugin({ +export const gcpProjectsPlugin = createPlugin({ id: 'gcp-projects', + routes: { + root: rootRouteRef, + }, apis: [ createApiFactory({ api: gcpApiRef, @@ -55,3 +46,11 @@ export const plugin = createPlugin({ router.addRoute(newProjectRouteRef, NewProjectPage); }, }); + +export const GcpProjectsPage = gcpProjectsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/GcpProjectsPage').then(m => m.GcpProjectsPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/gcp-projects/src/routes.ts b/plugins/gcp-projects/src/routes.ts new file mode 100644 index 0000000000..ff9bf9d85c --- /dev/null +++ b/plugins/gcp-projects/src/routes.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +import { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + path: '/gcp-projects', + title: 'GCP Projects', +}); +export const projectRouteRef = createRouteRef({ + path: '/gcp-projects/project', + title: 'GCP Project Page', +}); +export const newProjectRouteRef = createRouteRef({ + path: '/gcp-projects/new', + title: 'GCP Project Page', +}); diff --git a/plugins/github-actions/examples/sample.yaml b/plugins/github-actions/examples/sample.yaml index beda3c58ed..a765bd395d 100644 --- a/plugins/github-actions/examples/sample.yaml +++ b/plugins/github-actions/examples/sample.yaml @@ -5,7 +5,7 @@ metadata: description: backstage.io annotations: github.com/project-slug: 'backstage/backstage' - backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + backstage.io/techdocs-ref: url:https://github.com/backstage/backstage spec: type: website lifecycle: production diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index c3b05b0369..beb35b3cb0 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -34,8 +34,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/integration": "^0.3.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/integration": "^0.3.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,7 +49,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 2b98f0179d..447c3cbb81 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -42,7 +42,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphiql/dev/index.tsx b/plugins/graphiql/dev/index.tsx index b93995a5dc..918695e0e0 100644 --- a/plugins/graphiql/dev/index.tsx +++ b/plugins/graphiql/dev/index.tsx @@ -14,12 +14,18 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; import { githubAuthApiRef, errorApiRef } from '@backstage/core'; -import { plugin, GraphQLEndpoints, graphQlBrowseApiRef } from '../src'; +import { + graphiqlPlugin, + GraphQLEndpoints, + graphQlBrowseApiRef, + GraphiQLPage, +} from '../src'; createDevApp() - .registerPlugin(plugin) + .registerPlugin(graphiqlPlugin) .registerApi({ api: graphQlBrowseApiRef, deps: { @@ -47,4 +53,8 @@ createDevApp() ]); }, }) + .addPage({ + title: 'GraphiQL', + element: , + }) .render(); diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 1b95db6e39..850b29108e 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphiql/src/index.ts b/plugins/graphiql/src/index.ts index 4b1da14079..ed64779eba 100644 --- a/plugins/graphiql/src/index.ts +++ b/plugins/graphiql/src/index.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -export { plugin, GraphiQLPage } from './plugin'; +export { + graphiqlPlugin, + graphiqlPlugin as plugin, + GraphiQLPage, +} from './plugin'; export { GraphiQLPage as Router } from './components'; export * from './lib/api'; export * from './route-refs'; diff --git a/plugins/graphiql/src/plugin.test.ts b/plugins/graphiql/src/plugin.test.ts index 0841d3e071..3683c0be0a 100644 --- a/plugins/graphiql/src/plugin.test.ts +++ b/plugins/graphiql/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { graphiqlPlugin } from './plugin'; describe('graphiql', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(graphiqlPlugin).toBeDefined(); }); }); diff --git a/plugins/graphiql/src/plugin.ts b/plugins/graphiql/src/plugin.ts index 87f750b132..42969999d9 100644 --- a/plugins/graphiql/src/plugin.ts +++ b/plugins/graphiql/src/plugin.ts @@ -22,7 +22,7 @@ import { import { graphQlBrowseApiRef, GraphQLEndpoints } from './lib/api'; import { graphiQLRouteRef } from './route-refs'; -export const plugin = createPlugin({ +export const graphiqlPlugin = createPlugin({ id: 'graphiql', apis: [ // GitLab is used as an example endpoint, but most will want to plug in @@ -40,7 +40,7 @@ export const plugin = createPlugin({ ], }); -export const GraphiQLPage = plugin.provide( +export const GraphiQLPage = graphiqlPlugin.provide( createRoutableExtension({ component: () => import('./components').then(m => m.GraphiQLPage), mountPoint: graphiQLRouteRef, diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index f5214a3210..99d369b1a7 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@backstage/plugin-catalog-graphql": "^0.2.6", "@graphql-modules/core": "^0.7.17", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index c511553699..7456d4be53 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.13", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/jenkins/src/components/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts index db7c7ddd55..a1ff3a1eef 100644 --- a/plugins/jenkins/src/components/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -36,17 +36,17 @@ export function useBuilds(owner: string, repo: string, branch?: string) { const { loading, value: builds, retry } = useAsyncRetry(async () => { try { - let builds; + let build; if (branch) { - builds = await api.getLastBuild(`${owner}/${repo}/${branch}`); + build = await api.getLastBuild(`${owner}/${repo}/${branch}`); } else { - builds = await api.getFolder(`${owner}/${repo}`); + build = await api.getFolder(`${owner}/${repo}`); } - const size = Array.isArray(builds) ? builds?.[0].build_num! : 1; + const size = Array.isArray(build) ? build?.[0].build_num! : 1; setTotal(size); - return builds || []; + return build || []; } catch (e) { errorApi.post(e); throw e; diff --git a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts index 1dca918990..ac2d6c7e8f 100644 --- a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts +++ b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { JENKINS_ANNOTATION } from '../constants'; export const useProjectSlugFromEntity = () => { diff --git a/plugins/kafka-backend/CHANGELOG.md b/plugins/kafka-backend/CHANGELOG.md index f8dbdbd00b..1e4701af16 100644 --- a/plugins/kafka-backend/CHANGELOG.md +++ b/plugins/kafka-backend/CHANGELOG.md @@ -1,5 +1,43 @@ # @backstage/plugin-kafka-backend +## 0.2.0 + +### Minor Changes + +- 234e7d985: Added support for multiple Kafka clusters and multiple consumers per component. + Note that this introduces several breaking changes. + + 1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way: + + ```diff + kafka: + clientId: backstage + - brokers: + - - localhost:9092 + + clusters: + + - name: prod + + brokers: + + - localhost:9092 + ``` + + 2. Configuration of services has changed as well to support multiple clusters: + + ```diff + annotations: + - kafka.apache.org/consumer-groups: consumer + + kafka.apache.org/consumer-groups: prod/consumer + ``` + + 3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path: + `/consumers/${clusterId}/${consumerGroup}/offsets` + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.1.1 ### Patch Changes diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index 0c7eff2193..311464b47d 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kafka-backend", - "version": "0.1.1", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/jest-when": "^2.7.2", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", diff --git a/plugins/kafka/CHANGELOG.md b/plugins/kafka/CHANGELOG.md index 01479f8186..92f49a1d35 100644 --- a/plugins/kafka/CHANGELOG.md +++ b/plugins/kafka/CHANGELOG.md @@ -1,5 +1,42 @@ # @backstage/plugin-kafka +## 0.2.0 + +### Minor Changes + +- 234e7d985: Added support for multiple Kafka clusters and multiple consumers per component. + Note that this introduces several breaking changes. + + 1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way: + + ```diff + kafka: + clientId: backstage + - brokers: + - - localhost:9092 + + clusters: + + - name: prod + + brokers: + + - localhost:9092 + ``` + + 2. Configuration of services has changed as well to support multiple clusters: + + ```diff + annotations: + - kafka.apache.org/consumer-groups: consumer + + kafka.apache.org/consumer-groups: prod/consumer + ``` + + 3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path: + `/consumers/${clusterId}/${consumerGroup}/offsets` + +### Patch Changes + +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/plugin-catalog@0.2.14 + ## 0.1.1 ### Patch Changes diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 306b65a653..546d20bc67 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kafka", - "version": "0.1.1", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx index 3bee4e2e88..a28d1af15d 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity'; -import { EntityContext } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { PropsWithChildren } from 'react'; +import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity'; describe('useConsumerGroupOffsets', () => { let entity: Entity; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts index 522a6273fe..11155dca10 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { useMemo } from 'react'; import { KAFKA_CONSUMER_GROUP_ANNOTATION } from '../../constants'; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx index b7e3a29e80..9609654a90 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx @@ -13,19 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { EntityContext } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; -import * as data from './__fixtures__/consumer-group-offsets.json'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { renderHook } from '@testing-library/react-hooks'; +import { when } from 'jest-when'; +import React, { PropsWithChildren } from 'react'; import { ConsumerGroupOffsetsResponse, KafkaApi, kafkaApiRef, } from '../../api/types'; -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; import { useConsumerGroupsOffsetsForEntity } from './useConsumerGroupsOffsetsForEntity'; -import { when } from 'jest-when'; +import * as data from './__fixtures__/consumer-group-offsets.json'; const consumerGroupOffsets = data as ConsumerGroupOffsetsResponse; diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 279cb8a5ba..08a3a66657 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-kubernetes-backend +## 0.2.6 + +### Patch Changes + +- 681111228: Add AWS auth provider for Kubernetes +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.2.5 ### Patch Changes diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index eeb91de777..e69a82b738 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.2.5", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ }, "dependencies": { "@aws-sdk/credential-provider-node": "^3.3.0", - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@kubernetes/client-node": "^0.13.2", @@ -51,7 +51,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/aws4": "^1.5.1", "supertest": "^4.0.2" }, diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts index 2dd1e32b96..811a3c8acb 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts @@ -85,18 +85,18 @@ export const handleGetKubernetesObjectsForService: GetKubernetesObjectsForServic ] || `backstage.io/kubernetes-id=${requestBody.entity.metadata.name}`; return Promise.all( - clusterDetailsDecoratedForAuth.map(clusterDetails => { + clusterDetailsDecoratedForAuth.map(clusterDetailsItem => { return fetcher .fetchObjectsForService({ serviceId, - clusterDetails, + clusterDetails: clusterDetailsItem, objectTypesToFetch, labelSelector, } as ObjectFetchParams) .then(result => { return { cluster: { - name: clusterDetails.name, + name: clusterDetailsItem.name, }, resources: result.responses, errors: result.errors, diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index c1f09038ba..f16728e1fe 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-kubernetes +## 0.3.7 + +### Patch Changes + +- 681111228: Add AWS auth provider for Kubernetes +- a6e3b9596: Improve error reporting for plugin misconfiguration. +- Updated dependencies [681111228] + - @backstage/plugin-kubernetes-backend@0.2.6 + ## 0.3.6 ### Patch Changes diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 96d700c3bb..1d985c0d10 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.3.6", + "version": "0.3.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,7 +34,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", - "@backstage/plugin-kubernetes-backend": "^0.2.5", + "@backstage/plugin-kubernetes-backend": "^0.2.6", "@backstage/theme": "^0.2.2", "@kubernetes/client-node": "^0.13.2", "@material-ui/core": "^4.11.0", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx index 6bacad9b16..8c1365769f 100644 --- a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx @@ -32,8 +32,8 @@ export const DeploymentDrawer = ({ object={deployment} expanded={expanded} kind="Deployment" - renderObject={(deployment: V1Deployment) => { - const conditions = (deployment.status?.conditions ?? []) + renderObject={(deploymentObj: V1Deployment) => { + const conditions = (deploymentObj.status?.conditions ?? []) .map(renderCondition) .reduce((accum, next) => { accum[next[0]] = next[1]; @@ -41,10 +41,10 @@ export const DeploymentDrawer = ({ }, {} as { [key: string]: React.ReactNode }); return { - strategy: deployment.spec?.strategy ?? '???', - minReadySeconds: deployment.spec?.minReadySeconds ?? '???', + strategy: deploymentObj.spec?.strategy ?? '???', + minReadySeconds: deploymentObj.spec?.minReadySeconds ?? '???', progressDeadlineSeconds: - deployment.spec?.progressDeadlineSeconds ?? '???', + deploymentObj.spec?.progressDeadlineSeconds ?? '???', ...conditions, }; }} diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx index 00ab2cad84..26dcdfd249 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx @@ -32,16 +32,16 @@ export const HorizontalPodAutoscalerDrawer = ({ kind="HorizontalPodAutoscaler" object={hpa} expanded={expanded} - renderObject={(hpa: V1HorizontalPodAutoscaler) => { + renderObject={(hpaObject: V1HorizontalPodAutoscaler) => { return { targetCPUUtilizationPercentage: - hpa.spec?.targetCPUUtilizationPercentage, + hpaObject.spec?.targetCPUUtilizationPercentage, currentCPUUtilizationPercentage: - hpa.status?.currentCPUUtilizationPercentage, - minReplicas: hpa.spec?.minReplicas, - maxReplicas: hpa.spec?.maxReplicas, - currentReplicas: hpa.status?.currentReplicas, - desiredReplicas: hpa.status?.desiredReplicas, + hpaObject.status?.currentCPUUtilizationPercentage, + minReplicas: hpaObject.spec?.minReplicas, + maxReplicas: hpaObject.spec?.maxReplicas, + currentReplicas: hpaObject.status?.currentReplicas, + desiredReplicas: hpaObject.status?.desiredReplicas, }; }} > diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx index 9e63f22fda..d619700ac2 100644 --- a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx @@ -31,8 +31,8 @@ export const IngressDrawer = ({ object={ingress} expanded={expanded} kind="Ingress" - renderObject={(ingress: ExtensionsV1beta1Ingress) => { - return ingress.spec || {}; + renderObject={(ingressObject: ExtensionsV1beta1Ingress) => { + return ingressObject.spec || {}; }} > { - const phase = pod.status?.phase ?? 'unknown'; + renderObject={(podObject: V1Pod) => { + const phase = podObject.status?.phase ?? 'unknown'; const ports = - pod.spec?.containers?.map(c => { + podObject.spec?.containers?.map(c => { return { [c.name]: c.ports, }; }) ?? 'N/A'; - const conditions = (pod.status?.conditions ?? []) + const conditions = (podObject.status?.conditions ?? []) .map(renderCondition) .reduce((accum, next) => { accum[next[0]] = next[1]; @@ -55,11 +55,11 @@ export const PodDrawer = ({ }, {} as { [key: string]: React.ReactNode }); return { - images: imageChips(pod), + images: imageChips(podObject), phase: phase, - 'Containers Ready': containersReady(pod), - 'Total Restarts': totalRestarts(pod), - 'Container Statuses': containerStatuses(pod), + 'Containers Ready': containersReady(podObject), + 'Total Restarts': totalRestarts(podObject), + 'Container Statuses': containerStatuses(podObject), ...conditions, 'Exposed ports': ports, }; diff --git a/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx index d48f17813a..d0962c6314 100644 --- a/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx @@ -31,8 +31,8 @@ export const ServiceDrawer = ({ object={service} expanded={expanded} kind="Service" - renderObject={(service: V1Service) => { - return service.spec || {}; + renderObject={(serviceObject: V1Service) => { + return serviceObject.spec || {}; }} > { export const containersReady = (pod: V1Pod): string => { const containerStatuses = pod.status?.containerStatuses ?? []; - const containersReady = containerStatuses.filter(cs => cs.ready).length; + const containersReadyItem = containerStatuses.filter(cs => cs.ready).length; - return `${containersReady}/${containerStatuses.length}`; + return `${containersReadyItem}/${containerStatuses.length}`; }; export const totalRestarts = (pod: V1Pod): number => { @@ -47,8 +47,8 @@ export const totalRestarts = (pod: V1Pod): number => { }; export const containerStatuses = (pod: V1Pod): ReactNode => { - const containerStatuses = pod.status?.containerStatuses ?? []; - const errors = containerStatuses.reduce((accum, next) => { + const containerStatusesItem = pod.status?.containerStatuses ?? []; + const errors = containerStatusesItem.reduce((accum, next) => { if (next.state === undefined) { return accum; } diff --git a/plugins/lighthouse/dev/index.tsx b/plugins/lighthouse/dev/index.tsx index bf761965f1..1ea54e5871 100644 --- a/plugins/lighthouse/dev/index.tsx +++ b/plugins/lighthouse/dev/index.tsx @@ -15,11 +15,11 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { lighthousePlugin } from '../src/plugin'; import { lighthouseApiRef, LighthouseRestApi } from '../src'; createDevApp() - .registerPlugin(plugin) + .registerPlugin(lighthousePlugin) .registerApi({ api: lighthouseApiRef, deps: {}, diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 162da40fd0..5b45bfa48b 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -34,7 +34,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/lighthouse/src/Router.tsx b/plugins/lighthouse/src/Router.tsx index d264f3bc1b..0634a0aaa0 100644 --- a/plugins/lighthouse/src/Router.tsx +++ b/plugins/lighthouse/src/Router.tsx @@ -16,7 +16,6 @@ import React from 'react'; import { Route, Routes } from 'react-router-dom'; -import { createAuditRouteRef, rootRouteRef, viewAuditRouteRef } from './plugin'; import AuditList from './components/AuditList'; import AuditView, { AuditViewContent } from './components/AuditView'; import CreateAudit, { CreateAuditContent } from './components/CreateAudit'; @@ -25,32 +24,26 @@ import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../constants'; import { AuditListForEntity } from './components/AuditList/AuditListForEntity'; import { MissingAnnotationEmptyState } from '@backstage/core'; -export const isPluginApplicableToEntity = (entity: Entity) => +export const isLighthouseAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION]); export const Router = () => ( - } /> - } /> - } /> + } /> + } /> + } /> ); export const EmbeddedRouter = ({ entity }: { entity: Entity }) => - !isPluginApplicableToEntity(entity) ? ( + !isLighthouseAvailable(entity) ? ( ) : ( - } /> - } - /> - } - /> + } /> + } /> + } /> ); diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx index 15a4ba3255..483d630db8 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -14,23 +14,22 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { lighthouseApiRef, LighthouseRestApi, WebsiteListResponse, } from '../../api'; - -import * as data from '../../__fixtures__/website-list-response.json'; -import { EntityContext } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; -import { AuditListForEntity } from './AuditListForEntity'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; -import { MemoryRouter } from 'react-router-dom'; import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; +import * as data from '../../__fixtures__/website-list-response.json'; +import { AuditListForEntity } from './AuditListForEntity'; jest.mock('../../hooks/useWebsiteForEntity', () => ({ useWebsiteForEntity: jest.fn(), diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index 0fb66a317b..8a59869266 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -25,7 +25,6 @@ import { } from '../../utils'; import { Link, generatePath } from 'react-router-dom'; import AuditStatusIcon from '../AuditStatusIcon'; -import { viewAuditRouteRef } from '../../plugin'; const columns: TableColumn[] = [ { @@ -99,11 +98,7 @@ export const AuditListTable = ({ items }: { items: Website[] }) => { return { websiteUrl: ( - + {website.url} ), diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index b022482da4..ce0fcdb46e 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -35,7 +35,6 @@ import { useQuery } from '../../utils'; import LighthouseSupportButton from '../SupportButton'; import LighthouseIntro, { LIGHTHOUSE_INTRO_LOCAL_STORAGE } from '../Intro'; import AuditListTable from './AuditListTable'; -import { createAuditRouteRef } from '../../plugin'; export const LIMIT = 10; @@ -110,7 +109,7 @@ const AuditList = () => { diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx index 2a99339a84..2be284183c 100644 --- a/plugins/lighthouse/src/components/AuditView/index.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -47,7 +47,6 @@ import { lighthouseApiRef, Website, Audit } from '../../api'; import AuditStatusIcon from '../AuditStatusIcon'; import LighthouseSupportButton from '../SupportButton'; import { formatTime } from '../../utils'; -import { viewAuditRouteRef, createAuditRouteRef } from '../../plugin'; const useStyles = makeStyles({ contentGrid: { @@ -78,12 +77,7 @@ const AuditLinkList = ({ audits = [], selectedId }: AuditLinkListProps) => ( button component={Link} replace - to={resolvePath( - generatePath(viewAuditRouteRef.path, { - id: audit.id, - }), - '../../', - )} + to={resolvePath(generatePath('audit/:id', { id: audit.id }), '../../')} > @@ -163,7 +157,7 @@ export const AuditViewContent = () => { ); } - let createAuditButtonUrl = createAuditRouteRef.path; + let createAuditButtonUrl = 'create-audit'; if (value?.url) { createAuditButtonUrl += `?url=${encodeURIComponent(value.url)}`; } diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx index 42c213b42e..1322736321 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx @@ -14,21 +14,21 @@ * limitations under the License. */ -import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { AuditCompleted, LighthouseCategoryId, WebsiteListResponse, } from '../../api'; -import { EntityContext } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; -import { LastLighthouseAuditCard } from './LastLighthouseAuditCard'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; -import { MemoryRouter } from 'react-router-dom'; import * as data from '../../__fixtures__/website-list-response.json'; +import { LastLighthouseAuditCard } from './LastLighthouseAuditCard'; jest.mock('../../hooks/useWebsiteForEntity', () => ({ useWebsiteForEntity: jest.fn(), diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index c1773921cf..e22a64bdc1 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { lighthouseApiRef, WebsiteListResponse } from '../api'; -import { useWebsiteForEntity } from './useWebsiteForEntity'; -import { EntityContext } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { PropsWithChildren } from 'react'; +import { lighthouseApiRef, WebsiteListResponse } from '../api'; import * as data from '../__fixtures__/website-list-response.json'; +import { useWebsiteForEntity } from './useWebsiteForEntity'; const websiteListResponse = data as WebsiteListResponse; const website = websiteListResponse.items[0]; diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts index 08d9925b7a..e6713cf320 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog'; -import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants'; import { errorApiRef, useApi } from '@backstage/core'; -import { lighthouseApiRef } from '../api'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; +import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants'; +import { lighthouseApiRef } from '../api'; // For the sake of simplicity we assume that an entity has only one website url. This is to avoid encoding a list // type in an annotation which is a plain string. diff --git a/plugins/lighthouse/src/index.ts b/plugins/lighthouse/src/index.ts index 64fe2f8cc0..bf51bbbf35 100644 --- a/plugins/lighthouse/src/index.ts +++ b/plugins/lighthouse/src/index.ts @@ -14,7 +14,18 @@ * limitations under the License. */ -export { plugin } from './plugin'; -export { Router, isPluginApplicableToEntity, EmbeddedRouter } from './Router'; +export { + lighthousePlugin, + lighthousePlugin as plugin, + LighthousePage, + EntityLighthouseContent, + EntityLastLighthouseAuditCard, +} from './plugin'; +export { + Router, + isLighthouseAvailable as isPluginApplicableToEntity, + isLighthouseAvailable, + EmbeddedRouter, +} from './Router'; export * from './api'; export * from './components/Cards'; diff --git a/plugins/lighthouse/src/plugin.test.ts b/plugins/lighthouse/src/plugin.test.ts index 70b1844ec2..642f438e03 100644 --- a/plugins/lighthouse/src/plugin.test.ts +++ b/plugins/lighthouse/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { lighthousePlugin } from './plugin'; describe('lighthouse', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(lighthousePlugin).toBeDefined(); }); }); diff --git a/plugins/lighthouse/src/plugin.ts b/plugins/lighthouse/src/plugin.ts index e9055b4db7..c2a8521da7 100644 --- a/plugins/lighthouse/src/plugin.ts +++ b/plugins/lighthouse/src/plugin.ts @@ -19,6 +19,8 @@ import { createRouteRef, createApiFactory, configApiRef, + createRoutableExtension, + createComponentExtension, } from '@backstage/core'; import { lighthouseApiRef, LighthouseRestApi } from './api'; @@ -37,7 +39,11 @@ export const createAuditRouteRef = createRouteRef({ title: 'Create Lighthouse Audit', }); -export const plugin = createPlugin({ +export const entityContentRouteRef = createRouteRef({ + title: 'Lighthouse Entity Content', +}); + +export const lighthousePlugin = createPlugin({ id: 'lighthouse', apis: [ createApiFactory({ @@ -46,4 +52,31 @@ export const plugin = createPlugin({ factory: ({ configApi }) => LighthouseRestApi.fromConfig(configApi), }), ], + routes: { + root: createAuditRouteRef, + entityContent: entityContentRouteRef, + }, }); + +export const LighthousePage = lighthousePlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); + +export const EntityLighthouseContent = lighthousePlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.EmbeddedRouter), + mountPoint: entityContentRouteRef, + }), +); + +export const EntityLastLighthouseAuditCard = lighthousePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/Cards').then(m => m.LastLighthouseAuditCard), + }, + }), +); diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 57f146521b..2f43c9cbeb 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/org/dev/index.tsx b/plugins/org/dev/index.tsx index 264d6f801f..01951eaf03 100644 --- a/plugins/org/dev/index.tsx +++ b/plugins/org/dev/index.tsx @@ -14,6 +14,6 @@ * limitations under the License. */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { orgPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(orgPlugin).render(); diff --git a/plugins/org/package.json b/plugins/org/package.json index 976caffdbb..e376dea5bc 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 779cb1d92d..506a98d012 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -21,7 +21,7 @@ import { RELATION_PARENT_OF, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { entityRouteParams } from '@backstage/plugin-catalog'; +import { entityRouteParams } from '@backstage/plugin-catalog-react'; import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; import AccountTreeIcon from '@material-ui/icons/AccountTree'; import EmailIcon from '@material-ui/icons/Email'; @@ -73,12 +73,12 @@ export const GroupProfileCard = ({ } = group; const parent = group?.relations ?.filter(r => r.type === RELATION_CHILD_OF) - ?.map(group => group.target.name) + ?.map(groupItem => groupItem.target.name) .toString(); const childrens = group?.relations ?.filter(r => r.type === RELATION_PARENT_OF) - ?.map(group => group.target.name); + ?.map(groupItem => groupItem.target.name); const displayName = profile?.displayName ?? name; diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx index 535b2acd0e..6815fd0e55 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ +import { Entity, GroupEntity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; import React from 'react'; -import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; -import { Entity, GroupEntity } from '@backstage/catalog-model'; import { MembersListCard } from './MembersListCard'; describe('MemberTab Test', () => { diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx index 04a137d515..f644e7db7e 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -20,7 +20,10 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Avatar, InfoCard, Progress, useApi } from '@backstage/core'; -import { catalogApiRef, entityRouteParams } from '@backstage/plugin-catalog'; +import { + catalogApiRef, + entityRouteParams, +} from '@backstage/plugin-catalog-react'; import { Box, createStyles, diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index a61dd55161..60f06bd53a 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -14,12 +14,10 @@ * limitations under the License. */ -import React from 'react'; -import { InfoCard, useApi, Progress } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; -import { catalogApiRef } from '@backstage/plugin-catalog'; -import { useAsync } from 'react-use'; -import Alert from '@material-ui/lab/Alert'; +import { InfoCard, Progress, useApi } from '@backstage/core'; +import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react'; +import { pageTheme } from '@backstage/theme'; import { Box, createStyles, @@ -28,8 +26,9 @@ import { Theme, Typography, } from '@material-ui/core'; -import { pageTheme } from '@backstage/theme'; -import { isOwnerOf } from '../../isOwnerOf'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { useAsync } from 'react-use'; type EntitiesKinds = 'Component' | 'API'; type EntitiesTypes = diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index 99048c0cd8..bdd0cd15f6 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -19,7 +19,7 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { entityRouteParams } from '@backstage/plugin-catalog'; +import { entityRouteParams } from '@backstage/plugin-catalog-react'; import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; import EmailIcon from '@material-ui/icons/Email'; import GroupIcon from '@material-ui/icons/Group'; diff --git a/plugins/org/src/components/getEntityRelations.ts b/plugins/org/src/components/getEntityRelations.ts deleted file mode 100644 index 9230aaebee..0000000000 --- a/plugins/org/src/components/getEntityRelations.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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 { Entity, EntityName } from '@backstage/catalog-model'; - -// TODO: this file is copied from /packages/app/catalog/src/components/getEntityRelations.ts and -// should be replaced once common relation-functions are introduced. - -/** - * Get the related entity references. - */ -export function getEntityRelations( - entity: Entity | undefined, - relationType: string, - filter?: { kind: string }, -): EntityName[] { - let entityNames = - entity?.relations - ?.filter(r => r.type === relationType) - ?.map(r => r.target) || []; - - if (filter?.kind) { - entityNames = entityNames?.filter( - e => e.kind.toLowerCase() === filter.kind.toLowerCase(), - ); - } - - return entityNames; -} diff --git a/plugins/org/src/components/isOwnerOf.ts b/plugins/org/src/components/isOwnerOf.ts deleted file mode 100644 index dd7c7d6805..0000000000 --- a/plugins/org/src/components/isOwnerOf.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 { - Entity, - EntityName, - getEntityName, - RELATION_MEMBER_OF, - RELATION_OWNED_BY, -} from '@backstage/catalog-model'; -import { getEntityRelations } from './getEntityRelations'; - -// TODO: this file is copied from /packages/app/catalog/src/components/isOwnerOf.ts and -// should be replaced once common relation-functions are introduced. - -/** - * Check if one entity is owned by another. Returns true, if the entity is owned by the - * owner directly, or if the entity is owned by a group that the owner is a member of. - */ -export function isOwnerOf(owner: Entity, owned: Entity) { - const possibleOwners: EntityName[] = [ - ...getEntityRelations(owner, RELATION_MEMBER_OF, { kind: 'group' }), - ...(owner ? [getEntityName(owner)] : []), - ]; - - const owners = getEntityRelations(owned, RELATION_OWNED_BY); - - for (const owner of owners) { - if ( - possibleOwners.find( - o => - owner.kind.toLowerCase() === o.kind.toLowerCase() && - owner.namespace.toLowerCase() === o.namespace.toLowerCase() && - owner.name.toLowerCase() === o.name.toLowerCase(), - ) !== undefined - ) { - return true; - } - } - - return false; -} diff --git a/plugins/org/src/index.ts b/plugins/org/src/index.ts index 77ad7f9266..36c5f94ee5 100644 --- a/plugins/org/src/index.ts +++ b/plugins/org/src/index.ts @@ -13,5 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin } from './plugin'; +export { + orgPlugin, + orgPlugin as plugin, + EntityGroupProfileCard, + EntityMembersListCard, + EntityOwnershipCard, + EntityUserProfileCard, +} from './plugin'; export * from './components'; diff --git a/plugins/org/src/plugin.test.ts b/plugins/org/src/plugin.test.ts index d77cfd7ae8..e488422441 100644 --- a/plugins/org/src/plugin.test.ts +++ b/plugins/org/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { plugin } from './plugin'; +import { orgPlugin } from './plugin'; describe('groups', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(orgPlugin).toBeDefined(); }); }); diff --git a/plugins/org/src/plugin.ts b/plugins/org/src/plugin.ts index 39c3502fb5..195e88ea41 100644 --- a/plugins/org/src/plugin.ts +++ b/plugins/org/src/plugin.ts @@ -13,8 +13,37 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { createComponentExtension, createPlugin } from '@backstage/core'; -export const plugin = createPlugin({ +export const orgPlugin = createPlugin({ id: 'org', }); + +export const EntityGroupProfileCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.GroupProfileCard), + }, + }), +); +export const EntityMembersListCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.MembersListCard), + }, + }), +); +export const EntityOwnershipCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.OwnershipCard), + }, + }), +); +export const EntityUserProfileCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.UserProfileCard), + }, + }), +); diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 174f62d5e7..0e87bdc2d7 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -44,7 +44,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx index faa480f6e2..6fd036330a 100644 --- a/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx +++ b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx @@ -32,11 +32,11 @@ export const EscalationPolicy = ({ policyId }: Props) => { const { value: users, loading, error } = useAsync(async () => { const oncalls = await api.getOnCallByPolicyId(policyId); - const users = oncalls + const usersItem = oncalls .sort((a, b) => a.escalation_level - b.escalation_level) .map(oncall => oncall.user); - return users; + return usersItem; }); if (error) { diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx index 4fa88a1b9a..c390ed1f82 100644 --- a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx @@ -52,11 +52,11 @@ export const TriggerDialog = ({ const [description, setDescription] = useState(''); const [{ value, loading, error }, handleTriggerAlarm] = useAsyncFn( - async (description: string) => + async (descriptions: string) => await api.triggerAlarm({ integrationKey, source: window.location.toString(), - description, + description: descriptions, userName, }), ); diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 94825e8691..ae828cb2ec 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -28,7 +28,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -42,7 +42,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 99f7931c2b..39c431b3cd 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -45,7 +45,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index 6b097085e9..e9da8ef84d 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -20,7 +20,7 @@ import { createRouteRef, errorApiRef, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import { render, screen } from '@testing-library/react'; diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index d0b56598b2..8dbf712559 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -14,23 +14,23 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { Grid, makeStyles } from '@material-ui/core'; +import { Entity, Location } from '@backstage/catalog-model'; import { - InfoCard, - Page, Content, - useApi, + ContentHeader, errorApiRef, Header, - SupportButton, - ContentHeader, + InfoCard, + Page, RouteRef, + SupportButton, + useApi, } from '@backstage/core'; -import { RegisterComponentForm } from '../RegisterComponentForm'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Grid, makeStyles } from '@material-ui/core'; +import React, { useState } from 'react'; import { useMountedState } from 'react-use'; -import { Entity, Location } from '@backstage/catalog-model'; +import { RegisterComponentForm } from '../RegisterComponentForm'; import { RegisterComponentResultDialog } from '../RegisterComponentResultDialog'; const useStyles = makeStyles(theme => ({ diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index c31d1344cb..87248cb6db 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -16,7 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { RouteRef, StructuredMetadataTable } from '@backstage/core'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; +import { + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog-react'; import { Button, Dialog, diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 39a2b17369..522f5a3b4c 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "axios": "^0.21.1", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/supertest": "^2.0.8", "supertest": "^4.0.2" }, diff --git a/plugins/rollbar/README.md b/plugins/rollbar/README.md index cc862051f7..6337d45ffe 100644 --- a/plugins/rollbar/README.md +++ b/plugins/rollbar/README.md @@ -19,17 +19,7 @@ yarn add @backstage/plugin-rollbar export { plugin as Rollbar } from '@backstage/plugin-rollbar'; ``` -4. Add plugin API to your Backstage instance: - -```ts -// packages/app/src/api.ts -import { RollbarClient, rollbarApiRef } from '@backstage/plugin-rollbar'; - -// ... -builder.add(rollbarApiRef, new RollbarClient({ discoveryApi })); -``` - -5. Add to the app `EntityPage` component: +4. Add to the app `EntityPage` component: ```ts // packages/app/src/components/catalog/EntityPage.tsx @@ -48,7 +38,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( ); ``` -6. Setup the `app.config.yaml` and account token environment variable +5. Setup the `app.config.yaml` and account token environment variable ```yaml # app.config.yaml @@ -59,7 +49,7 @@ rollbar: $env: ROLLBAR_ACCOUNT_TOKEN ``` -7. Annotate entities with the rollbar project slug +6. Annotate entities with the rollbar project slug ```yaml # pump-station-catalog-component.yaml @@ -71,7 +61,7 @@ metadata: rollbar.com/project-slug: project-name ``` -8. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity +7. Run app with `yarn start` and navigate to `/rollbar` or a catalog entity ## Features diff --git a/plugins/rollbar/dev/index.tsx b/plugins/rollbar/dev/index.tsx index 812a5585d4..6cac0d9a0c 100644 --- a/plugins/rollbar/dev/index.tsx +++ b/plugins/rollbar/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { rollbarPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(rollbarPlugin).render(); diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index c37ca53a82..7ba72dad0d 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx b/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx index 888b9aabe0..07a39bd147 100644 --- a/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx +++ b/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx @@ -14,14 +14,18 @@ * limitations under the License. */ -import React from 'react'; import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; import { RollbarProject } from '../RollbarProject/RollbarProject'; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; }; -export const EntityPageRollbar = ({ entity }: Props) => { +export const EntityPageRollbar = (_props: Props) => { + const { entity } = useEntity(); + return ; }; diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx deleted file mode 100644 index ff3e909bf7..0000000000 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import { - ApiProvider, - ApiRegistry, - ConfigApi, - configApiRef, -} from '@backstage/core'; -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { render } from '@testing-library/react'; -import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi'; -import { RollbarProject } from '../../api/types'; -import { RollbarHome } from './RollbarHome'; - -describe('RollbarHome component', () => { - const projects: RollbarProject[] = [ - { id: 123, name: 'abc', accountId: 1, status: 'enabled' }, - { id: 456, name: 'xyz', accountId: 1, status: 'enabled' }, - ]; - const rollbarApi: Partial = { - getAllProjects: () => Promise.resolve(projects), - }; - const config: Partial = { - getString: () => 'foo', - getOptionalString: () => 'foo', - }; - - const renderWrapped = (children: React.ReactNode) => - render( - wrapInTestApp( - ) as CatalogApi, - ], - ])} - > - {children} - , - ), - ); - - it('should render rollbar landing page', async () => { - const rendered = renderWrapped(); - expect(rendered.getByText(/Rollbar/)).toBeInTheDocument(); - }); -}); diff --git a/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx b/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx index 34881ba560..b69c69a56b 100644 --- a/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx +++ b/plugins/rollbar/src/components/RollbarProject/RollbarProject.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import React from 'react'; import { Entity } from '@backstage/catalog-model'; +import React from 'react'; import { useTopActiveItems } from '../../hooks/useTopActiveItems'; import { RollbarTopItemsTable } from '../RollbarTopItemsTable/RollbarTopItemsTable'; diff --git a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx deleted file mode 100644 index a1c54dfd5a..0000000000 --- a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from 'react'; -import { - ApiProvider, - ApiRegistry, - ConfigApi, - configApiRef, -} from '@backstage/core'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { render } from '@testing-library/react'; -import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi'; -import { RollbarTopActiveItem } from '../../api/types'; -import { RollbarProjectPage } from './RollbarProjectPage'; -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; - -describe('RollbarProjectPage component', () => { - const items: RollbarTopActiveItem[] = [ - { - item: { - id: 9898989, - counter: 1234, - environment: 'production', - framework: 2, - lastOccurrenceTimestamp: new Date().getTime() / 1000, - level: 50, - occurrences: 100, - projectId: 12345, - title: 'error occurred', - uniqueOccurrences: 10, - }, - counts: [10, 10, 10, 10, 10, 50], - }, - ]; - const rollbarApi: Partial = { - getTopActiveItems: () => Promise.resolve(items), - }; - const config: Partial = { - getString: () => 'foo', - getOptionalString: () => 'foo', - }; - - const renderWrapped = (children: React.ReactNode) => - render( - wrapInTestApp( - ) as CatalogApi, - ], - ])} - > - {children} - , - ), - ); - - it('should render rollbar project page', async () => { - const rendered = renderWrapped(); - expect(rendered.getByText(/Rollbar/)).toBeInTheDocument(); - }); -}); diff --git a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx deleted file mode 100644 index 5563a6d7a0..0000000000 --- a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Content, Header, HeaderLabel, Page } from '@backstage/core'; -import { useCatalogEntity } from '../../hooks/useCatalogEntity'; -import { RollbarProject } from '../RollbarProject/RollbarProject'; - -export const RollbarProjectPage = () => { - const { entity } = useCatalogEntity(); - - return ( - -
- - -
- - {entity ? : 'Loading'} - -
- ); -}; diff --git a/plugins/rollbar/src/components/RollbarProjectTable/RollbarProjectTable.tsx b/plugins/rollbar/src/components/RollbarProjectTable/RollbarProjectTable.tsx deleted file mode 100644 index 5b3631ade9..0000000000 --- a/plugins/rollbar/src/components/RollbarProjectTable/RollbarProjectTable.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Link as RouterLink, generatePath } from 'react-router-dom'; -import { Table, TableColumn } from '@backstage/core'; -import { Entity } from '@backstage/catalog-model'; -import { Link } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import { entityRouteRef } from '../../routes'; - -const columns: TableColumn[] = [ - { - title: 'Name', - field: 'metadata.name', - type: 'string', - highlight: true, - render: (entity: any) => ( - - {entity.metadata.name} - - ), - }, - { - title: 'Description', - field: 'metadata.description', - }, -]; - -type Props = { - entities: Entity[]; - loading: boolean; - error?: any; -}; - -export const RollbarProjectTable = ({ entities, loading, error }: Props) => { - if (error) { - return ( -
- - Error encountered while fetching rollbar projects. {error.toString()} - -
- ); - } - - return ( -
- ); -}; diff --git a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx index dc177265cd..2916fefbcf 100644 --- a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx +++ b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx @@ -17,8 +17,8 @@ import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import * as React from 'react'; -import { RollbarTopItemsTable } from './RollbarTopItemsTable'; import { RollbarTopActiveItem } from '../../api/types'; +import { RollbarTopItemsTable } from './RollbarTopItemsTable'; const items: RollbarTopActiveItem[] = [ { diff --git a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx index a04155a0c3..c22f813dd9 100644 --- a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx +++ b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React from 'react'; import { Table, TableColumn } from '@backstage/core'; import { Box, Link, Typography } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; +import React from 'react'; import { RollbarFrameworkId, RollbarLevel, diff --git a/plugins/rollbar/src/components/Router.tsx b/plugins/rollbar/src/components/Router.tsx index f0756cb02a..7b95a4ec7c 100644 --- a/plugins/rollbar/src/components/Router.tsx +++ b/plugins/rollbar/src/components/Router.tsx @@ -14,29 +14,36 @@ * limitations under the License. */ -import React from 'react'; -import { Routes, Route } from 'react-router'; import { Entity } from '@backstage/catalog-model'; import { MissingAnnotationEmptyState } from '@backstage/core'; -import { catalogRouteRef } from '../routes'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { Route, Routes } from 'react-router'; import { ROLLBAR_ANNOTATION } from '../constants'; +import { rootRouteRef } from '../plugin'; import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar'; export const isPluginApplicableToEntity = (entity: Entity) => Boolean(entity.metadata.annotations?.[ROLLBAR_ANNOTATION]); type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; }; -export const Router = ({ entity }: Props) => - !isPluginApplicableToEntity(entity) ? ( - - ) : ( +export const Router = (_props: Props) => { + const { entity } = useEntity(); + + if (!isPluginApplicableToEntity(entity)) { + ; + } + + return ( } /> ); +}; diff --git a/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx b/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx index ac1a328521..e944a53551 100644 --- a/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx +++ b/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import React from 'react'; import { TrendGraph } from './TrendGraph'; describe('TrendGraph component', () => { diff --git a/plugins/rollbar/src/hooks/useCatalogEntity.ts b/plugins/rollbar/src/hooks/useCatalogEntity.ts index 172d057738..e39a7a75f7 100644 --- a/plugins/rollbar/src/hooks/useCatalogEntity.ts +++ b/plugins/rollbar/src/hooks/useCatalogEntity.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { useAsync } from 'react-use'; import { useApi } from '@backstage/core'; import { catalogApiRef, useEntityCompoundName, -} from '@backstage/plugin-catalog'; +} from '@backstage/plugin-catalog-react'; +import { useAsync } from 'react-use'; export function useCatalogEntity() { const catalogApi = useApi(catalogApiRef); diff --git a/plugins/rollbar/src/hooks/useRollbarEntities.ts b/plugins/rollbar/src/hooks/useRollbarEntities.ts index 5979c5832a..3f3415b176 100644 --- a/plugins/rollbar/src/hooks/useRollbarEntities.ts +++ b/plugins/rollbar/src/hooks/useRollbarEntities.ts @@ -14,9 +14,9 @@ * limitations under the License. */ +import { configApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; -import { useApi, configApiRef } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; import { ROLLBAR_ANNOTATION } from '../constants'; export function useRollbarEntities() { diff --git a/plugins/rollbar/src/index.ts b/plugins/rollbar/src/index.ts index 2a31c91a13..def2d99658 100644 --- a/plugins/rollbar/src/index.ts +++ b/plugins/rollbar/src/index.ts @@ -14,10 +14,16 @@ * limitations under the License. */ -export { plugin } from './plugin'; export * from './api'; -export * from './routes'; -export { Router } from './components/Router'; -export { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage'; export { EntityPageRollbar } from './components/EntityPageRollbar/EntityPageRollbar'; +export { + isPluginApplicableToEntity, + isPluginApplicableToEntity as isRollbarAvailable, + Router, +} from './components/Router'; export { ROLLBAR_ANNOTATION } from './constants'; +export { + EntityRollbarContent, + rollbarPlugin as plugin, + rollbarPlugin, +} from './plugin'; diff --git a/plugins/rollbar/src/plugin.test.ts b/plugins/rollbar/src/plugin.test.ts index 44c2168c78..7ef23b019a 100644 --- a/plugins/rollbar/src/plugin.test.ts +++ b/plugins/rollbar/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { rollbarPlugin } from './plugin'; describe('rollbar', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(rollbarPlugin).toBeDefined(); }); }); diff --git a/plugins/rollbar/src/plugin.ts b/plugins/rollbar/src/plugin.ts index c3ca6173ff..216713b3fc 100644 --- a/plugins/rollbar/src/plugin.ts +++ b/plugins/rollbar/src/plugin.ts @@ -15,17 +15,21 @@ */ import { - createPlugin, createApiFactory, + createPlugin, + createRoutableExtension, + createRouteRef, discoveryApiRef, } from '@backstage/core'; -import { rootRouteRef, entityRouteRef } from './routes'; -import { RollbarHome } from './components/RollbarHome/RollbarHome'; -import { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage'; import { rollbarApiRef } from './api/RollbarApi'; import { RollbarClient } from './api/RollbarClient'; -export const plugin = createPlugin({ +export const rootRouteRef = createRouteRef({ + path: '', + title: 'Rollbar', +}); + +export const rollbarPlugin = createPlugin({ id: 'rollbar', apis: [ createApiFactory({ @@ -34,8 +38,14 @@ export const plugin = createPlugin({ factory: ({ discoveryApi }) => new RollbarClient({ discoveryApi }), }), ], - register({ router }) { - router.addRoute(rootRouteRef, RollbarHome); - router.addRoute(entityRouteRef, RollbarProjectPage); + routes: { + entityContent: rootRouteRef, }, }); + +export const EntityRollbarContent = rollbarPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index bcf43cf229..353e2321cf 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/plugin-scaffolder-backend +## 0.5.2 + +### Patch Changes + +- 26a3a6cf0: Honor the branch ref in the url when cloning. + + This fixes a bug in the scaffolder prepare stage where a non-default branch + was specified in the scaffolder URL but the default branch was cloned. + For example, even though the `other` branch is specified in this example, the + `master` branch was actually cloned: + + ```yaml + catalog: + locations: + - type: url + target: https://github.com/backstage/backstage/blob/other/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml + ``` + + This also fixes a 404 in the prepare stage for GitLab URLs. + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + - @backstage/integration@0.3.1 + ## 0.5.1 ### Patch Changes diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 28e2515471..3b31b4f951 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -19,9 +19,17 @@ export interface Config { scaffolder?: { github?: { [key: string]: string; + /** + * The visibility to set on created repositories. + */ + visiblity?: 'public' | 'internal' | 'private'; }; gitlab?: { api: { [key: string]: string }; + /** + * The visibility to set on created repositories. + */ + visiblity?: 'public' | 'internal' | 'private'; }; azure?: { baseUrl: string; @@ -29,6 +37,10 @@ export interface Config { }; bitbucket?: { api: { [key: string]: string }; + /** + * The visibility to set on created repositories. + */ + visiblity?: 'public' | 'private'; }; }; } diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index e118a1d7f9..7af9f567e3 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.5.1", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.3.0", + "@backstage/integration": "^0.3.1", "@gitbeaker/core": "^28.0.2", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.0.12", @@ -59,10 +59,12 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@types/fs-extra": "^9.0.1", + "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", + "mock-fs": "^4.13.0", "supertest": "^4.0.2", "yaml": "^1.10.0", "msw": "^0.21.2" diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index eaae937337..c782037ec8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -13,11 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { JobProcessor } from './processor'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { StageInput } from './types'; -import { RequiredTemplateValues } from '../stages/templater'; import parseGitUrl from 'git-url-parse'; +import mockFs from 'mock-fs'; +import os from 'os'; +import { RequiredTemplateValues } from '../stages/templater'; +import { makeLogStream } from './logger'; +import { JobProcessor } from './processor'; +import { StageInput } from './types'; describe('JobProcessor', () => { const mockEntity: TemplateEntityV1alpha1 = { @@ -31,7 +34,7 @@ describe('JobProcessor', () => { name: 'graphql-starter', title: 'GraphQL Service', description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', + 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', @@ -68,9 +71,30 @@ describe('JobProcessor', () => { }, }; + const workingDirectory = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + + // NOTE(freben): Without this line, mock-fs makes winston/logform break. + // There are a number of reported issues with logform and its use of dynamic + // strings for imports. It confuses webpack. The basic fix is to trigger + // those imports before mock-fs runs. I wanted to add a mock dir + // 'node_modules': mockFs.passthrough(), but that doesn't seem to be a thing + // in mock-fs 4. + // Probable REAL fix: https://github.com/winstonjs/logform/pull/117 + makeLogStream({}); + + beforeEach(() => { + mockFs({ + [workingDirectory]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + describe('create', () => { it('creates should create a new job with a unique id', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -84,7 +108,7 @@ describe('JobProcessor', () => { }); it('should setup the correct context for the job', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -97,7 +121,7 @@ describe('JobProcessor', () => { }); it('should set the status as pending', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -120,7 +144,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -139,12 +163,12 @@ describe('JobProcessor', () => { describe('get', () => { it('return undefined for when the job does not exist', () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); expect(processor.get('123')).not.toBeDefined(); }); it('should return the exact same instance of the job when one is created', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -154,9 +178,10 @@ describe('JobProcessor', () => { expect(processor.get(job.id)).toBe(job); }); }); + describe('process', () => { it('throws an error when the status of the job is not in pending state', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -182,7 +207,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -208,7 +233,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -244,7 +269,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -283,7 +308,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index b932db7fe1..425fc07946 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -13,13 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import os from 'os'; +import fs from 'fs-extra'; import { Processor, Job, StageContext, StageInput } from './types'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import * as uuid from 'uuid'; import Docker from 'dockerode'; +import path from 'path'; import { TemplaterValues, TemplaterBase } from '../stages/templater'; import { PreparerBuilder } from '../stages/prepare'; import { makeLogStream } from './logger'; +import { Logger } from 'winston'; +import { Config } from '@backstage/config'; export type JobProcessorArguments = { preparers: PreparerBuilder; @@ -33,7 +39,45 @@ export type JobAndDirectoryTuple = { }; export class JobProcessor implements Processor { - private jobs = new Map(); + private readonly workingDirectory: string; + private readonly jobs: Map; + + static async fromConfig({ + config, + logger, + }: { + config: Config; + logger: Logger; + }) { + let workingDirectory: string; + if (config.has('backend.workingDirectory')) { + workingDirectory = config.getString('backend.workingDirectory'); + try { + // Check if working directory exists and is writable + await fs.promises.access( + workingDirectory, + fs.constants.F_OK | fs.constants.W_OK, + ); + logger.info(`using working directory: ${workingDirectory}`); + } catch (err) { + logger.error( + `working directory ${workingDirectory} ${ + err.code === 'ENOENT' ? 'does not exist' : 'is not writable' + }`, + ); + throw err; + } + } else { + workingDirectory = os.tmpdir(); + } + + return new JobProcessor(workingDirectory); + } + + constructor(workingDirectory: string) { + this.workingDirectory = workingDirectory; + this.jobs = new Map(); + } create({ entity, @@ -52,6 +96,7 @@ export class JobProcessor implements Processor { values, logger, logStream: stream, + workspacePath: path.join(this.workingDirectory, id), }; const job: Job = { @@ -80,11 +125,13 @@ export class JobProcessor implements Processor { throw new Error("Job is not in a 'PENDING' state"); } + await fs.mkdir(job.context.workspacePath); + job.status = 'STARTED'; try { for (const stage of job.stages) { - // Create a logger for each stage so we can create seperate + // Create a logger for each stage so we can create separate // Streams for each step. const { logger, log, stream } = makeLogStream({ id: job.id, @@ -104,8 +151,8 @@ export class JobProcessor implements Processor { logStream: stream, }); - // If the handler returns something, then let's merge this onto the ontext - // For the next stage to use as it might be relevant. + // If the handler returns something, then let's merge this onto the + // context for the next stage to use as it might be relevant. if (handlerResponse) { job.context = { ...job.context, @@ -134,6 +181,8 @@ export class JobProcessor implements Processor { // If something went wrong, fail the job, and set the error property on the job. job.error = { name: error.name, message: error.message }; job.status = 'FAILED'; + } finally { + await fs.remove(job.context.workspacePath); } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts index 4d0369708c..e45e6c8b96 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts @@ -26,6 +26,7 @@ export type StageContext = { entity: TemplateEntityV1alpha1; logger: Logger; logStream: Writable; + workspacePath: string; } & T; export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts index be6d215e5b..5c809d229e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { parseLocationAnnotation } from './helpers'; +import { parseLocationAnnotation, joinGitUrlPath } from './helpers'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, @@ -73,7 +73,7 @@ describe('Helpers', () => { metadata: { annotations: { [LOCATION_ANNOTATION]: - ':https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', + ':https://github.com/o/r/blob/master/template.yaml', }, name: 'graphql-starter', title: 'GraphQL Service', @@ -250,4 +250,76 @@ describe('Helpers', () => { }); }); }); + + describe('joinGitUrlPath', () => { + it.each([ + [ + 'https://github.com/o/r/blob/master/template.yaml', + 'template', + 'https://github.com/o/r/blob/master/template', + ], + [ + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml', + undefined, + 'https://dev.azure.com/o/p/_git/template-repo?path=%2F', + ], + [ + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml', + 'a', + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa', + ], + [ + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Ftemplate.yaml', + 'b', + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Fb', + ], + [ + 'https://github.com/o/r/blob/master/template.yaml', + undefined, + 'https://github.com/o/r/blob/master', + ], + [ + 'https://github.com/o/r/blob/master/template.yaml', + 'template', + 'https://github.com/o/r/blob/master/template', + ], + [ + 'https://github.com/o/r/blob/master/templates/graphql-starter/template.yaml', + 'template', + 'https://github.com/o/r/blob/master/templates/graphql-starter/template', + ], + [ + 'https://gitlab.com/o/r/-/blob/master/template.yaml', + undefined, + 'https://gitlab.com/o/r/-/blob/master', + ], + [ + 'https://gitlab.com/o/r/-/blob/master/template.yaml', + 'template', + 'https://gitlab.com/o/r/-/blob/master/template', + ], + [ + 'https://gitlab.com/o/r/-/blob/master/a/b/c/template.yaml', + '../../c', + 'https://gitlab.com/o/r/-/blob/master/a/c', + ], + [ + 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', + undefined, + 'https://bitbucket.org/p/r/src/master/a/b', + ], + [ + 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', + 'c', + 'https://bitbucket.org/p/r/src/master/a/b/c', + ], + [ + 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', + '../c', + 'https://bitbucket.org/p/r/src/master/a/c', + ], + ])('should join git url %s with path %s', (url, path, result) => { + expect(joinGitUrlPath(url, path)).toBe(result); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts index f8d8229298..784e72ca87 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { posix as posixPath } from 'path'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, @@ -53,3 +55,20 @@ export const parseLocationAnnotation = ( location, }; }; + +export function joinGitUrlPath(repoUrl: string, path?: string): string { + const parsed = new URL(repoUrl); + + if (parsed.hostname.endsWith('azure.com')) { + const templatePath = posixPath.normalize( + posixPath.join( + posixPath.dirname(parsed.searchParams.get('path') || '/'), + path || '.', + ), + ); + parsed.searchParams.set('path', templatePath); + return parsed.toString(); + } + + return new URL(path || '.', repoUrl).toString().replace(/\/$/, ''); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 8dd72c35e9..6637a68e4b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -14,19 +14,14 @@ * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; import { AzurePreparer } from './azure'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; +jest.mock('fs-extra'); + describe('AzurePreparer', () => { const mockGitClient = { clone: jest.fn(), @@ -36,119 +31,86 @@ describe('AzurePreparer', () => { jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); - let mockEntity: TemplateEntityV1alpha1; - beforeEach(() => { - jest.clearAllMocks(); - mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'url:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; - }); - const preparer = AzurePreparer.fromConfig({ host: 'dev.azure.com', token: 'fake-azure-token', }); - // TODO(blam): Here's a test that will fail when the deprecation is complete - it('calls the clone command with deprecated token', async () => { - await preparer.prepare(mockEntity, { logger }); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - password: 'fake-azure-token', - username: 'notempty', - }); - }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); + const prepareOptions = { + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + workspacePath, + logger, + }; it('calls the clone command with token from integrations config', async () => { - await preparer.prepare(mockEntity, { logger }); - + await preparer.prepare(prepareOptions); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, password: 'fake-azure-token', username: 'notempty', }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it('calls the clone command with the correct arguments for a repository', async () => { - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: expect.any(String), + dir: checkoutPath, + }); + }); + + it('calls the clone command with the correct arguments for a repository with a specified branch', async () => { + await preparer.prepare({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml&version=GBmaster', + logger, + workspacePath, + }); + + expect(mockGitClient.clone).toHaveBeenCalledWith({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + dir: checkoutPath, + ref: 'master', }); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - delete mockEntity.spec.path; - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + workspacePath, + logger, + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: expect.any(String), + dir: checkoutPath, }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); }); - it('return the temp directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), + it('moves the template from path if it is specified', async () => { + await preparer.prepare({ + url: `https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=${encodeURIComponent( + './subdir', + )}`, + logger, + workspacePath, }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - - const response = await preparer.prepare(mockEntity, { - workingDirectory: '/workDir', - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + expect(fs.move).toHaveBeenCalledWith( + path.resolve(checkoutPath, 'subdir'), + templatePath, ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts index 8b204fa799..9dc29be890 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -13,11 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import parseGitUrl from 'git-url-parse'; @@ -30,25 +27,13 @@ export class AzurePreparer implements PreparerBase { constructor(private readonly config: { token?: string }) {} - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { location } = parseLocationAnnotation(template); - const workingDirectory = opts.workingDirectory ?? os.tmpdir(); - const logger = opts.logger; - - const templateId = template.metadata.name; - - const parsedGitLocation = parseGitUrl(location); - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), - ); - - const templateDirectory = path.join( - `${path.dirname(parsedGitLocation.filepath)}`, - template.spec.path ?? '.', + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath ?? '', ); // Username can be anything but the empty string according to: @@ -62,10 +47,17 @@ export class AzurePreparer implements PreparerBase { : Git.fromAuth({ logger }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + ref: parsedGitUrl.ref, + dir: checkoutPath, }); - return path.resolve(tempDir, templateDirectory); + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index ff74fffdd7..faf7f0f6ff 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -14,21 +14,15 @@ * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; import { BitbucketPreparer } from './bitbucket'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; +import path from 'path'; +import os from 'os'; + +jest.mock('fs-extra'); describe('BitbucketPreparer', () => { - let mockEntity: TemplateEntityV1alpha1; const logger = getVoidLogger(); const mockGitClient = { clone: jest.fn(), @@ -38,44 +32,6 @@ describe('BitbucketPreparer', () => { beforeEach(() => { jest.clearAllMocks(); - mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'bitbucket:https://bitbucket.org/backstage-project/backstage-repo', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; }); const preparer = BitbucketPreparer.fromConfig({ @@ -84,21 +40,34 @@ describe('BitbucketPreparer', () => { appPassword: 'fake-password', }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); + + const prepareOptions = { + url: 'https://bitbucket.org/backstage-project/backstage-repo', + logger, + workspacePath, + }; + it('calls the clone command with the correct arguments for a repository', async () => { - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: expect.any(String), + dir: checkoutPath, + ref: expect.any(String), }); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => { - const preparer = BitbucketPreparer.fromConfig({ + const preparerCheck = BitbucketPreparer.fromConfig({ host: 'bitbucket.org', username: 'fake-user', appPassword: 'fake-password', }); - await preparer.prepare(mockEntity, { logger }); + await preparerCheck.prepare(prepareOptions); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, @@ -108,32 +77,32 @@ describe('BitbucketPreparer', () => { }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - delete mockEntity.spec.path; - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: expect.any(String), + dir: checkoutPath, + ref: expect.any(String), }); }); - it('return the temp directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), + it('moves a template subdirectory to checkout if specified', async () => { + await preparer.prepare({ + url: 'https://bitbucket.org/foo/bar/src/master/1/2/3', + logger, + workspacePath, }); - - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, + expect(fs.move).toHaveBeenCalledWith( + path.resolve(checkoutPath, '1', '2', '3'), + templatePath, ); }); it('calls the clone command with with token for auth method', async () => { - const preparer = BitbucketPreparer.fromConfig({ + const preparerCheck = BitbucketPreparer.fromConfig({ host: 'bitbucket.org', token: 'fake-token', }); - - await preparer.prepare(mockEntity, { logger }); + await preparerCheck.prepare(prepareOptions); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, @@ -141,16 +110,4 @@ describe('BitbucketPreparer', () => { password: 'fake-token', }); }); - - it('return the working directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - workingDirectory: '/workDir', - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); - }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index 5020a5f658..d473656863 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -13,11 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import { BitbucketIntegrationConfig } from '@backstage/integration'; @@ -40,43 +37,30 @@ export class BitbucketPreparer implements PreparerBase { }, ) {} - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { location } = parseLocationAnnotation(template); - const workingDirectory = opts.workingDirectory ?? os.tmpdir(); - const logger = opts.logger; - const templateId = template.metadata.name; - - const repo = parseGitUrl(location); - const repositoryCheckoutUrl = repo.toString('https'); - - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); - const templateDirectory = path.join( - `${path.dirname(repo.filepath)}`, - template.spec.path ?? '.', - ); - - const checkoutLocation = path.resolve(tempDir, templateDirectory); - - const auth = this.getAuth(); - const git = auth - ? Git.fromAuth({ - ...auth, - logger, - }) - : Git.fromAuth({ logger }); + const git = Git.fromAuth({ logger, ...this.getAuth() }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, + ref: parsedGitUrl.ref, }); - return checkoutLocation; + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } private getAuth(): { username: string; password: string } | undefined { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts index 2b328f420b..0c94edce42 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts @@ -14,57 +14,44 @@ * limitations under the License. */ -import os from 'os'; -import fs from 'fs-extra'; -import YAML from 'yaml'; -import { FilePreparer } from './file'; -import path from 'path'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger } from '@backstage/backend-common'; +import fs from 'fs-extra'; +import { FilePreparer } from './file'; +import os from 'os'; +import path from 'path'; -const setupTest = async (fixturePath: string) => { - const locationForTemplateYaml = path.resolve( - path.dirname( - require.resolve('@backstage/plugin-scaffolder-backend/package'), - ), - 'fixtures', - fixturePath, - ); - - const [parsedDocument] = YAML.parseAllDocuments( - await fs.readFile(locationForTemplateYaml, 'utf-8'), - ); - - const template: TemplateEntityV1alpha1 = parsedDocument.toJSON(); - template.metadata.annotations = { - [LOCATION_ANNOTATION]: `file:${locationForTemplateYaml}`, - }; - - const filePreparer = new FilePreparer(); - const resultDir = await filePreparer.prepare(template, { - logger: getVoidLogger(), - workingDirectory: os.tmpdir(), - }); - - return { filePreparer, template, resultDir }; -}; +jest.mock('fs-extra'); describe('File preparer', () => { - it('excludes the yaml file from the temp folder', async () => { - const { resultDir } = await setupTest('test-simple-template/template.yaml'); - expect(fs.existsSync(`${resultDir}/template.yaml`)).toBe(false); - }); + it('prepares templates from a file path', async () => { + const logger = getVoidLogger(); + const preparer = new FilePreparer(); + const root = os.platform() === 'win32' ? 'C:\\' : '/'; + const workspacePath = path.join(root, 'tmp'); + const checkoutPath = path.resolve(workspacePath, 'checkout'); - it('resolves relative path from the template', async () => { - const { resultDir } = await setupTest('test-simple-template/template.yaml'); - expect(fs.existsSync(`${resultDir}/expected_file.ts`)).toBe(true); - }); + await preparer.prepare({ + url: `file:///${root}path/to/template`, + logger, + workspacePath, + }); + expect(fs.copy).toHaveBeenCalledWith( + path.join(root, 'path', 'to', 'template'), + checkoutPath, + { + recursive: true, + }, + ); + expect(fs.ensureDir).toHaveBeenCalledWith(checkoutPath); - it('resolves relative path from the nested template', async () => { - const { resultDir } = await setupTest('test-nested-template/template.yaml'); - expect(fs.existsSync(`${resultDir}/expected_file.ts`)).toBe(true); + await expect( + preparer.prepare({ + url: 'file://not/full/path', + logger, + workspacePath, + }), + ).rejects.toThrow( + "Wrong location protocol, should be 'file', file://not/full/path", + ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts index 08129c7397..4d49ba222e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts @@ -13,43 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; +import { fileURLToPath } from 'url'; import { InputError } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; export class FilePreparer implements PreparerBase { - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - - if (protocol !== 'file') { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'file'`, - ); + async prepare({ url, workspacePath }: PreparerOptions) { + if (!url.startsWith('file:///')) { + throw new InputError(`Wrong location protocol, should be 'file', ${url}`); } - const templateId = template.metadata.name; - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), - ); + const checkoutDir = path.join(workspacePath, 'checkout'); + await fs.ensureDir(checkoutDir); - const parentDirectory = path.resolve( - path.dirname(location), - template.spec.path ?? '.', - ); + const templatePath = fileURLToPath(url); - await fs.copy(parentDirectory, tempDir, { - filter: src => src !== location, + await fs.copy(templatePath, checkoutDir, { recursive: true, }); - - return tempDir; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index b43545f5fc..301f015c1e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -14,21 +14,19 @@ * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; import { GithubPreparer } from './github'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; +jest.mock('fs-extra'); + describe('GitHubPreparer', () => { - let mockEntity: TemplateEntityV1alpha1; + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); + const mockGitClient = { clone: jest.fn(), }; @@ -38,44 +36,6 @@ describe('GitHubPreparer', () => { beforeEach(() => { jest.clearAllMocks(); - mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; }); const preparer = GithubPreparer.fromConfig({ @@ -84,58 +44,49 @@ describe('GitHubPreparer', () => { }); it('calls the clone command with the correct arguments for a repository', async () => { - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare({ + url: + 'https://github.com/benjdlambert/backstage-graphql-template/blob/master/templates/graphql-starter/template', + logger, + workspacePath, + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + dir: checkoutPath, + ref: expect.any(String), }); + expect(fs.move).toHaveBeenCalledWith( + path.resolve(checkoutPath, 'templates', 'graphql-starter', 'template'), + templatePath, + ); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - delete mockEntity.spec.path; - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare({ + url: + 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', + logger, + workspacePath, + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + dir: checkoutPath, + ref: 'master', }); - }); - - it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = GithubPreparer.fromConfig({ - host: 'github.com', - token: 'fake-token', - }); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = GithubPreparer.fromConfig({ - host: 'github.com', - token: 'fake-token', - }); - - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - workingDirectory: '/workDir', - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it('calls the clone command with token', async () => { - await preparer.prepare(mockEntity, { logger }); + await preparer.prepare({ + url: + 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', + logger, + workspacePath, + }); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index cfeff454e7..533aa8e005 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -13,11 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import parseGitUrl from 'git-url-parse'; @@ -30,29 +27,15 @@ export class GithubPreparer implements PreparerBase { constructor(private readonly config: { token?: string }) {} - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { location } = parseLocationAnnotation(template); - const workingDirectory = opts.workingDirectory ?? os.tmpdir(); - const logger = opts.logger; - - const templateId = template.metadata.name; - - const parsedGitLocation = parseGitUrl(location); - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); - const templateDirectory = path.join( - `${path.dirname(parsedGitLocation.filepath)}`, - template.spec.path ?? '.', - ); - - const checkoutLocation = path.resolve(tempDir, templateDirectory); - const git = this.config.token ? Git.fromAuth({ username: this.config.token, @@ -62,10 +45,17 @@ export class GithubPreparer implements PreparerBase { : Git.fromAuth({ logger }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, + ref: parsedGitUrl.ref, }); - return checkoutLocation; + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 19cbf793e4..6ed4651e4f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -13,60 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; import { GitlabPreparer } from './gitlab'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; -const mockTemplate = (): TemplateEntityV1alpha1 => ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'url:https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/template.yaml', - }, - name: 'graphql-starter', - title: 'GraphQL Service', - description: - 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, -}); +jest.mock('fs-extra'); describe('GitLabPreparer', () => { - let mockEntity: TemplateEntityV1alpha1; + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); + const mockGitClient = { clone: jest.fn(), }; @@ -81,61 +40,41 @@ describe('GitLabPreparer', () => { host: 'gitlab.com', token: 'fake-token', }); - it(`calls the clone command with the correct arguments for a repository`, async () => { - mockEntity = mockTemplate(); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + it(`calls the clone command with the correct arguments for a repository`, async () => { + await preparer.prepare({ + url: + 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master', + logger, + workspacePath, + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + url: 'https://gitlab.com/benjdlambert/backstage-graphql-template.git', + dir: checkoutPath, + ref: expect.any(String), }); - }); - - it(`calls the clone command with the correct arguments if an access token is provided in integrations for a repository`, async () => { - mockEntity = mockTemplate(); - - await preparer.prepare(mockEntity, { logger }); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, username: 'oauth2', password: 'fake-token', }); + + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); - it(`calls the clone command with the correct arguments for a repository when no path is provided`, async () => { - mockEntity = mockTemplate(); - delete mockEntity.spec.path; - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + it(`clones the template from a sub directory if specified`, async () => { + await preparer.prepare({ + url: + 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/1/2/3', + logger, + workspacePath, }); - }); - - it(`return the temp directory with the path to the folder if it is specified`, async () => { - mockEntity = mockTemplate(); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - workingDirectory: '/workDir', - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + expect(fs.move).toHaveBeenCalledWith( + path.resolve(checkoutPath, '1', '2', '3'), + templatePath, ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index 1fca2ff0f9..fa5c8c1325 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -14,13 +14,10 @@ * limitations under the License. */ import { Git } from '@backstage/backend-common'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { GitLabIntegrationConfig } from '@backstage/integration'; import fs from 'fs-extra'; import parseGitUrl from 'git-url-parse'; -import os from 'os'; import path from 'path'; -import { parseLocationAnnotation } from '../helpers'; import { PreparerBase, PreparerOptions } from './types'; export class GitlabPreparer implements PreparerBase { @@ -30,26 +27,15 @@ export class GitlabPreparer implements PreparerBase { constructor(private readonly config: { token?: string }) {} - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { location } = parseLocationAnnotation(template); - const logger = opts.logger; - const workingDirectory = opts.workingDirectory ?? os.tmpdir(); - - const templateId = template.metadata.name; - - const parsedGitLocation = parseGitUrl(location); - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), - ); - - const templateDirectory = path.join( - `${path.dirname(parsedGitLocation.filepath)}`, - template.spec.path ?? '.', + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); + parsedGitUrl.git_suffix = true; const git = this.config.token ? Git.fromAuth({ @@ -60,10 +46,17 @@ export class GitlabPreparer implements PreparerBase { : Git.fromAuth({ logger }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, + ref: parsedGitUrl.ref, }); - return path.resolve(tempDir, templateDirectory); + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts index 17ffea5557..6a2b7808f1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts @@ -13,24 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; export type PreparerOptions = { - workingDirectory?: string; + /** + * Full URL to the directory containg template data + */ + url: string; + /** + * The workspace path that will eventually be the the root of the new repo + */ + workspacePath: string; logger: Logger; }; export interface PreparerBase { /** - * Given an Entity definition from the Service Catalog, go and prepare a directory - * with contents from the remote location in temporary storage and return the path - * @param template The template entity from the Service Catalog + * Prepare a directory with contents from the remote location */ - prepare( - template: TemplateEntityV1alpha1, - opts?: PreparerOptions, - ): Promise; + prepare(opts: PreparerOptions): Promise; } export type PreparerBuilder = { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts index e4410e9838..1ce47e04dd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts @@ -20,6 +20,8 @@ jest.mock('azure-devops-node-api', () => ({ getPersonalAccessTokenHandler: jest.fn().mockReturnValue(() => {}), })); +import os from 'os'; +import { resolve } from 'path'; import { AzurePublisher } from './azure'; import { WebApi } from 'azure-devops-node-api'; import * as helpers from './helpers'; @@ -28,6 +30,9 @@ import { getVoidLogger } from '@backstage/backend-common'; describe('Azure Publisher', () => { const logger = getVoidLogger(); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); + describe('publish: createRemoteInAzure', () => { it('should use azure-devops-node-api to create a repo in the given project', async () => { const mockGitClient = { @@ -53,7 +58,7 @@ describe('Azure Publisher', () => { storePath: 'https://dev.azure.com/organisation/project/_git/repo', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -74,7 +79,7 @@ describe('Azure Publisher', () => { 'project', ); expect(helpers.initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', auth: { username: 'notempty', password: 'fake-azure-token' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts index b2ac486d77..4190ba8abb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts @@ -21,6 +21,7 @@ import { initRepoAndPush } from './helpers'; import { AzureIntegrationConfig } from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; +import path from 'path'; export class AzurePublisher implements PublisherBase { static async fromConfig(config: AzureIntegrationConfig) { @@ -34,7 +35,7 @@ export class AzurePublisher implements PublisherBase { async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { const { owner, name, organization, resource } = parseGitUrl( @@ -56,7 +57,7 @@ export class AzurePublisher implements PublisherBase { const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`; await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl, auth: { username: 'notempty', diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts index f75eea939a..4cf5e83ba3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -16,6 +16,8 @@ jest.mock('./helpers'); +import os from 'os'; +import { resolve } from 'path'; import { BitbucketPublisher } from './bitbucket'; import { initRepoAndPush } from './helpers'; import { getVoidLogger } from '@backstage/backend-common'; @@ -32,6 +34,9 @@ describe('Bitbucket Publisher', () => { jest.clearAllMocks(); }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); + describe('publish: createRemoteInBitbucketCloud', () => { it('should create repo in bitbucket cloud', async () => { server.use( @@ -58,18 +63,21 @@ describe('Bitbucket Publisher', () => { ), ); - const publisher = await BitbucketPublisher.fromConfig({ - host: 'bitbucket.org', - username: 'fake-user', - appPassword: 'fake-token', - }); + const publisher = await BitbucketPublisher.fromConfig( + { + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-token', + }, + { repoVisibility: 'private' }, + ); const result = await publisher.publish({ values: { storePath: 'https://bitbucket.org/project/repo', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger: logger, }); @@ -80,7 +88,7 @@ describe('Bitbucket Publisher', () => { }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://bitbucket.org/project/repo', auth: { username: 'fake-user', password: 'fake-token' }, logger: logger, @@ -117,17 +125,20 @@ describe('Bitbucket Publisher', () => { ), ); - const publisher = await BitbucketPublisher.fromConfig({ - host: 'bitbucket.mycompany.com', - token: 'fake-token', - }); + const publisher = await BitbucketPublisher.fromConfig( + { + host: 'bitbucket.mycompany.com', + token: 'fake-token', + }, + { repoVisibility: 'private' }, + ); const result = await publisher.publish({ values: { storePath: 'https://bitbucket.mycompany.com/project/repo', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger: logger, }); @@ -138,7 +149,7 @@ describe('Bitbucket Publisher', () => { }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', auth: { username: 'x-token-auth', password: 'fake-token' }, logger: logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index a0b867507d..373920c005 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -19,18 +19,25 @@ import { initRepoAndPush } from './helpers'; import fetch from 'cross-fetch'; import { BitbucketIntegrationConfig } from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; +import path from 'path'; + +export type RepoVisibilityOptions = 'private' | 'public'; // TODO(blam): We should probably start to use a bitbucket client here that we can change // the baseURL to point at on-prem or public bitbucket versions like we do for // github and ghe. There's to much logic and not enough types here for us to say that this way is better than using // a supported bitbucket client if one exists. export class BitbucketPublisher implements PublisherBase { - static async fromConfig(config: BitbucketIntegrationConfig) { + static async fromConfig( + config: BitbucketIntegrationConfig, + { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, + ) { return new BitbucketPublisher({ host: config.host, token: config.token, appPassword: config.appPassword, username: config.username, + repoVisibility, }); } @@ -40,12 +47,13 @@ export class BitbucketPublisher implements PublisherBase { token?: string; appPassword?: string; username?: string; + repoVisibility: RepoVisibilityOptions; }, ) {} async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { const { owner: project, name } = parseGitUrl(values.storePath); @@ -58,7 +66,7 @@ export class BitbucketPublisher implements PublisherBase { }); await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl: result.remoteUrl, auth: { username: this.config.username ? this.config.username : 'x-token-auth', @@ -100,6 +108,7 @@ export class BitbucketPublisher implements PublisherBase { body: JSON.stringify({ scm: 'git', description: description, + is_private: this.config.repoVisibility === 'private', }), headers: { Authorization: `Basic ${buffer.toString('base64')}`, @@ -143,6 +152,7 @@ export class BitbucketPublisher implements PublisherBase { body: JSON.stringify({ name: name, description: description, + is_private: this.config.repoVisibility === 'private', }), headers: { Authorization: `Bearer ${this.config.token}`, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts index 9a788b960d..30fe8fd65a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -17,6 +17,9 @@ jest.mock('@octokit/rest'); jest.mock('./helpers'); +import os from 'os'; +import { resolve } from 'path'; + import { getVoidLogger } from '@backstage/backend-common'; import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; import { GithubPublisher } from './github'; @@ -36,6 +39,9 @@ describe('GitHub Publisher', () => { jest.clearAllMocks(); }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); + describe('with public repo visibility', () => { describe('publish: createRemoteInGithub', () => { it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { @@ -64,7 +70,7 @@ describe('GitHub Publisher', () => { owner: 'bob', access: 'blam/team', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -89,7 +95,7 @@ describe('GitHub Publisher', () => { permission: 'admin', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -122,7 +128,7 @@ describe('GitHub Publisher', () => { owner: 'bob', access: 'blam', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -140,7 +146,7 @@ describe('GitHub Publisher', () => { expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -175,7 +181,7 @@ describe('GitHub Publisher', () => { access: 'bob', description: 'description', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -198,7 +204,7 @@ describe('GitHub Publisher', () => { permission: 'admin', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -233,7 +239,7 @@ describe('GitHub Publisher', () => { storePath: 'https://github.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -249,7 +255,7 @@ describe('GitHub Publisher', () => { visibility: 'internal', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -283,7 +289,7 @@ describe('GitHub Publisher', () => { storePath: 'https://github.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -299,7 +305,7 @@ describe('GitHub Publisher', () => { private: true, }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 31d94d5f53..13aab7ff34 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -19,8 +19,10 @@ import { initRepoAndPush } from './helpers'; import { GitHubIntegrationConfig } from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; import { Octokit } from '@octokit/rest'; +import path from 'path'; export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; + export class GithubPublisher implements PublisherBase { static async fromConfig( config: GitHubIntegrationConfig, @@ -41,6 +43,7 @@ export class GithubPublisher implements PublisherBase { repoVisibility, }); } + constructor( private readonly config: { token: string; @@ -51,7 +54,7 @@ export class GithubPublisher implements PublisherBase { async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { const { owner, name } = parseGitUrl(values.storePath); @@ -66,7 +69,7 @@ export class GithubPublisher implements PublisherBase { }); await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl, auth: { username: this.config.token, @@ -79,7 +82,6 @@ export class GithubPublisher implements PublisherBase { /\.git$/, '/blob/master/catalog-info.yaml', ); - return { remoteUrl, catalogInfoUrl }; } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index 11b32a6de5..61c8480efb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -20,6 +20,8 @@ jest.mock('@gitbeaker/node', () => ({ jest.mock('./helpers'); +import os from 'os'; +import path from 'path'; import { GitlabPublisher } from './gitlab'; import { Gitlab } from '@gitbeaker/node'; import { initRepoAndPush } from './helpers'; @@ -47,13 +49,19 @@ describe('GitLab Publisher', () => { ); }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = path.resolve(workspacePath, 'result'); + describe('publish: createRemoteInGitLab', () => { it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => { - const publisher = await GitlabPublisher.fromConfig({ - host: 'gitlab.com', - token: 'fake-token', - baseUrl: 'https://gitlab.hosted.com', - }); + const publisher = await GitlabPublisher.fromConfig( + { + host: 'gitlab.com', + token: 'fake-token', + baseUrl: 'https://gitlab.hosted.com', + }, + { repoVisibility: 'public' }, + ); mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 42, @@ -68,7 +76,7 @@ describe('GitLab Publisher', () => { storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -83,9 +91,10 @@ describe('GitLab Publisher', () => { expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 42, name: 'test', + visibility: 'public', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'mockclone', auth: { username: 'oauth2', password: 'fake-token' }, logger, @@ -93,10 +102,13 @@ describe('GitLab Publisher', () => { }); it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { - const publisher = await GitlabPublisher.fromConfig({ - host: 'gitlab.com', - token: 'fake-token', - }); + const publisher = await GitlabPublisher.fromConfig( + { + host: 'gitlab.com', + token: 'fake-token', + }, + { repoVisibility: 'public' }, + ); mockGitlabClient.Namespaces.show.mockResolvedValue({}); mockGitlabClient.Users.current.mockResolvedValue({ @@ -111,7 +123,7 @@ describe('GitLab Publisher', () => { storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath, logger, }); @@ -123,9 +135,10 @@ describe('GitLab Publisher', () => { expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 21, name: 'test', + visibility: 'public', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: resultPath, remoteUrl: 'mockclone', auth: { username: 'oauth2', password: 'fake-token' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts index 24149ab2f3..4b10d653da 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts @@ -19,26 +19,39 @@ import { Gitlab } from '@gitbeaker/node'; import { Gitlab as GitlabClient } from '@gitbeaker/core'; import { initRepoAndPush } from './helpers'; import parseGitUrl from 'git-url-parse'; - +import path from 'path'; import { GitLabIntegrationConfig } from '@backstage/integration'; +export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; + export class GitlabPublisher implements PublisherBase { - static async fromConfig(config: GitLabIntegrationConfig) { + static async fromConfig( + config: GitLabIntegrationConfig, + { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, + ) { if (!config.token) { return undefined; } const client = new Gitlab({ host: config.baseUrl, token: config.token }); - return new GitlabPublisher({ token: config.token, client }); + return new GitlabPublisher({ + token: config.token, + client, + repoVisibility, + }); } constructor( - private readonly config: { token: string; client: GitlabClient }, + private readonly config: { + token: string; + client: GitlabClient; + repoVisibility: RepoVisibilityOptions; + }, ) {} async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { const { owner, name } = parseGitUrl(values.storePath); @@ -49,7 +62,7 @@ export class GitlabPublisher implements PublisherBase { }); await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl, auth: { username: 'oauth2', @@ -85,6 +98,7 @@ export class GitlabPublisher implements PublisherBase { const project = (await this.config.client.Projects.create({ namespace_id: targetNamespace, name: name, + visibility: this.config.repoVisibility, })) as { http_url_to_repo: string }; return project?.http_url_to_repo; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index 79eb3b7708..30201e6dca 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -16,10 +16,19 @@ import { Config } from '@backstage/config'; import { PublisherBase, PublisherBuilder } from './types'; -import { GithubPublisher, RepoVisibilityOptions } from './github'; -import { GitlabPublisher } from './gitlab'; +import { + GithubPublisher, + RepoVisibilityOptions as GithubRepoVisibilityOptions, +} from './github'; +import { + GitlabPublisher, + RepoVisibilityOptions as GitlabRepoVisibilityOptions, +} from './gitlab'; import { AzurePublisher } from './azure'; -import { BitbucketPublisher } from './bitbucket'; +import { + BitbucketPublisher, + RepoVisibilityOptions as BitbucketRepoVisibilityOptions, +} from './bitbucket'; import { Logger } from 'winston'; import { ScmIntegrations } from '@backstage/integration'; @@ -74,7 +83,7 @@ export class Publishers implements PublisherBuilder { for (const integration of scm.github.list()) { const repoVisibility = (config.getOptionalString( 'scaffolder.github.visibility', - ) ?? 'public') as RepoVisibilityOptions; + ) ?? 'public') as GithubRepoVisibilityOptions; const publisher = await GithubPublisher.fromConfig(integration.config, { repoVisibility, @@ -98,7 +107,13 @@ export class Publishers implements PublisherBuilder { } for (const integration of scm.gitlab.list()) { - const publisher = await GitlabPublisher.fromConfig(integration.config); + const repoVisibility = (config.getOptionalString( + 'scaffolder.gitlab.visibility', + ) ?? 'public') as GitlabRepoVisibilityOptions; + + const publisher = await GitlabPublisher.fromConfig(integration.config, { + repoVisibility, + }); if (publisher) { publishers.register(integration.config.host, publisher); @@ -107,16 +122,28 @@ export class Publishers implements PublisherBuilder { publishers.register( integration.config.host, - await GitlabPublisher.fromConfig({ - token: config.getOptionalString('scaffolder.gitlab.token') ?? '', - host: integration.config.host, - }), + await GitlabPublisher.fromConfig( + { + token: config.getOptionalString('scaffolder.gitlab.token') ?? '', + host: integration.config.host, + }, + { repoVisibility }, + ), ); } } for (const integration of scm.bitbucket.list()) { - const publisher = await BitbucketPublisher.fromConfig(integration.config); + const repoVisibility = (config.getOptionalString( + 'scaffolder.bitbucket.visibility', + ) ?? 'public') as BitbucketRepoVisibilityOptions; + + const publisher = await BitbucketPublisher.fromConfig( + integration.config, + { + repoVisibility, + }, + ); if (publisher) { publishers.register(integration.config.host, publisher); @@ -125,15 +152,19 @@ export class Publishers implements PublisherBuilder { publishers.register( integration.config.host, - await BitbucketPublisher.fromConfig({ - token: config.getOptionalString('scaffolder.bitbucket.token') ?? '', - username: - config.getOptionalString('scaffolder.bitbucket.username') ?? '', - appPassword: - config.getOptionalString('scaffolder.bitbucket.appPassword') ?? - '', - host: integration.config.host, - }), + await BitbucketPublisher.fromConfig( + { + token: + config.getOptionalString('scaffolder.bitbucket.token') ?? '', + username: + config.getOptionalString('scaffolder.bitbucket.username') ?? '', + appPassword: + config.getOptionalString('scaffolder.bitbucket.appPassword') ?? + '', + host: integration.config.host, + }, + { repoVisibility }, + ), ); } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index fbf656206d..3ae92b8b7d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts @@ -32,7 +32,7 @@ export type PublisherBase = { export type PublisherOptions = { values: TemplaterValues; - directory: string; + workspacePath: string; logger: Logger; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index 656abd9613..77368fc1ab 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -13,48 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('./helpers', () => ({ - runDockerContainer: jest.fn(), - runCommand: jest.fn(), -})); -jest.mock('command-exists-promise', () => jest.fn()); + +const runDockerContainer = jest.fn(); +const runCommand = jest.fn(); +const commandExists = jest.fn(); + +jest.mock('./helpers', () => ({ runDockerContainer, runCommand })); +jest.mock('command-exists-promise', () => commandExists); +jest.mock('fs-extra'); import { CookieCutter } from './cookiecutter'; import fs from 'fs-extra'; -import os from 'os'; import path from 'path'; -import { RunDockerContainerOptions, RunCommandOptions } from './helpers'; import { PassThrough } from 'stream'; import Docker from 'dockerode'; import parseGitUrl from 'git-url-parse'; -const commandExists = require('command-exists-promise'); - describe('CookieCutter Templater', () => { - const cookie = new CookieCutter(); const mockDocker = {} as Docker; - const { - runDockerContainer, - }: { - runDockerContainer: jest.Mock; - } = require('./helpers'); - jest - .spyOn(fs, 'readdir') - .mockImplementation(() => Promise.resolve(['newthing'])); - - beforeEach(async () => { + beforeEach(() => { jest.clearAllMocks(); }); - const mkTemp = async () => { - const tempDir = os.tmpdir(); - return await fs.promises.mkdtemp(path.join(tempDir, 'temp')); - }; - it('should write a cookiecutter.json file with the values from the entity', async () => { - const tempdir = await mkTemp(); - const values = { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -65,22 +47,31 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ directory: tempdir, values, dockerClient: mockDocker }); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); - const cookieCutterJson = await fs.readJSON(`${tempdir}/cookiecutter.json`); + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }); - expect(cookieCutterJson).toEqual( - expect.objectContaining(JSON.parse(JSON.stringify(values))), + expect(fs.ensureDir).toBeCalledWith(path.join('tempdir', 'intermediate')); + expect(fs.writeJson).toBeCalledWith( + path.join('tempdir', 'template', 'cookiecutter.json'), + expect.objectContaining(values), ); }); it('should merge any value that is in the cookiecutter.json path already', async () => { - const tempdir = await mkTemp(); const existingJson = { _copy_without_render: ['./github/workflows/*'], }; - await fs.writeJSON(`${tempdir}/cookiecutter.json`, existingJson); + jest + .spyOn(fs, 'readJSON') + .mockImplementationOnce(() => Promise.resolve(existingJson)); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); const values = { owner: 'blobby', @@ -91,23 +82,29 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ directory: tempdir, values, dockerClient: mockDocker }); - - const cookieCutterJson = await fs.readJSON(`${tempdir}/cookiecutter.json`); - - expect(cookieCutterJson).toEqual({ - ...existingJson, - ...values, - destination: { - git: expect.objectContaining({ organization: 'org', name: 'repo' }), - }, + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, }); + + expect(fs.writeJSON).toBeCalledWith( + path.join('tempdir', 'template', 'cookiecutter.json'), + { + ...existingJson, + ...values, + destination: { + git: expect.objectContaining({ organization: 'org', name: 'repo' }), + }, + }, + ); }); it('should throw an error if the cookiecutter json is malformed and not missing', async () => { - const tempdir = await mkTemp(); - - await fs.writeFile(`${tempdir}/cookiecutter.json`, "{'"); + jest.spyOn(fs, 'readJSON').mockImplementationOnce(() => { + throw new Error('BAM'); + }); const values = { owner: 'blobby', @@ -117,14 +114,17 @@ describe('CookieCutter Templater', () => { }, }; + const templater = new CookieCutter(); await expect( - cookie.run({ directory: tempdir, values, dockerClient: mockDocker }), - ).rejects.toThrow(/Unexpected token ' in JSON at position 1/); + templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }), + ).rejects.toThrow('BAM'); }); it('should run the correct docker container with the correct bindings for the volumes', async () => { - const tempdir = await mkTemp(); - const values = { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -134,7 +134,14 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ directory: tempdir, values, dockerClient: mockDocker }); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }); expect(runDockerContainer).toHaveBeenCalledWith({ imageName: 'spotify/backstage-cookiecutter', @@ -146,39 +153,16 @@ describe('CookieCutter Templater', () => { '/template', '--verbose', ], - templateDir: tempdir, - resultDir: expect.stringContaining(`${tempdir}-result`), + templateDir: path.join('tempdir', 'template'), + resultDir: path.join('tempdir', 'intermediate'), logStream: undefined, dockerClient: mockDocker, }); }); - it('should return the result path to the end templated folder', async () => { - const tempdir = await mkTemp(); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - const { resultDir } = await cookie.run({ - directory: tempdir, - values, - dockerClient: mockDocker, - }); - - expect(resultDir.startsWith(`${tempdir}-result`)).toBeTruthy(); - }); - it('should pass through the streamer to the run docker helper', async () => { const stream = new PassThrough(); - const tempdir = await mkTemp(); - const values = { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -188,8 +172,11 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ - directory: tempdir, + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', values, logStream: stream, dockerClient: mockDocker, @@ -205,29 +192,17 @@ describe('CookieCutter Templater', () => { '/template', '--verbose', ], - templateDir: tempdir, - resultDir: expect.stringContaining(`${tempdir}-result`), + templateDir: path.join('tempdir', 'template'), + resultDir: path.join('tempdir', 'intermediate'), logStream: stream, dockerClient: mockDocker, }); }); describe('when cookiecutter is available', () => { - beforeAll(() => { - commandExists.mockImplementation(() => () => true); - }); - it('use the binary', async () => { - const { - runCommand, - }: { - runCommand: jest.Mock; - } = require('./helpers'); - const stream = new PassThrough(); - const tempdir = await mkTemp(); - const values = { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -237,8 +212,12 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ - directory: tempdir, + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + commandExists.mockImplementationOnce(() => () => true); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', values, logStream: stream, dockerClient: mockDocker, @@ -249,8 +228,8 @@ describe('CookieCutter Templater', () => { args: expect.arrayContaining([ '--no-input', '-o', - tempdir, - expect.stringContaining(`${tempdir}-result`), + path.join('tempdir', 'intermediate'), + path.join('tempdir', 'template'), '--verbose', ]), logStream: stream, @@ -259,18 +238,17 @@ describe('CookieCutter Templater', () => { }); describe('when nothing was generated', () => { - beforeEach(() => { - jest.spyOn(fs, 'readdir').mockImplementation(() => Promise.resolve([])); - }); - it('throws an error', async () => { const stream = new PassThrough(); - const tempdir = await mkTemp(); + jest + .spyOn(fs, 'readdir') + .mockImplementationOnce(() => Promise.resolve([])); - return expect( - cookie.run({ - directory: tempdir, + const templater = new CookieCutter(); + await expect( + templater.run({ + workspacePath: 'tempdir', values: { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -281,7 +259,7 @@ describe('CookieCutter Templater', () => { logStream: stream, dockerClient: mockDocker, }), - ).rejects.toThrow(/Cookie Cutter did not generate anything/); + ).rejects.toThrow(/No data generated by cookiecutter/); }); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index 7f7fd3494f..9568af2a15 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -19,8 +19,6 @@ import { runDockerContainer, runCommand } from './helpers'; import { TemplaterBase, TemplaterRunOptions } from '.'; import path from 'path'; -import { TemplaterRunResult } from './types'; - const commandExists = require('command-exists-promise'); export class CookieCutter implements TemplaterBase { @@ -28,7 +26,7 @@ export class CookieCutter implements TemplaterBase { directory: string, ): Promise> { try { - return await fs.readJSON(`${directory}/cookiecutter.json`); + return await fs.readJSON(path.join(directory, 'cookiecutter.json')); } catch (ex) { if (ex.code !== 'ENOENT') { throw ex; @@ -38,28 +36,33 @@ export class CookieCutter implements TemplaterBase { } } - public async run(options: TemplaterRunOptions): Promise { + public async run({ + workspacePath, + dockerClient, + values, + logStream, + }: TemplaterRunOptions): Promise { + const templateDir = path.join(workspacePath, 'template'); + const intermediateDir = path.join(workspacePath, 'intermediate'); + await fs.ensureDir(intermediateDir); + const resultDir = path.join(workspacePath, 'result'); + // First lets grab the default cookiecutter.json file - const cookieCutterJson = await this.fetchTemplateCookieCutter( - options.directory, - ); + const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir); const cookieInfo = { ...cookieCutterJson, - ...options.values, + ...values, }; - await fs.writeJSON(`${options.directory}/cookiecutter.json`, cookieInfo); - - const templateDir = options.directory; - const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); + await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo); const cookieCutterInstalled = await commandExists('cookiecutter'); if (cookieCutterInstalled) { await runCommand({ command: 'cookiecutter', - args: ['--no-input', '-o', resultDir, templateDir, '--verbose'], - logStream: options.logStream, + args: ['--no-input', '-o', intermediateDir, templateDir, '--verbose'], + logStream, }); } else { await runDockerContainer({ @@ -73,22 +76,20 @@ export class CookieCutter implements TemplaterBase { '--verbose', ], templateDir, - resultDir, - logStream: options.logStream, - dockerClient: options.dockerClient, + resultDir: intermediateDir, + logStream, + dockerClient, }); } - // if cookiecutter was successful, resultDir will contain + // if cookiecutter was successful, intermediateDir will contain // exactly one directory. - const [generated] = await fs.readdir(resultDir); + const [generated] = await fs.readdir(intermediateDir); if (generated === undefined) { - throw new Error('Cookie Cutter did not generate anything'); + throw new Error('No data generated by cookiecutter'); } - return { - resultDir: path.resolve(resultDir, generated), - }; + await fs.move(path.join(intermediateDir, generated), resultDir); } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts index f5863feb10..5e957e0504 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts @@ -17,25 +17,30 @@ import fs from 'fs-extra'; import { runDockerContainer } from '../helpers'; import { TemplaterBase, TemplaterRunOptions } from '..'; import path from 'path'; -import { TemplaterRunResult } from '../types'; import * as yaml from 'yaml'; -import { resolvePackagePath } from '@backstage/backend-common'; // TODO(blam): Replace with the universal import from github-actions after a release // As it will break the E2E without it const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug'; export class CreateReactAppTemplater implements TemplaterBase { - public async run(options: TemplaterRunOptions): Promise { + public async run({ + workspacePath, + values, + logStream, + dockerClient, + }: TemplaterRunOptions): Promise { const { component_id: componentName, use_typescript: withTypescript, use_github_actions: withGithubActions, description, owner, - } = options.values; + } = values; + const intermediateDir = path.join(workspacePath, 'template'); + await fs.ensureDir(intermediateDir); - const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); + const resultDir = path.join(workspacePath, 'result'); await runDockerContainer({ imageName: 'node:lts-alpine', @@ -44,35 +49,80 @@ export class CreateReactAppTemplater implements TemplaterBase { componentName as string, withTypescript ? ' --template typescript' : '', ], - templateDir: options.directory, - resultDir, - logStream: options.logStream, - dockerClient: options.dockerClient, + templateDir: intermediateDir, + resultDir: intermediateDir, + logStream: logStream, + dockerClient: dockerClient, createOptions: { Entrypoint: ['npx'], WorkingDir: '/result', }, }); - const extraAnnotations: Record = {}; - const finalDir = path.resolve( - resultDir, - options.values.component_id as string, - ); + // if cookiecutter was successful, intermediateDir will contain + // exactly one directory. + const [generated] = await fs.readdir(intermediateDir); + if (generated === undefined) { + throw new Error('No data generated by cookiecutter'); + } + + await fs.move(path.join(intermediateDir, generated), resultDir); + + const extraAnnotations: Record = {}; if (withGithubActions) { - await fs.promises.mkdir(`${finalDir}/.github`); - await fs.promises.mkdir(`${finalDir}/.github/workflows`); - await fs.promises.copyFile( - `${resolvePackagePath( - '@backstage/plugin-scaffolder-backend', - )}/templates/.github/workflows/main.yml`, - `${finalDir}/.github/workflows/main.yml`, + await fs.mkdir(`${resultDir}/.github`); + await fs.mkdir(`${resultDir}/.github/workflows`); + const githubActionsYaml = ` +name: CRA Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x] + + steps: + - name: checkout code + uses: actions/checkout@v1 + - name: get yarn cache + id: yarn-cache + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v2 + with: + path: \${{ steps.yarn-cache.outputs.dir }} + key: \${{ runner.os }}-yarn-\${{ hashFiles('**/yarn.lock') }} + restore-keys: | + \${{ runner.os }}-yarn- + - name: use node.js \${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: \${{ matrix.node-version }} + - name: yarn install, build, and test + working-directory: . + run: | + yarn install + yarn build + yarn test + env: + CI: true + `; + await fs.writeFile( + `${resultDir}/.github/workflows/main.yml`, + githubActionsYaml, ); extraAnnotations[ GITHUB_ACTIONS_ANNOTATION - ] = `${options.values?.destination?.git?.owner}/${options.values?.destination?.git?.name}`; + ] = `${values?.destination?.git?.owner}/${values?.destination?.git?.name}`; } const componentInfo = { @@ -92,13 +142,9 @@ export class CreateReactAppTemplater implements TemplaterBase { }, }; - await fs.promises.writeFile( - `${finalDir}/catalog-info.yaml`, + await fs.writeFile( + `${resultDir}/catalog-info.yaml`, yaml.stringify(componentInfo), ); - - return { - resultDir: finalDir, - }; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml deleted file mode 100644 index 65cae9abbb..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: CRA Build - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [12.x] - - steps: - - name: checkout code - uses: actions/checkout@v1 - - name: get yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: use node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: yarn install, build, and test - working-directory: . - run: | - yarn install - yarn build - yarn test - env: - CI: true diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts index 9ef20b7740..209210c075 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts @@ -14,52 +14,13 @@ * limitations under the License. */ import { Templaters } from '.'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { CookieCutter } from './cookiecutter'; describe('Templaters', () => { - const mockTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml', - }, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - templater: 'cookiecutter', - path: '.', - type: 'website', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; it('should throw an error when the templater is not registered', () => { const templaters = new Templaters(); - expect(() => templaters.get(mockTemplate)).toThrow( + expect(() => templaters.get('cookiecutter')).toThrow( expect.objectContaining({ message: 'No templater registered for template: "cookiecutter"', }), @@ -71,55 +32,6 @@ describe('Templaters', () => { templaters.register('cookiecutter', templater); - expect(templaters.get(mockTemplate)).toBe(templater); - }); - - it('should throw an error if the templater does not exist in the entity', () => { - const brokenTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: {}, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - type: 'website', - path: '.', - templater: '', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; - - const templaters = new Templaters(); - - expect(() => templaters.get(brokenTemplate)).toThrow( - expect.objectContaining({ - name: 'InputError', - message: expect.stringContaining( - 'Template does not have a required templating key', - ), - }), - ); + expect(templaters.get('cookiecutter')).toBe(templater); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts index 549b898af8..9535d28837 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts @@ -20,26 +20,20 @@ import { TemplaterBuilder, } from './types'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { getTemplaterKey } from './helpers'; - export class Templaters implements TemplaterBuilder { - private preparerMap = new Map(); + private templaterMap = new Map(); register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase) { - this.preparerMap.set(templaterKey, templater); + this.templaterMap.set(templaterKey, templater); } - get(template: TemplateEntityV1alpha1): TemplaterBase { - const templaterKey = getTemplaterKey(template); - const preparer = this.preparerMap.get(templaterKey); + get(templaterId: string): TemplaterBase { + const templater = this.templaterMap.get(templaterId); - if (!preparer) { - throw new Error( - `No templater registered for template: "${templaterKey}"`, - ); + if (!templater) { + throw new Error(`No templater registered for template: "${templaterId}"`); } - return preparer; + return templater; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts index 812fb972d5..fa14788d22 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts @@ -15,7 +15,6 @@ */ import type { Writable } from 'stream'; import Docker from 'dockerode'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import gitUrlParse from 'git-url-parse'; /** @@ -46,7 +45,7 @@ export type TemplaterRunResult = { * client to run any templater on top of your directory. */ export type TemplaterRunOptions = { - directory: string; + workspacePath: string; values: TemplaterValues; logStream?: Writable; dockerClient: Docker; @@ -54,7 +53,7 @@ export type TemplaterRunOptions = { export type TemplaterBase = { // runs the templating with the values and returns the directory to push the VCS - run(opts: TemplaterRunOptions): Promise; + run(opts: TemplaterRunOptions): Promise; }; export type TemplaterConfig = { @@ -71,5 +70,5 @@ export type SupportedTemplatingKey = 'cookiecutter' | string; */ export type TemplaterBuilder = { register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void; - get(template: TemplateEntityV1alpha1): TemplaterBase; + get(templater: string): TemplaterBase; }; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 59d5f4696e..31f62febd2 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -23,6 +23,8 @@ jest.doMock('fs-extra', () => ({ F_OK: 0, W_OK: 1, }, + mkdir: jest.fn(), + remove: jest.fn(), })); import { getVoidLogger } from '@backstage/backend-common'; @@ -116,9 +118,10 @@ describe('createRouter - working directory', () => { }, }); - expect(mockPrepare).toBeCalledWith(expect.anything(), { + expect(mockPrepare).toBeCalledWith({ logger: expect.anything(), - workingDirectory: '/path', + workspacePath: expect.stringContaining('path'), + url: expect.anything(), }); }); @@ -143,8 +146,10 @@ describe('createRouter - working directory', () => { }, }); - expect(mockPrepare).toBeCalledWith(expect.anything(), { + expect(mockPrepare).toBeCalledWith({ logger: expect.anything(), + workspacePath: expect.anything(), + url: expect.anything(), }); }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 115efc2ec4..9098e5d05e 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -15,19 +15,19 @@ */ import { Config } from '@backstage/config'; -import fs from 'fs-extra'; import Docker from 'dockerode'; import express from 'express'; +import { resolve as resolvePath } from 'path'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { JobProcessor, PreparerBuilder, - StageContext, TemplaterBuilder, TemplaterValues, PublisherBuilder, parseLocationAnnotation, + joinGitUrlPath, FilePreparer, } from '../scaffolder'; import { CatalogEntityClient } from '../lib/catalog'; @@ -62,27 +62,7 @@ export async function createRouter( } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); - const jobProcessor = new JobProcessor(); - - let workingDirectory: string; - if (config.has('backend.workingDirectory')) { - workingDirectory = config.getString('backend.workingDirectory'); - try { - // Check if working directory exists and is writable - await fs.promises.access( - workingDirectory, - fs.constants.F_OK | fs.constants.W_OK, - ); - logger.info(`using working directory: ${workingDirectory}`); - } catch (err) { - logger.error( - `working directory ${workingDirectory} ${ - err.code === 'ENOENT' ? 'does not exist' : 'is not writable' - }`, - ); - throw err; - } - } + const jobProcessor = await JobProcessor.fromConfig({ config, logger }); router .get('/v1/job/:jobId', ({ params }, res) => { @@ -128,51 +108,69 @@ export async function createRouter( res.status(400).json({ errors: validationResult.errors }); return; } + const job = jobProcessor.create({ entity: template, values, stages: [ { name: 'Prepare the skeleton', - handler: async ctx => { - const { protocol, location: pullPath } = parseLocationAnnotation( - ctx.entity, + async handler(ctx) { + const { + protocol, + location: templateEntityLocation, + } = parseLocationAnnotation(ctx.entity); + + if (protocol === 'file') { + const preparer = new FilePreparer(); + + const path = resolvePath( + templateEntityLocation, + template.spec.path || '.', + ); + + await preparer.prepare({ + url: `file://${path}`, + logger: ctx.logger, + workspacePath: ctx.workspacePath, + }); + return; + } + + const preparer = preparers.get(templateEntityLocation); + + const url = joinGitUrlPath( + templateEntityLocation, + template.spec.path, ); - const preparer = - protocol === 'file' - ? new FilePreparer() - : preparers.get(pullPath); - - const skeletonDir = await preparer.prepare(ctx.entity, { + await preparer.prepare({ + url, logger: ctx.logger, - workingDirectory, + workspacePath: ctx.workspacePath, }); - return { skeletonDir }; }, }, { name: 'Run the templater', - handler: async (ctx: StageContext<{ skeletonDir: string }>) => { - const templater = templaters.get(ctx.entity); - const { resultDir } = await templater.run({ - directory: ctx.skeletonDir, + async handler(ctx) { + const templater = templaters.get(ctx.entity.spec.templater); + await templater.run({ + workspacePath: ctx.workspacePath, dockerClient, logStream: ctx.logStream, values: ctx.values, }); - - return { resultDir }; }, }, { name: 'Publish template', - handler: async (ctx: StageContext<{ resultDir: string }>) => { + handler: async ctx => { const publisher = publishers.get(ctx.values.storePath); ctx.logger.info('Will now store the template'); const result = await publisher.publish({ values: ctx.values, - directory: ctx.resultDir, + workspacePath: ctx.workspacePath, logger: ctx.logger, }); return result; diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 6d435a144c..11a9cfb75c 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-scaffolder +## 0.4.1 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/plugin-catalog@0.2.14 + ## 0.4.0 ### Minor Changes diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 812a5585d4..eff16ab0f5 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -14,7 +14,32 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { discoveryApiRef } from '@backstage/core'; +import { CatalogClient } from '@backstage/catalog-client'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { TemplateIndexPage, TemplatePage } from '../src/plugin'; +import { ScaffolderApi, scaffolderApiRef } from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerApi({ + api: catalogApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }), + }) + .registerApi({ + api: scaffolderApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }), + }) + .addPage({ + path: '/create', + title: 'Create', + element: , + }) + .addPage({ + path: '/create/:templateName', + element: , + }) + .render(); diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 483dc548ea..69021953a2 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.4.0", + "version": "0.4.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -51,9 +51,10 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", + "@backstage/catalog-client": "^0.3.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 528d42b94f..27c5b4f18a 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -19,7 +19,7 @@ import { Box, Button, Paper, - Step, + Step as StepUI, StepContent, StepLabel, Stepper, @@ -67,7 +67,7 @@ export const MultistepJsonForm = ({ <> {steps.map(({ label, schema, ...formProps }) => ( - + {label}
-
+ ))}
{activeStep === steps.length && ( diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 39999f938f..75ee2d07df 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -27,7 +27,7 @@ import { useApi, WarningPanel, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Button, Grid, Link, Typography } from '@material-ui/core'; import React, { useEffect } from 'react'; import { Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 62963b58b8..f3f97be877 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 60f5ad8dec..e1422d4ec1 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -27,19 +27,19 @@ import { catalogApiRef, entityRoute, entityRouteParams, -} from '@backstage/plugin-catalog'; +} from '@backstage/plugin-catalog-react'; import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; -import React, { useState, useCallback } from 'react'; +import parseGitUrl from 'git-url-parse'; +import React, { useCallback, useState } from 'react'; import { generatePath, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; import { rootRoute } from '../../routes'; +import { useJobPolling } from '../hooks/useJobPolling'; import { JobStatusModal } from '../JobStatusModal'; import { MultistepJsonForm } from '../MultistepJsonForm'; -import { useJobPolling } from '../hooks/useJobPolling'; -import parseGitUrl from 'git-url-parse'; const useTemplate = ( templateName: string, @@ -92,8 +92,8 @@ export const TemplatePage = () => { ); const [jobId, setJobId] = useState(null); - const job = useJobPolling(jobId, async job => { - if (!job.metadata.catalogInfoUrl) { + const job = useJobPolling(jobId, async jobItem => { + if (!jobItem.metadata.catalogInfoUrl) { errorApi.post( new Error(`No catalogInfoUrl returned from the scaffolder`), ); @@ -103,7 +103,9 @@ export const TemplatePage = () => { try { const { entities: [createdEntity], - } = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl }); + } = await catalogApi.addLocation({ + target: jobItem.metadata.catalogInfoUrl, + }); const resolvedPath = generatePath( `/catalog/${entityRoute.path}`, @@ -122,8 +124,8 @@ export const TemplatePage = () => { const handleCreate = async () => { try { - const jobId = await scaffolderApi.scaffold(templateName, formState); - setJobId(jobId); + const id = await scaffolderApi.scaffold(templateName, formState); + setJobId(id); setModalOpen(true); } catch (e) { errorApi.post(e); diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index f0fdf5b429..5f102e7853 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -14,6 +14,11 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + scaffolderPlugin, + scaffolderPlugin as plugin, + TemplateIndexPage, + TemplatePage, +} from './plugin'; export { ScaffolderApi, scaffolderApiRef } from './api'; export { rootRoute, templateRoute } from './routes'; diff --git a/plugins/scaffolder/src/plugin.test.ts b/plugins/scaffolder/src/plugin.test.ts index 3b4c92168d..03dd9fe465 100644 --- a/plugins/scaffolder/src/plugin.test.ts +++ b/plugins/scaffolder/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { scaffolderPlugin } from './plugin'; describe('scaffolder', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(scaffolderPlugin).toBeDefined(); }); }); diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 41f7a03249..ed20705185 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -18,13 +18,14 @@ import { createPlugin, createApiFactory, discoveryApiRef, + createRoutableExtension, } from '@backstage/core'; -import { ScaffolderPage } from './components/ScaffolderPage'; -import { TemplatePage } from './components/TemplatePage'; +import { ScaffolderPage as ScaffolderPageComponent } from './components/ScaffolderPage'; +import { TemplatePage as TemplatePageComponent } from './components/TemplatePage'; import { rootRoute, templateRoute } from './routes'; import { scaffolderApiRef, ScaffolderApi } from './api'; -export const plugin = createPlugin({ +export const scaffolderPlugin = createPlugin({ id: 'scaffolder', apis: [ createApiFactory({ @@ -34,7 +35,27 @@ export const plugin = createPlugin({ }), ], register({ router }) { - router.addRoute(rootRoute, ScaffolderPage); - router.addRoute(templateRoute, TemplatePage); + router.addRoute(rootRoute, ScaffolderPageComponent); + router.addRoute(templateRoute, TemplatePageComponent); + }, + routes: { + templateIndex: rootRoute, + template: templateRoute, }, }); + +export const TemplateIndexPage = scaffolderPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ScaffolderPage').then(m => m.ScaffolderPage), + mountPoint: rootRoute, + }), +); + +export const TemplatePage = scaffolderPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/TemplatePage').then(m => m.TemplatePage), + mountPoint: templateRoute, + }), +); diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 28c77f29a6..8efd1d3aaf 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -16,12 +16,11 @@ import { createRouteRef } from '@backstage/core'; export const rootRoute = createRouteRef({ - icon: () => null, path: '/create', title: 'Create new entity', }); + export const templateRoute = createRouteRef({ - icon: () => null, path: '/create/:templateName', title: 'Entity creation', }); diff --git a/plugins/search/package.json b/plugins/search/package.json index 41e295aba4..28627b88ea 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -30,8 +30,8 @@ }, "dependencies": { "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", "@backstage/catalog-model": "^0.7.0", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index e74741e27b..9d88227a44 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { CatalogApi } from '@backstage/plugin-catalog'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { CatalogApi } from '@backstage/plugin-catalog-react'; export type Result = { name: string; diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index f81cd4f66d..c4d8809732 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -13,23 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useEffect } from 'react'; -import { useAsync } from 'react-use'; - -import { makeStyles, Typography, Grid, Divider } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; import { - Link, EmptyState, + Link, Progress, Table, TableColumn, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; - -import { FiltersButton, Filters, FiltersState } from '../Filters'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Divider, Grid, makeStyles, Typography } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import React, { useEffect, useState } from 'react'; +import { useAsync } from 'react-use'; import SearchApi, { Result, SearchResults } from '../../apis'; +import { Filters, FiltersButton, FiltersState } from '../Filters'; const useStyles = makeStyles(theme => ({ searchQuery: { @@ -59,11 +57,6 @@ type TableHeaderProps = { handleToggleFilters: () => void; }; -type Filters = { - selected: string; - checked: Array; -}; - // TODO: move out column to make the search result component more generic const columns: TableColumn[] = [ { diff --git a/plugins/sentry/dev/index.tsx b/plugins/sentry/dev/index.tsx index c9313911b2..87e03605e4 100644 --- a/plugins/sentry/dev/index.tsx +++ b/plugins/sentry/dev/index.tsx @@ -15,17 +15,17 @@ */ import { Entity } from '@backstage/catalog-model'; -import { EntityProvider } from '@backstage/plugin-catalog'; import { Content, Header, Page } from '@backstage/core'; import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { Grid } from '@material-ui/core'; import React from 'react'; import { + EntitySentryCard, + EntitySentryContent, MockSentryApi, SentryApi, sentryApiRef, - EntitySentryCard, - EntitySentryContent, } from '../src'; import { SENTRY_PROJECT_SLUG_ANNOTATION } from '../src/components/useProjectSlug'; diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 0bcc49ce35..38e1c55ed0 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,7 +46,7 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sentry/src/extensions.tsx b/plugins/sentry/src/extensions.tsx index a66080f2b6..ea63cd472a 100644 --- a/plugins/sentry/src/extensions.tsx +++ b/plugins/sentry/src/extensions.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import React from 'react'; import { createComponentExtension, createRoutableExtension, } from '@backstage/core'; -import { useEntity } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; import { plugin, rootRouteRef } from './plugin'; export const EntitySentryContent = plugin.provide( diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 375b5a8312..c3be5e7415 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-sonarqube +## 0.1.9 + +### Patch Changes + +- 49a67732f: Ask the SonarQube server for all support metrics prior to querying them for a project. + ## 0.1.8 ### Patch Changes diff --git a/plugins/sonarqube/dev/index.tsx b/plugins/sonarqube/dev/index.tsx index 5e8aedd577..2dd9236547 100644 --- a/plugins/sonarqube/dev/index.tsx +++ b/plugins/sonarqube/dev/index.tsx @@ -15,21 +15,61 @@ */ import { Entity } from '@backstage/catalog-model'; -import { - Content, - createPlugin, - createRouteRef, - Header, - Page, -} from '@backstage/core'; -import { createDevApp } from '@backstage/dev-utils'; +import { Content, Header, Page } from '@backstage/core'; +import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; import { Grid } from '@material-ui/core'; import React from 'react'; -import { SonarQubeCard } from '../src'; +import { EntitySonarQubeCard, sonarQubePlugin } from '../src'; import { FindingSummary, SonarQubeApi, sonarQubeApiRef } from '../src/api'; import { SONARQUBE_PROJECT_KEY_ANNOTATION } from '../src/components/useProjectKey'; +const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [SONARQUBE_PROJECT_KEY_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + createDevApp() + .registerPlugin(sonarQubePlugin) + .addPage({ + title: 'Cards', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }) .registerApi({ api: sonarQubeApiRef, deps: {}, @@ -114,58 +154,4 @@ createDevApp() }, } as SonarQubeApi), }) - .registerPlugin( - createPlugin({ - id: 'defectdojo-demo', - register({ router }) { - const entity = (name?: string) => - ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - annotations: { - [SONARQUBE_PROJECT_KEY_ANNOTATION]: name, - }, - name: name, - }, - } as Entity); - - const ExamplePage = () => ( - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - ); - - router.addRoute( - createRouteRef({ path: '/', title: 'SonarQube' }), - ExamplePage, - ); - }, - }), - ) .render(); diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 0f7eca0f9a..c286dd9dac 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.8", + "version": "0.1.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,6 +33,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.0", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/core": "^0.5.0", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -46,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx index c5e295a22b..5577048fa9 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -22,6 +22,7 @@ import { Progress, useApi, } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Chip, Grid } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import BugReport from '@material-ui/icons/BugReport'; @@ -69,10 +70,10 @@ const useStyles = makeStyles(theme => ({ }, })); -interface DuplicationRating { +type DuplicationRating = { greaterThan: number; rating: '1.0' | '2.0' | '3.0' | '4.0' | '5.0'; -} +}; const defaultDuplicationRatings: DuplicationRating[] = [ { greaterThan: 0, rating: '1.0' }, @@ -83,14 +84,14 @@ const defaultDuplicationRatings: DuplicationRating[] = [ ]; export const SonarQubeCard = ({ - entity, variant = 'gridItem', duplicationRatings = defaultDuplicationRatings, }: { - entity: Entity; + entity?: Entity; variant?: string; duplicationRatings?: DuplicationRating[]; }) => { + const { entity } = useEntity(); const sonarQubeApi = useApi(sonarQubeApiRef); const projectTitle = useProjectKey(entity); diff --git a/plugins/sonarqube/src/index.ts b/plugins/sonarqube/src/index.ts index f09aeb1038..8fae151929 100644 --- a/plugins/sonarqube/src/index.ts +++ b/plugins/sonarqube/src/index.ts @@ -15,4 +15,8 @@ */ export * from './components'; -export { plugin } from './plugin'; +export { + sonarQubePlugin, + sonarQubePlugin as plugin, + EntitySonarQubeCard, +} from './plugin'; diff --git a/plugins/sonarqube/src/plugin.test.ts b/plugins/sonarqube/src/plugin.test.ts index 32730b64c3..246f8b297e 100644 --- a/plugins/sonarqube/src/plugin.test.ts +++ b/plugins/sonarqube/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { sonarQubePlugin } from './plugin'; describe('sonarqube', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(sonarQubePlugin).toBeDefined(); }); }); diff --git a/plugins/sonarqube/src/plugin.ts b/plugins/sonarqube/src/plugin.ts index f8b8cafc5c..3f58b24abf 100644 --- a/plugins/sonarqube/src/plugin.ts +++ b/plugins/sonarqube/src/plugin.ts @@ -17,12 +17,13 @@ import { configApiRef, createApiFactory, + createComponentExtension, createPlugin, discoveryApiRef, } from '@backstage/core'; import { sonarQubeApiRef, SonarQubeClient } from './api'; -export const plugin = createPlugin({ +export const sonarQubePlugin = createPlugin({ id: 'sonarqube', apis: [ createApiFactory({ @@ -36,3 +37,12 @@ export const plugin = createPlugin({ }), ], }); + +export const EntitySonarQubeCard = sonarQubePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/SonarQubeCard').then(m => m.SonarQubeCard), + }, + }), +); diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index d9f627ac8c..9014d52931 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 583a7c91e0..759fec62c3 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -59,7 +59,7 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { ring: loaderResponse!.rings.find( r => r.id === entry.timeline[0].ringId, )!, - history: entry.timeline.map(e => { + timeline: entry.timeline.map(e => { return { date: e.date, ring: loaderResponse!.rings.find(a => a.id === e.ringId)!, @@ -67,6 +67,8 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { moved: e.moved, }; }), + moved: entry.timeline[0].moved, + url: entry.url, }; }); }; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index e319846d78..e10993b010 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -71,12 +71,12 @@ const useStyles = makeStyles(theme => ({ 'font-feature-settings': 'pnum', }, entry: { - pointerEvents: 'none', + pointerEvents: 'visiblePainted', userSelect: 'none', fontSize: '11px', }, entryLink: { - pointerEvents: 'none', + pointerEvents: 'visiblePainted', }, })); diff --git a/plugins/tech-radar/src/sampleData.ts b/plugins/tech-radar/src/sampleData.ts index 914bc30931..613e7b3cdc 100644 --- a/plugins/tech-radar/src/sampleData.ts +++ b/plugins/tech-radar/src/sampleData.ts @@ -53,7 +53,7 @@ entries.push({ entries.push({ timeline: [ { - moved: 0, + moved: -1, ringId: 'use', date: new Date('2020-08-06'), description: @@ -69,7 +69,7 @@ entries.push({ entries.push({ timeline: [ { - moved: 0, + moved: 1, ringId: 'use', date: new Date('2020-08-06'), }, @@ -88,7 +88,7 @@ entries.push({ date: new Date('2020-08-06'), }, ], - url: '#', + url: 'https://reactjs.org/', key: 'react', id: 'react', title: 'React', diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index b51d86138a..7ec1162d03 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -14,17 +14,12 @@ * limitations under the License. */ /** - * techdocs schema below is an abstract of what's used within techdocs-backend and for its visibility - * to view the complete techdoc schema please refer: plugins/techdocs/config.d.ts + * TechDocs schema below is an abstract of what's used within techdocs-backend and for its visibility + * to view the complete TechDocs schema please refer: plugins/techdocs/config.d.ts * */ export interface Config { /** Configuration options for the techdocs-backend plugin */ techdocs: { - /** - * attr: 'storageUrl' - accepts a string value - * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs - */ - storageUrl: string; /** * documentation building process depends on the builder attr * attr: 'builder' - accepts a string value @@ -45,5 +40,11 @@ export interface Config { */ type: 'local' | 'googleGcs' | 'awsS3'; }; + /** + * attr: 'storageUrl' - accepts a string value + * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs + * @deprecated + */ + storageUrl?: string; }; } diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index eb3d70dee7..e6af442d20 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/techdocs-common": "^0.3.5", + "@backstage/techdocs-common": "^0.3.6", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", @@ -45,7 +45,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 5ad23cde84..fe7b52ab52 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -102,7 +102,9 @@ export async function createRouter({ router.get('/docs/:namespace/:kind/:name/*', async (req, res) => { const { kind, namespace, name } = req.params; - const storageUrl = config.getString('techdocs.storageUrl'); + const storageUrl = + config.getOptionalString('techdocs.storageUrl') ?? + `${await discovery.getBaseUrl('techdocs')}/static/docs`; const catalogUrl = await discovery.getBaseUrl('catalog'); const triple = [kind, namespace, name].map(encodeURIComponent).join('/'); @@ -148,6 +150,7 @@ export async function createRouter({ } break; case 'awsS3': + case 'azureBlobStorage': case 'googleGcs': // This block should be valid for all external storage implementations. So no need to duplicate in future, // add the publisher type in the list here. diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 721c10a1ec..d91e2f08a2 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -17,6 +17,7 @@ import { createServiceBuilder, SingleHostDiscovery, + UrlReader, } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -49,10 +50,18 @@ export async function startStandaloneServer( }, }); const discovery = SingleHostDiscovery.fromConfig(config); + const mockUrlReader: jest.Mocked = { + read: jest.fn(), + readTree: jest.fn(), + }; logger.debug('Creating application...'); const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(config, logger); + const directoryPreparer = new DirectoryPreparer( + config, + logger, + mockUrlReader, + ); preparers.register('dir', directoryPreparer); const generators = new Generators(); diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index f9831f27cb..1104d75237 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -17,17 +17,6 @@ export interface Config { /** Configuration options for the techdocs plugin */ techdocs: { - /** - * attr: 'requestUrl' - accepts a string value - * e.g. requestUrl: http://localhost:7000/api/techdocs - * @visibility frontend - */ - requestUrl: string; - /** - * attr: 'storageUrl' - accepts a string value - * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs - */ - storageUrl: string; /** * documentation building process depends on the builder attr * attr: 'builder' - accepts a string value @@ -114,6 +103,47 @@ export interface Config { region?: string; }; } + | { + /** + * attr: 'type' - accepts a string value + * e.g. type: 'azureBlobStorage' + * alternatives: 'azureBlobStorage' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + type: 'azureBlobStorage'; + + /** + * azureBlobStorage required when 'type' is set to azureBlobStorage + */ + azureBlobStorage?: { + /** + * (Required) Credentials used to access a storage container. + * @visibility secret + */ + credentials: { + /** + * Account access name + * attr: 'account' - accepts a string value + * @visibility secret + */ + accountName: string; + /** + * (Optional) Account secret primary key + * If not set, environment variables will be used to authenticate. + * https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + * attr: 'accountKey' - accepts a string value + * @visibility secret + */ + accountKey?: string; + }; + /** + * (Required) Cloud Storage Container Name + * attr: 'containerName' - accepts a string value + * @visibility backend + */ + containerName: string; + }; + } | { /** * attr: 'type' - accepts a string value @@ -143,5 +173,18 @@ export interface Config { credentials?: string; }; }; + /** + * attr: 'requestUrl' - accepts a string value + * e.g. requestUrl: http://localhost:7000/api/techdocs + * @visibility frontend + * @deprecated + */ + requestUrl?: string; + /** + * attr: 'storageUrl' - accepts a string value + * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs + * @deprecated + */ + storageUrl?: string; }; } diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts index ae2b8c58ae..7d0679f6f6 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -13,20 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { DiscoveryApi } from '@backstage/core'; +import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import { TechDocsStorage } from '../src/api'; export class TechDocsDevStorageApi implements TechDocsStorage { - public apiOrigin: string; + public configApi: Config; + public discoveryApi: DiscoveryApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } async getEntityDocs(entityId: EntityName, path: string) { const { name } = entityId; - const url = `${this.apiOrigin}/${name}/${path}`; + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/${name}/${path}`; const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, @@ -39,8 +57,13 @@ export class TechDocsDevStorageApi implements TechDocsStorage { return request.text(); } - getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string { + async getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise { const { name } = entityId; - return new URL(oldBaseUrl, `${this.apiOrigin}/${name}/${path}`).toString(); + const apiOrigin = await this.getApiOrigin(); + return new URL(oldBaseUrl, `${apiOrigin}/${name}/${path}`).toString(); } } diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index 12974ef8b7..42fa9a9ec4 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { configApiRef, discoveryApiRef } from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; import { plugin } from '../src/plugin'; import { TechDocsDevStorageApi } from './api'; @@ -22,10 +23,11 @@ import { techdocsStorageApiRef } from '../src'; createDevApp() .registerApi({ api: techdocsStorageApiRef, - deps: {}, - factory: () => + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => new TechDocsDevStorageApi({ - apiOrigin: 'http://localhost:3000/api', + configApi, + discoveryApi, }), }) .registerPlugin(plugin) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index ac439b8623..20e294c661 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -31,12 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/config": "^0.1.2", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", - "@backstage/techdocs-common": "^0.3.5", + "@backstage/techdocs-common": "^0.3.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -49,7 +50,7 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts index 132976e280..734650d7c8 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/api.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Config } from '@backstage/config'; +import { UrlPatternDiscovery } from '@backstage/core'; import { TechDocsStorageApi } from './api'; -const DOC_STORAGE_URL = 'https://example-storage.com'; - const mockEntity = { kind: 'Component', namespace: 'default', @@ -24,19 +24,31 @@ const mockEntity = { }; describe('TechDocsStorageApi', () => { - it('should return correct base url based on defined storage', () => { - const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); + const mockBaseUrl = 'http://backstage:9191/api/techdocs'; + const configApi = { + getOptionalString: () => 'http://backstage:9191/api/techdocs', + } as Partial; + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); - expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, + it('should return correct base url based on defined storage', async () => { + // @ts-ignore Partial not assignable to Config. + const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); + + await expect( + storageApi.getBaseUrl('test.js', mockEntity, ''), + ).resolves.toEqual( + `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, ); }); - it('should return base url with correct entity structure', () => { - const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); + it('should return base url with correct entity structure', async () => { + // @ts-ignore Partial not assignable to Config. + const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); - expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, + await expect( + storageApi.getBaseUrl('test/', mockEntity, ''), + ).resolves.toEqual( + `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, ); }); }); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 3a950aeeb3..a8b1f1e34f 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { createApiRef } from '@backstage/core'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; +import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import { TechDocsMetadata } from './types'; @@ -30,7 +31,11 @@ export const techdocsApiRef = createApiRef({ export interface TechDocsStorage { getEntityDocs(entityId: EntityName, path: string): Promise; - getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string; + getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise; } export interface TechDocs { @@ -44,10 +49,25 @@ export interface TechDocs { * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API */ export class TechDocsApi implements TechDocs { - public apiOrigin: string; + public configApi: Config; + public discoveryApi: DiscoveryApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } /** @@ -62,7 +82,8 @@ export class TechDocsApi implements TechDocs { async getTechDocsMetadata(entityId: EntityName) { const { kind, namespace, name } = entityId; - const requestUrl = `${this.apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; const request = await fetch(`${requestUrl}`); const res = await request.json(); @@ -81,7 +102,8 @@ export class TechDocsApi implements TechDocs { async getEntityMetadata(entityId: EntityName) { const { kind, namespace, name } = entityId; - const requestUrl = `${this.apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; const request = await fetch(`${requestUrl}`); const res = await request.json(); @@ -96,10 +118,25 @@ export class TechDocsApi implements TechDocs { * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API */ export class TechDocsStorageApi implements TechDocsStorage { - public apiOrigin: string; + public configApi: Config; + public discoveryApi: DiscoveryApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } /** @@ -113,7 +150,8 @@ export class TechDocsStorageApi implements TechDocsStorage { async getEntityDocs(entityId: EntityName, path: string) { const { kind, namespace, name } = entityId; - const url = `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, @@ -132,12 +170,18 @@ export class TechDocsStorageApi implements TechDocsStorage { return request.text(); } - getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string { + async getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise { const { kind, namespace, name } = entityId; + const apiOrigin = await this.getApiOrigin(); + return new URL( oldBaseUrl, - `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`, + `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`, ).toString(); } } diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 576ba0a292..982b749f2c 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -34,6 +34,7 @@ import { createRouteRef, createApiFactory, configApiRef, + discoveryApiRef, } from '@backstage/core'; import { techdocsStorageApiRef, @@ -57,24 +58,25 @@ export const rootCatalogDocsRouteRef = createRouteRef({ title: 'Docs', }); -// TODO: Use discovery API for frontend to get URL for techdocs-backend instead of requestUrl export const plugin = createPlugin({ id: 'techdocs', apis: [ createApiFactory({ api: techdocsStorageApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => new TechDocsStorageApi({ - apiOrigin: configApi.getString('techdocs.requestUrl'), + configApi, + discoveryApi, }), }), createApiFactory({ api: techdocsApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => new TechDocsApi({ - apiOrigin: configApi.getString('techdocs.requestUrl'), + configApi, + discoveryApi, }), }), ], diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 1264e2b6eb..50990911a1 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -129,7 +129,7 @@ export const Reader = ({ entityId, onReady }: Props) => { }, }), onCssReady({ - docStorageUrl: techdocsStorageApi.apiOrigin, + docStorageUrl: techdocsStorageApi.getApiOrigin(), onLoading: (dom: Element) => { (dom as HTMLElement).style.setProperty('opacity', '0'); }, diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx index 4979f027a6..fb951f0a19 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx @@ -15,7 +15,7 @@ */ import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index 9f569dd167..f064c35517 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -22,16 +22,15 @@ import { Progress, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Grid } from '@material-ui/core'; import React from 'react'; -import { generatePath, useNavigate } from 'react-router-dom'; +import { generatePath } from 'react-router-dom'; import { useAsync } from 'react-use'; import { rootDocsRouteRef } from '../../plugin'; export const TechDocsHome = () => { const catalogApi = useApi(catalogApiRef); - const navigate = useNavigate(); const { value, loading, error } = useAsync(async () => { const response = await catalogApi.getEntities(); @@ -80,15 +79,11 @@ export const TechDocsHome = () => { ? value.map((entity, index: number) => ( - navigate( - generatePath(rootDocsRouteRef.path, { - namespace: entity.metadata.namespace ?? 'default', - kind: entity.kind, - name: entity.metadata.name, - }), - ) - } + href={generatePath(rootDocsRouteRef.path, { + namespace: entity.metadata.namespace ?? 'default', + kind: entity.kind, + name: entity.metadata.name, + })} title={entity.metadata.name} label="Read Docs" description={entity.metadata.description} diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index bc84f792a7..4b578bef38 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -56,7 +56,8 @@ describe('', () => { }; const techdocsStorageApi: Partial = { getEntityDocs: (): Promise => Promise.resolve('String'), - getBaseUrl: (): string => '', + getBaseUrl: (): Promise => Promise.resolve('String'), + getApiOrigin: (): Promise => Promise.resolve('String'), }; const apiRegistry = ApiRegistry.from([ diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index 728c39826d..10c7191b63 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -21,7 +21,7 @@ import { TechDocsStorage } from '../../api'; const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; const techdocsStorageApi: TechDocsStorage = { - getBaseUrl: jest.fn(() => DOC_STORAGE_URL), + getBaseUrl: jest.fn(() => Promise.resolve(DOC_STORAGE_URL)), getEntityDocs: () => new Promise(resolve => resolve('yes!')), }; diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 8d9d5a7294..2872ad36cc 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -35,12 +35,12 @@ export const addBaseUrl = ({ ): void => { Array.from(list) .filter(elem => !!elem.getAttribute(attributeName)) - .forEach((elem: T) => { + .forEach(async (elem: T) => { const elemAttribute = elem.getAttribute(attributeName); if (!elemAttribute) return; elem.setAttribute( attributeName, - techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path), + await techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path), ); }); }; diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts index 67ae38c56b..1fbc59e213 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts @@ -22,8 +22,9 @@ import { } from '../../test-utils'; import { onCssReady } from '../transformers'; -const docStorageUrl: string = - 'https://techdocs-mock-sites.storage.googleapis.com'; +const docStorageUrl: Promise = Promise.resolve( + 'https://techdocs-mock-sites.storage.googleapis.com', +); const fixture = ` diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.ts b/plugins/techdocs/src/reader/transformers/onCssReady.ts index dd50879459..50dbe52cbd 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.ts @@ -17,7 +17,7 @@ import type { Transformer } from './index'; type OnCssReadyOptions = { - docStorageUrl: string; + docStorageUrl: Promise; onLoading: (dom: Element) => void; onLoaded: (dom: Element) => void; }; @@ -30,7 +30,9 @@ export const onCssReady = ({ return dom => { const cssPages = Array.from( dom.querySelectorAll('head > link[rel="stylesheet"]'), - ).filter(elem => elem.getAttribute('href')?.startsWith(docStorageUrl)); + ).filter(async elem => + elem.getAttribute('href')?.startsWith(await docStorageUrl), + ); let count = cssPages.length; diff --git a/plugins/user-settings/dev/index.tsx b/plugins/user-settings/dev/index.tsx index 264d6f801f..3eaed00022 100644 --- a/plugins/user-settings/dev/index.tsx +++ b/plugins/user-settings/dev/index.tsx @@ -13,7 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { userSettingsPlugin, UserSettingsPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(userSettingsPlugin) + .addPage({ + title: 'Settings', + element: , + }) + .render(); diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 2e6f5e3e95..78790a891e 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx index 5df4df4450..de6f4703e0 100644 --- a/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx +++ b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx @@ -32,11 +32,11 @@ export const EmptyFlags = () => ( - An example how how to add a feature flags is highlighted below: + An example for how to add a feature flag is highlighted below: { - const theme = useTheme(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); return ( @@ -32,7 +30,7 @@ export const General = () => { - {!fullScreen && } + diff --git a/plugins/user-settings/src/components/General/Profile.tsx b/plugins/user-settings/src/components/General/Profile.tsx index b0231d95b6..c8034085bd 100644 --- a/plugins/user-settings/src/components/General/Profile.tsx +++ b/plugins/user-settings/src/components/General/Profile.tsx @@ -24,31 +24,27 @@ export const Profile = () => { const { profile, displayName } = useUserProfile(); return ( - - - - - - - - - - - - {displayName} - - - {profile.email} - - - - - - + + + + + + + + + + {displayName} + + + {profile.email} + - + + + + - + ); }; diff --git a/plugins/user-settings/src/index.ts b/plugins/user-settings/src/index.ts index 9b0ce4262c..b26c8b4b0b 100644 --- a/plugins/user-settings/src/index.ts +++ b/plugins/user-settings/src/index.ts @@ -13,5 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin } from './plugin'; + +export { + userSettingsPlugin, + userSettingsPlugin as plugin, + UserSettingsPage, +} from './plugin'; export * from './components/'; diff --git a/plugins/user-settings/src/plugin.test.ts b/plugins/user-settings/src/plugin.test.ts index 810a2cda12..479fd0ac61 100644 --- a/plugins/user-settings/src/plugin.test.ts +++ b/plugins/user-settings/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { plugin } from './plugin'; +import { userSettingsPlugin } from './plugin'; describe('user-settings', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(userSettingsPlugin).toBeDefined(); }); }); diff --git a/plugins/user-settings/src/plugin.ts b/plugins/user-settings/src/plugin.ts index 2f896c8197..36d2254c11 100644 --- a/plugins/user-settings/src/plugin.ts +++ b/plugins/user-settings/src/plugin.ts @@ -13,13 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; + +import { + createPlugin, + createRoutableExtension, + createRouteRef, +} from '@backstage/core'; export const settingsRouteRef = createRouteRef({ path: '/settings', title: 'Settings', }); -export const plugin = createPlugin({ +export const userSettingsPlugin = createPlugin({ id: 'user-settings', + routes: { + settingsPage: settingsRouteRef, + }, }); + +export const UserSettingsPage = userSettingsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/SettingsPage').then(m => m.SettingsPage), + mountPoint: settingsRouteRef, + }), +); diff --git a/plugins/welcome/dev/index.tsx b/plugins/welcome/dev/index.tsx index 812a5585d4..b237812f97 100644 --- a/plugins/welcome/dev/index.tsx +++ b/plugins/welcome/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { welcomePlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(welcomePlugin).render(); diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 440e8c2d47..9482e34743 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/welcome/src/index.ts b/plugins/welcome/src/index.ts index 3a0a0fe2d3..cccc6bacf3 100644 --- a/plugins/welcome/src/index.ts +++ b/plugins/welcome/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { welcomePlugin, welcomePlugin as plugin, WelcomePage } from './plugin'; diff --git a/plugins/welcome/src/plugin.test.ts b/plugins/welcome/src/plugin.test.ts index d60c73ec68..381ea80f38 100644 --- a/plugins/welcome/src/plugin.test.ts +++ b/plugins/welcome/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { welcomePlugin } from './plugin'; describe('welcome', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(welcomePlugin).toBeDefined(); }); }); diff --git a/plugins/welcome/src/plugin.ts b/plugins/welcome/src/plugin.ts index 754f708110..f950b93874 100644 --- a/plugins/welcome/src/plugin.ts +++ b/plugins/welcome/src/plugin.ts @@ -14,18 +14,28 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import WelcomePage from './components/WelcomePage'; +import { + createPlugin, + createRoutableExtension, + createRouteRef, +} from '@backstage/core'; +import WelcomePageComponent from './components/WelcomePage'; export const rootRouteRef = createRouteRef({ - path: '/welcome', title: 'Welcome', }); -export const plugin = createPlugin({ +export const welcomePlugin = createPlugin({ id: 'welcome', register({ router, featureFlags }) { - router.addRoute(rootRouteRef, WelcomePage); + router.addRoute(rootRouteRef, WelcomePageComponent); featureFlags.register('enable-welcome-box'); }, }); + +export const WelcomePage = welcomePlugin.provide( + createRoutableExtension({ + component: () => import('./components/WelcomePage').then(m => m.default), + mountPoint: rootRouteRef, + }), +); diff --git a/yarn.lock b/yarn.lock index 6b634511b4..482ce1f737 100644 --- a/yarn.lock +++ b/yarn.lock @@ -954,6 +954,101 @@ dependencies: tslib "^1.8.0" +"@azure/abort-controller@^1.0.0": + version "1.0.2" + resolved "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.0.2.tgz#822405c966b2aec16fb62c1b19d37eaccf231995" + integrity sha512-XUyTo+bcyxHEf+jlN2MXA7YU9nxVehaubngHV1MIZZaqYmZqykkoeAz/JMMEeR7t3TcyDwbFa3Zw8BZywmIx4g== + dependencies: + tslib "^2.0.0" + +"@azure/core-asynciterator-polyfill@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.0.tgz#dcccebb88406e5c76e0e1d52e8cc4c43a68b3ee7" + integrity sha512-kmv8CGrPfN9SwMwrkiBK9VTQYxdFQEGe0BmQk+M8io56P9KNzpAxcWE/1fxJj7uouwN4kXF0BHW8DNlgx+wtCg== + +"@azure/core-auth@^1.1.3": + version "1.1.4" + resolved "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.1.4.tgz#af9a334acf3cb9c49e6013e6caf6dc9d43476030" + integrity sha512-+j1embyH1jqf04AIfJPdLafd5SC1y6z1Jz4i+USR1XkTp6KM8P5u4/AjmWMVoEQdM/M29PJcRDZcCEWjK9S1bw== + dependencies: + "@azure/abort-controller" "^1.0.0" + tslib "^2.0.0" + +"@azure/core-http@^1.2.0": + version "1.2.2" + resolved "https://registry.npmjs.org/@azure/core-http/-/core-http-1.2.2.tgz#a6f7717184fd2657d3acabd1d64dfdc0bd531ce3" + integrity sha512-9eu2OcbR7e44gqBy4U1Uv8NTWgLIMwKXMEGgO2MahsJy5rdTiAhs5fJHQffPq8uX2MFh21iBODwO9R/Xlov88A== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.1.3" + "@azure/core-tracing" "1.0.0-preview.9" + "@azure/logger" "^1.0.0" + "@opentelemetry/api" "^0.10.2" + "@types/node-fetch" "^2.5.0" + "@types/tunnel" "^0.0.1" + form-data "^3.0.0" + node-fetch "^2.6.0" + process "^0.11.10" + tough-cookie "^4.0.0" + tslib "^2.0.0" + tunnel "^0.0.6" + uuid "^8.3.0" + xml2js "^0.4.19" + +"@azure/core-lro@^1.0.2": + version "1.0.3" + resolved "https://registry.npmjs.org/@azure/core-lro/-/core-lro-1.0.3.tgz#1ddfb4ecdb81ce87b5f5d972ffe2acbbc46e524e" + integrity sha512-Py2crJ84qx1rXkzIwfKw5Ni4WJuzVU7KAF6i1yP3ce8fbynUeu8eEWS4JGtSQgU7xv02G55iPDROifmSDbxeHA== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-http" "^1.2.0" + events "^3.0.0" + tslib "^2.0.0" + +"@azure/core-paging@^1.1.1": + version "1.1.3" + resolved "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.1.3.tgz#3587c9898a0530cacb64bab216d7318468aa5efc" + integrity sha512-his7Ah40ThEYORSpIAwuh6B8wkGwO/zG7gqVtmSE4WAJ46e36zUDXTKReUCLBDc6HmjjApQQxxcRFy5FruG79A== + dependencies: + "@azure/core-asynciterator-polyfill" "^1.0.0" + +"@azure/core-tracing@1.0.0-preview.9": + version "1.0.0-preview.9" + resolved "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.9.tgz#84f3b85572013f9d9b85e1e5d89787aa180787eb" + integrity sha512-zczolCLJ5QG42AEPQ+Qg9SRYNUyB+yZ5dzof4YEc+dyWczO9G2sBqbAjLB7IqrsdHN2apkiB2oXeDKCsq48jug== + dependencies: + "@opencensus/web-types" "0.0.7" + "@opentelemetry/api" "^0.10.2" + tslib "^2.0.0" + +"@azure/identity@^1.2.2": + version "1.2.2" + resolved "https://registry.npmjs.org/@azure/identity/-/identity-1.2.2.tgz#00d673c8881778c55777fcc76e822b42466d3fc7" + integrity sha512-aYkeNXl52aEHW1iOZQJb3SC7Vvbu87f01iNT+pSVHwj09LpN9+gP/Lb9uoWy36Fgv9WlukM55LbjLSbb1Renqw== + dependencies: + "@azure/core-http" "^1.2.0" + "@azure/core-tracing" "1.0.0-preview.9" + "@azure/logger" "^1.0.0" + "@azure/msal-node" "1.0.0-beta.3" + "@opentelemetry/api" "^0.10.2" + axios "^0.21.1" + events "^3.0.0" + jws "^4.0.0" + msal "^1.0.2" + open "^7.0.0" + qs "^6.7.0" + tslib "^2.0.0" + uuid "^8.3.0" + optionalDependencies: + keytar "^5.4.0" + +"@azure/logger@^1.0.0": + version "1.0.1" + resolved "https://registry.npmjs.org/@azure/logger/-/logger-1.0.1.tgz#19b333203d1b2931353d8879e814b64a7274837a" + integrity sha512-QYQeaJ+A5x6aMNu8BG5qdsVBnYBop9UMwgUvGihSjf1PdZZXB+c/oMdM2ajKwzobLBh9e9QuMQkN9iL+IxLBLA== + dependencies: + tslib "^2.0.0" + "@azure/msal-common@^2.1.0": version "2.1.0" resolved "https://registry.npmjs.org/@azure/msal-common/-/msal-common-2.1.0.tgz#a4bc17e254d6ec524016f13267947dd4ff4a624d" @@ -961,7 +1056,7 @@ dependencies: debug "^4.1.1" -"@azure/msal-node@^1.0.0-beta.3": +"@azure/msal-node@1.0.0-beta.3", "@azure/msal-node@^1.0.0-beta.3": version "1.0.0-beta.3" resolved "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.0.0-beta.3.tgz#c84c7948028b39e48b901f5fac35bdedcbc8772e" integrity sha512-/KfYRfrsOIrZONvo/0Vi5umuqbPBtCWNtmRvkse64uI0C4CP/W4WXwRD42VMws/8LtKvr1I5rYlYgFzt5zDz/A== @@ -971,6 +1066,21 @@ jsonwebtoken "^8.5.1" uuid "^8.3.0" +"@azure/storage-blob@^12.4.0": + version "12.4.0" + resolved "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.4.0.tgz#7127ddd9f413105e2c3688691bc4c6245d0806b3" + integrity sha512-OnhVSoKD1HzBB79/rFzPbC4w9TdzFXeoOwkX+aIu3rb8qvN0VaqvUqZXSrBCyG2LcLyVkY4MPCJQBrmEUm9kvw== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-http" "^1.2.0" + "@azure/core-lro" "^1.0.2" + "@azure/core-paging" "^1.1.1" + "@azure/core-tracing" "1.0.0-preview.9" + "@azure/logger" "^1.0.0" + "@opentelemetry/api" "^0.10.2" + events "^3.0.0" + tslib "^2.0.0" + "@babel/code-frame@7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" @@ -2478,6 +2588,7 @@ "@backstage/config" "^0.1.2" "@types/json-schema" "^7.0.5" "@types/yup" "^0.29.8" + ajv "^7.0.3" json-schema "^0.2.5" lodash "^4.17.15" uuid "^8.0.0" @@ -2489,6 +2600,7 @@ "@backstage/config" "^0.1.2" "@types/json-schema" "^7.0.5" "@types/yup" "^0.29.8" + ajv "^7.0.3" json-schema "^0.2.5" lodash "^4.17.15" uuid "^8.0.0" @@ -2503,6 +2615,7 @@ "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.45" + "@testing-library/react-hooks" "^3.4.2" "@types/dagre" "^0.7.44" "@types/prop-types" "^15.7.3" "@types/react" "^16.9" @@ -2542,53 +2655,54 @@ resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-5.0.0.tgz#3ba791f37b90e7f6170d252b63aacfcae943c039" integrity sha512-WmKrB/575EJCzbeSJR3YQ5sET5FaizeljLRw1382qVUeGqzuWBgIS+AF5a0FO51uQTrDpoRgvuHC2IWVsgwkkA== -"@changesets/apply-release-plan@^4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-4.0.0.tgz#e78efb56a4e459a8dab814ba43045f2ace0f27c9" - integrity sha512-MrcUd8wIlQ4S/PznzqJVsmnEpUGfPEkCGF54iqt8G05GEqi/zuxpoTfebcScpj5zeiDyxFIcA9RbeZ3pvJJxoA== +"@changesets/apply-release-plan@^4.2.0": + version "4.2.0" + resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-4.2.0.tgz#f1005815f27c3238f66507e90c6ae14d8fc62b41" + integrity sha512-/vt6UwgQldhOw93Gb8llI5OuYGlJt2+U45AfcXsoxzl8gZzCmChGm3vUaQJYbmtL8TbL8OOVXHRIKJJidMNPKw== dependencies: - "@babel/runtime" "^7.4.4" - "@changesets/config" "^1.2.0" + "@babel/runtime" "^7.10.4" + "@changesets/config" "^1.5.0" "@changesets/get-version-range-type" "^0.3.2" "@changesets/git" "^1.0.5" - "@changesets/types" "^3.1.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" + detect-indent "^6.0.0" fs-extra "^7.0.1" lodash.startcase "^4.4.0" outdent "^0.5.0" - prettier "^1.18.2" + prettier "^1.19.1" resolve-from "^5.0.0" semver "^5.4.1" -"@changesets/assemble-release-plan@^4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.0.0.tgz#60c2392c0e2c99f24778ab3a5c8e8c80ddaaaa59" - integrity sha512-3Kv21FNvysTQvZs3fHr6aZeDibhZHtgI1++fMZplzVtwNVmpjow3zv9lcZmJP26LthbpVH3I8+nqlU7M43lfWA== +"@changesets/assemble-release-plan@^4.0.0", "@changesets/assemble-release-plan@^4.1.0": + version "4.1.0" + resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.1.0.tgz#091e5e768dfe51835937e71d1ebaca1c9d6de55b" + integrity sha512-dMqe2L5Pn4UGTW89kOuuCuZD3pQFZj1Sxv92ZW4S98sXGsxcb2PdW+PeHbQ7tawkCYCOvzhXxAlN4OdF2DlDKQ== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" - "@changesets/types" "^3.1.0" + "@changesets/get-dependents-graph" "^1.2.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" semver "^5.4.1" -"@changesets/cli@^2.11.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.12.0.tgz#26124b051e6ce6dcc5aa4595588c8cb2ce3e4363" - integrity sha512-dGdFkg75zsaEObsG8gwMLglS6sJVjXWwgVTAzEIjqIoWVnKwqZqccTb4gn0noq47uCwy7SqxiAJqGibIy9UOKw== +"@changesets/cli@^2.14.0": + version "2.14.0" + resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.14.0.tgz#b8d1d33d832c640ce0b95333bbd8d5ac5b9c9824" + integrity sha512-rbQMRDXl1cXOglnjUvYyrFLlYBbS0YZdfxZfW3ZbGLzLoS4n50+B9fLSE9oW20hQuL3zAnyLyacb9cwNhF2lig== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/apply-release-plan" "^4.0.0" - "@changesets/assemble-release-plan" "^4.0.0" - "@changesets/config" "^1.4.0" + "@changesets/apply-release-plan" "^4.2.0" + "@changesets/assemble-release-plan" "^4.1.0" + "@changesets/config" "^1.5.0" "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" + "@changesets/get-dependents-graph" "^1.2.0" "@changesets/get-release-plan" "^2.0.1" - "@changesets/git" "^1.0.6" + "@changesets/git" "^1.1.0" "@changesets/logger" "^0.0.5" "@changesets/pre" "^1.0.4" "@changesets/read" "^0.4.6" - "@changesets/types" "^3.2.0" + "@changesets/types" "^3.3.0" "@changesets/write" "^0.1.3" "@manypkg/get-packages" "^1.0.1" "@types/semver" "^6.0.0" @@ -2608,15 +2722,15 @@ term-size "^2.1.0" tty-table "^2.8.10" -"@changesets/config@^1.2.0", "@changesets/config@^1.4.0": - version "1.4.0" - resolved "https://registry.npmjs.org/@changesets/config/-/config-1.4.0.tgz#c157a4121f198b749f2bbc2e9015b6e976ece7d6" - integrity sha512-eoTOcJ6py7jBDY8rUXwEGxR5UtvUX+p//0NhkVpPGcnvIeITHq+DOIsuWyGzWcb+1FaYkof3CCr32/komZTu4Q== +"@changesets/config@^1.2.0", "@changesets/config@^1.5.0": + version "1.5.0" + resolved "https://registry.npmjs.org/@changesets/config/-/config-1.5.0.tgz#6d58b01e45916318d3f39e9cde86a5d69dc58ed8" + integrity sha512-Bl9nLVYcwFCpd9jpzcOsExZk1NuTYX20D2YWHCdS1xll3W0yOdSUlWLUCCfugN1l3+yTR6iDW6q9o6vpCevWvA== dependencies: "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" + "@changesets/get-dependents-graph" "^1.2.0" "@changesets/logger" "^0.0.5" - "@changesets/types" "^3.2.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" fs-extra "^7.0.1" micromatch "^4.0.2" @@ -2628,12 +2742,12 @@ dependencies: extendable-error "^0.1.5" -"@changesets/get-dependents-graph@^1.1.3": - version "1.1.3" - resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.1.3.tgz#da959c43ce98f3a990a6b8d9c1f894bcc1b629c7" - integrity sha512-cTbySXwSv9yWp4Pp5R/b5Qv23wJgFaFCqUbsI3IJ2pyPl0vMaODAZS8NI1nNK2XSxGIg1tw+dWNSR4PlrKBSVQ== +"@changesets/get-dependents-graph@^1.2.0": + version "1.2.0" + resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.2.0.tgz#6986927a5fec60bc6fcc76f966efae2ac30c05ed" + integrity sha512-96NInEKpEZH8KvmXyh42PynXVAdq3kQ9VjAeswHtJ3umUCeTF42b/KVXaov+5P1RNnaUVtRuEwzs4syGuowDTw== dependencies: - "@changesets/types" "^3.0.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" chalk "^2.1.0" fs-extra "^7.0.1" @@ -2657,10 +2771,10 @@ resolved "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.3.2.tgz#8131a99035edd11aa7a44c341cbb05e668618c67" integrity sha512-SVqwYs5pULYjYT4op21F2pVbcrca4qA/bAA3FmFXKMN7Y+HcO8sbZUTx3TAy2VXulP2FACd1aC7f2nTuqSPbqg== -"@changesets/git@^1.0.5", "@changesets/git@^1.0.6": - version "1.0.6" - resolved "https://registry.npmjs.org/@changesets/git/-/git-1.0.6.tgz#057e627e5d3fcb74bf6c18d7284e130ba5a7632e" - integrity sha512-e0M06XuME3W5lGhz+CO0vLc60u+hLk/pYjOx/6GXEWuQrwtGgeycFIfRgRt8qTs664o1oKtVHBbd7ItpoWgFfA== +"@changesets/git@^1.0.5", "@changesets/git@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@changesets/git/-/git-1.1.0.tgz#ecaa8058ac87b450c09da0e74b68e6854cd0742c" + integrity sha512-f/2rQynT+JiAL/V0V/GQdXhLkcb86ELg3UwH3fQO4wVdfUbE9NHIHq9ohJdH1Ymh0Lv48F/b38aWZ5v2sKiF3w== dependencies: "@babel/runtime" "^7.10.4" "@changesets/errors" "^0.1.4" @@ -2677,9 +2791,9 @@ chalk "^2.1.0" "@changesets/parse@^0.3.6": - version "0.3.6" - resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.6.tgz#8c2c8480fc07d2db2c37469d4a8df10906a989c6" - integrity sha512-0XPd/es9CfogI7XIqDr7I2mWzm++xX2s9GZsij3GajPYd7ouEsgJyNatPooxNtqj6ZepkiD6uqlqbeBUyj/A0Q== + version "0.3.7" + resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.7.tgz#1368136e2b83d5cff11b4d383a3032723530db99" + integrity sha512-8yqKulslq/7V2VRBsJqPgjnZMoehYqhJm5lEOXJPZ2rcuSdyj8+p/2vq2vRDBJT2m0rP+C9G8DujsGYQIFZezw== dependencies: "@changesets/types" "^3.0.0" js-yaml "^3.13.1" @@ -2709,10 +2823,10 @@ fs-extra "^7.0.1" p-filter "^2.1.0" -"@changesets/types@^3.0.0", "@changesets/types@^3.1.0", "@changesets/types@^3.1.1", "@changesets/types@^3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@changesets/types/-/types-3.2.0.tgz#d8306d7219c3b19b6d860ddeb9d7374e2dd6b035" - integrity sha512-rAmPtOyXpisEEE25CchKNUAf2ApyAeuZ/h78YDoqKZaCk5tUD0lgYZGPIRV9WTPoqNjJULIym37ogc6pkax5jg== +"@changesets/types@^3.0.0", "@changesets/types@^3.1.0", "@changesets/types@^3.1.1", "@changesets/types@^3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@changesets/types/-/types-3.3.0.tgz#04cd8184b2d2da760667bd89bf9b930938dbd96e" + integrity sha512-rJamRo+OD/MQekImfIk07JZwYSB18iU6fYL8xOg0gfAiTh1a1+OlR1fPIxm55I7RsWw812is2YcPPwXdIewrhA== "@changesets/write@^0.1.3": version "0.1.3" @@ -4914,6 +5028,23 @@ fast-deep-equal "^3.1.3" lodash.clonedeep "^4.5.0" +"@opencensus/web-types@0.0.7": + version "0.0.7" + resolved "https://registry.npmjs.org/@opencensus/web-types/-/web-types-0.0.7.tgz#4426de1fe5aa8f624db395d2152b902874f0570a" + integrity sha512-xB+w7ZDAu3YBzqH44rCmG9/RlrOmFuDPt/bpf17eJr8eZSrLt7nc7LnWdxM9Mmoj/YKMHpxRg28txu3TcpiL+g== + +"@opentelemetry/api@^0.10.2": + version "0.10.2" + resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-0.10.2.tgz#9647b881f3e1654089ff7ea59d587b2d35060654" + integrity sha512-GtpMGd6vkzDMYcpu2t9LlhEgMy/SzBwRnz48EejlRArYqZzqSzAsKmegUK7zHgl+EOIaK9mKHhnRaQu3qw20cA== + dependencies: + "@opentelemetry/context-base" "^0.10.2" + +"@opentelemetry/context-base@^0.10.2": + version "0.10.2" + resolved "https://registry.npmjs.org/@opentelemetry/context-base/-/context-base-0.10.2.tgz#55bea904b2b91aa8a8675df9eaba5961bddb1def" + integrity sha512-hZNKjKOYsckoOEgBziGMnBcX0M7EtstnCmwz5jZUOUYwlZ+/xxX6z3jPu1XVO2Jivk0eLfuP9GP+vFD49CMetw== + "@panva/asn1.js@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6" @@ -5104,10 +5235,10 @@ react-router-dom "6.0.0-beta.0" react-use "^15.3.3" -"@rollup/plugin-commonjs@^16.0.0": - version "16.0.0" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-16.0.0.tgz#169004d56cd0f0a1d0f35915d31a036b0efe281f" - integrity sha512-LuNyypCP3msCGVQJ7ki8PqYdpjfEkE/xtFa5DqlF+7IBD0JsfMZ87C58heSwIMint58sAUZbt3ITqOmdQv/dXw== +"@rollup/plugin-commonjs@^17.1.0": + version "17.1.0" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.1.0.tgz#757ec88737dffa8aa913eb392fade2e45aef2a2d" + integrity sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew== dependencies: "@rollup/pluginutils" "^3.1.0" commondir "^1.0.1" @@ -6524,9 +6655,9 @@ integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": - version "4.17.9" - resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.9.tgz#2d7b34dcfd25ec663c25c85d76608f8b249667f1" - integrity sha512-DG0BYg6yO+ePW+XoDENYz8zhNGC3jDDEpComMYn7WJc4mY1Us8Rw9ax2YhJXxpyk2SF47PQAoQ0YyVT1a0bEkA== + version "4.17.18" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz#8371e260f40e0e1ca0c116a9afcd9426fa094c40" + integrity sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA== dependencies: "@types/node" "*" "@types/qs" "*" @@ -6946,6 +7077,14 @@ "@types/node" "*" form-data "^3.0.0" +"@types/node-fetch@^2.5.0": + version "2.5.8" + resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz#e199c835d234c7eb0846f6618012e558544ee2fb" + integrity sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node@*", "@types/node@>= 8": version "14.0.26" resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c" @@ -7405,6 +7544,13 @@ resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" integrity sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A== +"@types/tunnel@^0.0.1": + version "0.0.1" + resolved "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.1.tgz#0d72774768b73df26f25df9184273a42da72b19c" + integrity sha512-AOqu6bQu5MSWwYvehMXLukFHnupHrpZ8nvgae5Ggie9UwzDR1CCwoXgSSWNZJuyOlCdfdsWMA5F2LlmvyoTv8A== + dependencies: + "@types/node" "*" + "@types/uglify-js@*": version "3.0.4" resolved "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082" @@ -7541,9 +7687,9 @@ integrity sha512-MBSp62AjB1KrSOI3gX9GekddXU5YYQAVA93+aSl78biBqoSzxg876aQY2KJK5Gnfbpqq7O2cadVX5kPAtBqIXw== "@types/zen-observable@^0.8.0": - version "0.8.0" - resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" - integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== + version "0.8.2" + resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71" + integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg== "@typescript-eslint/eslint-plugin@^v4.14.0": version "4.14.0" @@ -7970,6 +8116,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.12.5, ajv json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^7.0.3: + version "7.0.3" + resolved "https://registry.npmjs.org/ajv/-/ajv-7.0.3.tgz#13ae747eff125cafb230ac504b2406cf371eece2" + integrity sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + alphanum-sort@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" @@ -10787,9 +10943,9 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core-js@^3.6.5: - version "3.6.5" - resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" - integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== + version "3.8.3" + resolved "https://registry.npmjs.org/core-js/-/core-js-3.8.3.tgz#c21906e1f14f3689f93abcc6e26883550dd92dd0" + integrity sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q== core-js@^2.4.0, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.5: version "2.6.11" @@ -11869,7 +12025,7 @@ detect-indent@^6.0.0: resolved "https://registry.npmjs.org/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== -detect-libc@^1.0.2: +detect-libc@^1.0.2, detect-libc@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= @@ -13112,6 +13268,11 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expand-tilde@^2.0.0, expand-tilde@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" @@ -14194,6 +14355,11 @@ gitconfiglocal@^1.0.0: dependencies: ini "^1.3.2" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= + github-slugger@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" @@ -14890,18 +15056,6 @@ highlight.js@^10.4.1, highlight.js@~10.4.0: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== -history@^4.9.0: - version "4.10.1" - resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" - integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== - dependencies: - "@babel/runtime" "^7.1.2" - loose-envify "^1.2.0" - resolve-pathname "^3.0.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - value-equal "^1.0.1" - history@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" @@ -14918,7 +15072,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -16124,11 +16278,6 @@ is-yarn-global@^0.3.0: resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= - isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -16273,9 +16422,9 @@ jake@^10.6.1: minimatch "^3.0.4" jenkins@^0.28.0: - version "0.28.0" - resolved "https://registry.npmjs.org/jenkins/-/jenkins-0.28.0.tgz#72d6fcc452145403b34f6d4ecbd877ee1ab77fca" - integrity sha512-EGzzZcyFwXBCPZZoDNvZTPmZOqaHALfAStNfCF37oh+Mc6G/e0MwIuQjx5kMEynTXR9bF5EwLiuMTiTf5kHk5g== + version "0.28.1" + resolved "https://registry.npmjs.org/jenkins/-/jenkins-0.28.1.tgz#f7951798ee5d2bb501a831979b6b5ecc1a922a64" + integrity sha512-gcC4QUrP4VzdqOMHoVzh36XlJprxJkI2HGLQSY7w84KoCTVNDcR/8O00tYyXp9vrZOx4wl5zCXLVKMgH2IoyJQ== dependencies: papi "^0.29.0" @@ -16947,6 +17096,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -17235,6 +17389,14 @@ kafkajs@^1.16.0-beta.6: resolved "https://registry.npmjs.org/kafkajs/-/kafkajs-1.16.0-beta.6.tgz#650f4d16abd60516aa0c375be613368391216df1" integrity sha512-R4DT3s7oCAoxdq3tN8w2WkUxLXcZgLQSjQVSmEZpEmT6hVdZ5AUZWqyLYr1IRbQowsJK4Z7eWJAk68J0JiRlUw== +keytar@^5.4.0: + version "5.6.0" + resolved "https://registry.npmjs.org/keytar/-/keytar-5.6.0.tgz#7b5d4bd043d17211163640be6c4a27a49b12bb39" + integrity sha512-ueulhshHSGoryfRXaIvTj0BV1yB0KddBGhGoqCxSN9LR1Ks1GKuuCdVhF+2/YOs5fMl6MlTI9On1a4DHDXoTow== + dependencies: + nan "2.14.1" + prebuild-install "5.3.3" + keyv@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -17903,7 +18065,7 @@ longest-streak@^2.0.0: resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -18556,14 +18718,6 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= -mini-create-react-context@^0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040" - integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA== - dependencies: - "@babel/runtime" "^7.5.5" - tiny-warning "^1.0.3" - mini-css-extract-plugin@^0.9.0: version "0.9.0" resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e" @@ -18804,6 +18958,13 @@ ms@2.1.2, ms@^2.0.0, ms@^2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +msal@^1.0.2: + version "1.4.4" + resolved "https://registry.npmjs.org/msal/-/msal-1.4.4.tgz#3f9b5a4442aa711c12ab8e88b8ed89b293f99711" + integrity sha512-aOBD/L6jAsizDFzKxxvXxH0FEDjp6Inr3Ufi/Y2o7KCFKN+akoE2sLeszEb/0Y3VxHxK0F0ea7xQ/HHTomKivw== + dependencies: + tslib "^1.9.3" + msw@^0.20.5: version "0.20.5" resolved "https://registry.npmjs.org/msw/-/msw-0.20.5.tgz#b6141080c0d8b17c451d9ca36c28cc47b4ac487a" @@ -18908,7 +19069,7 @@ mz@^2.5.0, mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.12.1, nan@^2.14.0: +nan@2.14.1, nan@^2.12.1, nan@^2.14.0: version "2.14.1" resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== @@ -18949,6 +19110,11 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + native-url@^0.2.6: version "0.2.6" resolved "https://registry.npmjs.org/native-url/-/native-url-0.2.6.tgz#ca1258f5ace169c716ff44eccbddb674e10399ae" @@ -19023,6 +19189,13 @@ nock@^13.0.5: lodash.set "^4.3.2" propagate "^2.0.0" +node-abi@^2.7.0: + version "2.19.3" + resolved "https://registry.npmjs.org/node-abi/-/node-abi-2.19.3.tgz#252f5dcab12dad1b5503b2d27eddd4733930282d" + integrity sha512-9xZrlyfvKhWme2EXFKQhZRp1yNWT/uI1luYPr3sFl+H4keYY4xR+1jO7mvTTijIsHf1M+QDe9uWuKeEpLInIlg== + dependencies: + semver "^5.4.1" + node-addon-api@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" @@ -19202,9 +19375,9 @@ node-request-interceptor@^0.5.1: headers-utils "^1.2.0" nodemon@^2.0.2: - version "2.0.6" - resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz#1abe1937b463aaf62f0d52e2b7eaadf28cc2240d" - integrity sha512-4I3YDSKXg6ltYpcnZeHompqac4E6JeAMpGm8tJnB9Y3T0ehasLa4139dJOcCrB93HHrUMsCrKtoAlXTqT5n4AQ== + version "2.0.7" + resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.7.tgz#6f030a0a0ebe3ea1ba2a38f71bf9bab4841ced32" + integrity sha512-XHzK69Awgnec9UzHr1kc8EomQh4sjTQ8oRf8TsGrSmHDx9/UmiGG9E/mM3BuTfNeFwdNBvrqQq/RHL0xIeyFOA== dependencies: chokidar "^3.2.2" debug "^3.2.6" @@ -19217,6 +19390,11 @@ nodemon@^2.0.2: undefsafe "^2.0.3" update-notifier "^4.1.0" +noop-logger@^0.1.1: + version "0.1.1" + resolved "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2" + integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI= + "nopt@2 || 3": version "3.0.6" resolved "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" @@ -19354,7 +19532,7 @@ npm-run-path@^4.0.0: dependencies: path-key "^3.0.0" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.2, npmlog@^4.1.2: +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.1, npmlog@^4.0.2, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -19602,6 +19780,14 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +open@^7.0.0: + version "7.3.1" + resolved "https://registry.npmjs.org/open/-/open-7.3.1.tgz#111119cb919ca1acd988f49685c4fdd0f4755356" + integrity sha512-f2wt9DCBKKjlFbjzGb8MOAW8LH8F0mrs1zc7KTjAJ9PZNQbfenzWbNP1VZJvw6ICMG9r14Ah6yfwPn7T7i646A== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + open@^7.0.2, open@^7.0.3: version "7.2.1" resolved "https://registry.npmjs.org/open/-/open-7.2.1.tgz#07b0ade11a43f2a8ce718480bdf3d7563a095195" @@ -20293,13 +20479,6 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= -path-to-regexp@^1.7.0: - version "1.8.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" - integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== - dependencies: - isarray "0.0.1" - path-type@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -21026,6 +21205,27 @@ postgres-interval@^1.1.0: dependencies: xtend "^4.0.0" +prebuild-install@5.3.3: + version "5.3.3" + resolved "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.3.tgz#ef4052baac60d465f5ba6bf003c9c1de79b9da8e" + integrity sha512-GV+nsUXuPW2p8Zy7SarF/2W/oiK8bFQgJcncoJ0d7kRpekEA0ftChjfEaF9/Y+QJEc/wFR7RAEa8lYByuUIe2g== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.0" + mkdirp "^0.5.1" + napi-build-utils "^1.0.1" + node-abi "^2.7.0" + noop-logger "^0.1.1" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + which-pm-runs "^1.0.0" + precond@0.2: version "0.2.3" resolved "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac" @@ -21061,7 +21261,7 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^1.18.2: +prettier@^1.18.2, prettier@^1.19.1: version "1.19.1" resolved "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== @@ -21293,7 +21493,7 @@ pseudomap@^1.0.2: resolved "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.28: +psl@^1.1.28, psl@^1.1.33: version "1.8.0" resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== @@ -21386,6 +21586,11 @@ qs@^6.5.1, qs@^6.6.0, qs@^6.9.1, qs@^6.9.4: resolved "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687" integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ== +qs@^6.7.0: + version "6.9.6" + resolved "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz#26ed3c8243a431b2924aca84cc90471f35d5a0ee" + integrity sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ== + qs@~6.5.2: version "6.5.2" resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -21754,7 +21959,7 @@ react-inspector@^5.0.1: is-dom "^1.1.0" prop-types "^15.6.1" -react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -21871,7 +22076,7 @@ react-resize-detector@^2.3.0: prop-types "^15.6.0" resize-observer-polyfill "^1.5.0" -react-router-dom@6.0.0-beta.0: +react-router-dom@6.0.0-beta.0, react-router-dom@^6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-beta.0.tgz#9dcc8555365f22f7fbd09f26b6b82543f3eb97d6" integrity sha512-36yNNGMT8RB9FRPL9nKJi6HKDkgOakU+o/2hHpSzR6e37gN70MpOU6QQlmif4oAWWBwjyGc3ZNOMFCsFuHUY5w== @@ -21879,35 +22084,6 @@ react-router-dom@6.0.0-beta.0: prop-types "^15.7.2" react-router "6.0.0-beta.0" -react-router-dom@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" - integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - loose-envify "^1.3.1" - prop-types "^15.6.2" - react-router "5.2.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - -react-router@5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" - integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - hoist-non-react-statics "^3.1.0" - loose-envify "^1.3.1" - mini-create-react-context "^0.4.0" - path-to-regexp "^1.7.0" - prop-types "^15.6.2" - react-is "^16.6.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - react-router@6.0.0-beta.0, react-router@^6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-beta.0.tgz#3e11f39b6ded4412c2fed9e4f989dd4c8156724d" @@ -22686,6 +22862,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" @@ -22753,11 +22934,6 @@ resolve-from@^4.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve-pathname@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" - integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== - resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -24535,6 +24711,16 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== +tar-fs@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + tar-fs@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz#e44086c1c60d31a4f0cf893b1c4e155dabfae9e2" @@ -24826,7 +25012,7 @@ tiny-emitter@^2.0.0: resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tiny-invariant@^1.0.2, tiny-invariant@^1.0.6: +tiny-invariant@^1.0.6: version "1.1.0" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== @@ -24836,7 +25022,7 @@ tiny-merge-patch@^0.1.2: resolved "https://registry.npmjs.org/tiny-merge-patch/-/tiny-merge-patch-0.1.2.tgz#2e8ded19c56ea15dbd3ad4ed5db1c8e5ad544c3c" integrity sha1-Lo3tGcVuoV29OtTtXbHI5a1UTDw= -tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: +tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== @@ -24970,6 +25156,15 @@ tough-cookie@^3.0.1: psl "^1.1.28" punycode "^2.1.1" +tough-cookie@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" + integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.1.2" + tr46@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" @@ -25177,7 +25372,7 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" -tunnel@0.0.6: +tunnel@0.0.6, tunnel@^0.0.6: version "0.0.6" resolved "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== @@ -25490,7 +25685,7 @@ universal-user-agent@^6.0.0: resolved "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== -universalify@^0.1.0: +universalify@^0.1.0, universalify@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== @@ -25797,11 +25992,6 @@ validate.io-number@^1.0.3: resolved "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz#f63ffeda248bf28a67a8d48e0e3b461a1665baf8" integrity sha1-9j/+2iSL8opnqNSODjtGGhZluvg= -value-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" - integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== - vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -26437,7 +26627,7 @@ xml-name-validator@^3.0.0: resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== -xml2js@^0.4.23: +xml2js@^0.4.19, xml2js@^0.4.23: version "0.4.23" resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==