diff --git a/.changeset/afraid-carpets-know.md b/.changeset/afraid-carpets-know.md new file mode 100644 index 0000000000..94a5452606 --- /dev/null +++ b/.changeset/afraid-carpets-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Add Optional Props to Override Icon for SidebarSearch and SidebarSearchModal Component diff --git a/.changeset/brave-impalas-switch.md b/.changeset/brave-impalas-switch.md new file mode 100644 index 0000000000..729a4ec96a --- /dev/null +++ b/.changeset/brave-impalas-switch.md @@ -0,0 +1,22 @@ +--- +'@backstage/backend-common': patch +--- + +Fixed bug in backend-common to allow passing of remote option in order to enable passing remote url in --config option. The remote option should be passed along with reloadIntervalSeconds from packages/backend/src/index.ts (Updated the file as well) + +These changes are needed in `packages/backend/src/index.ts` if remote URLs are desired to be passed in --config option and read and watch remote files for config. + +```diff +@@ -86,7 +86,11 @@ async function main() { + const config = await loadBackendConfig({ + argv: process.argv, + logger, ++ remote: { ++ reloadIntervalSeconds: 60 * 10 // Check remote config changes every 10 minutes. Change to your desired interval in seconds ++ } + }); ++ + const createEnv = makeCreateEnv(config); + + const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); +``` diff --git a/.changeset/cuddly-cooks-enjoy.md b/.changeset/cuddly-cooks-enjoy.md new file mode 100644 index 0000000000..d39844e7a9 --- /dev/null +++ b/.changeset/cuddly-cooks-enjoy.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': patch +--- + +In case remote.reloadIntervalSeconds is passed, it must be a valid positive value diff --git a/.changeset/cuddly-suns-sit.md b/.changeset/cuddly-suns-sit.md new file mode 100644 index 0000000000..f1fe16add6 --- /dev/null +++ b/.changeset/cuddly-suns-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Fix undefined identity bug in UserSettingsProfileCard caused by using deprecated methods of the IdentityApi diff --git a/.changeset/fluffy-grapes-decide.md b/.changeset/fluffy-grapes-decide.md new file mode 100644 index 0000000000..17a6cc965f --- /dev/null +++ b/.changeset/fluffy-grapes-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added peerPluginDependencies option to experimentalInstallationRecipe for install command to install plugins it depends on. diff --git a/.changeset/fresh-months-approve.md b/.changeset/fresh-months-approve.md new file mode 100644 index 0000000000..c2da09daf6 --- /dev/null +++ b/.changeset/fresh-months-approve.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': minor +--- + +Add new property to enable open links in a new window/tab diff --git a/.changeset/funny-chefs-guess.md b/.changeset/funny-chefs-guess.md new file mode 100644 index 0000000000..66824892f2 --- /dev/null +++ b/.changeset/funny-chefs-guess.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Fixed bug in OwnershipCard component where text wasn't correctly pluralized diff --git a/.changeset/gentle-humans-hope.md b/.changeset/gentle-humans-hope.md new file mode 100644 index 0000000000..b8c337cec9 --- /dev/null +++ b/.changeset/gentle-humans-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-rails': patch +--- + +Add new options to rails new (force and skipTests) diff --git a/.changeset/gold-seas-wave.md b/.changeset/gold-seas-wave.md new file mode 100644 index 0000000000..ec0c6451b7 --- /dev/null +++ b/.changeset/gold-seas-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-common': minor +--- + +Create catalog-common and add catalog permissions. diff --git a/.changeset/good-fans-dance.md b/.changeset/good-fans-dance.md new file mode 100644 index 0000000000..a24b467c01 --- /dev/null +++ b/.changeset/good-fans-dance.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-bazaar': patch +'@backstage/plugin-bazaar-backend': patch +--- + +made the linkage between a Bazaar project to a catalog Entity optional diff --git a/.changeset/many-trees-happen.md b/.changeset/many-trees-happen.md new file mode 100644 index 0000000000..f29c11fb22 --- /dev/null +++ b/.changeset/many-trees-happen.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': minor +--- + +Removed deprecated option `env` from `LoadConfigOptions` and associated tests diff --git a/.changeset/neat-stingrays-decide.md b/.changeset/neat-stingrays-decide.md new file mode 100644 index 0000000000..f339d7ded6 --- /dev/null +++ b/.changeset/neat-stingrays-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': minor +--- + +Removed deprecated `Keyboard` class which has been superseded by `@testing-library/user-event#userEvent` diff --git a/.changeset/new-suns-agree.md b/.changeset/new-suns-agree.md new file mode 100644 index 0000000000..c93bb1ca12 --- /dev/null +++ b/.changeset/new-suns-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-rollbar-backend': patch +--- + +Bump `axios` diff --git a/.changeset/new-waves-rule.md b/.changeset/new-waves-rule.md new file mode 100644 index 0000000000..7470a1d08e --- /dev/null +++ b/.changeset/new-waves-rule.md @@ -0,0 +1,7 @@ +--- +'@backstage/core-app-api': minor +--- + +- Removed deprecated definition `createApp` from `@backstage/core-app-api` which has been replaced by `@backstage/app-defaults#createApp` +- Removed deprecated type `BackstagePluginWithAnyOutput` +- Removed deprecated constructors for `GithubAuth`, `OAuth2`, and `SamlAuth` as the `create` method should be used instead diff --git a/.changeset/nice-apricots-grow.md b/.changeset/nice-apricots-grow.md new file mode 100644 index 0000000000..290041091d --- /dev/null +++ b/.changeset/nice-apricots-grow.md @@ -0,0 +1,8 @@ +--- +'@backstage/core-plugin-api': minor +--- + +- Removed deprecated option `description` from `ApiRefConfig` +- Removed descriptions from all plugin API refs +- Removed deprecated parameters `path`, `icon`, and `title` in `createRouteRef` +- Removed deprecated types `Error` and `ErrorContext` from `ErrorApi` diff --git a/.changeset/olive-cameras-rush.md b/.changeset/olive-cameras-rush.md new file mode 100644 index 0000000000..9a83aea699 --- /dev/null +++ b/.changeset/olive-cameras-rush.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': minor +--- + +Removed deprecated `msw` definition which was replaced by calling `setupRequestMockHandlers` directly diff --git a/.changeset/perfect-apricots-raise.md b/.changeset/perfect-apricots-raise.md new file mode 100644 index 0000000000..d7a40e6343 --- /dev/null +++ b/.changeset/perfect-apricots-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': minor +--- + +Removed deprecated option `configPaths` as it has been superseded by `configTargets` diff --git a/.changeset/rare-ladybugs-invite.md b/.changeset/rare-ladybugs-invite.md new file mode 100644 index 0000000000..4153363b9c --- /dev/null +++ b/.changeset/rare-ladybugs-invite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Fixed the fallback identity population to correctly generate an entity reference for `userEntityRef` if no token is provided. diff --git a/.changeset/short-rockets-remain.md b/.changeset/short-rockets-remain.md new file mode 100644 index 0000000000..603caec71a --- /dev/null +++ b/.changeset/short-rockets-remain.md @@ -0,0 +1,9 @@ +--- +'@backstage/app-defaults': patch +'@backstage/core-app-api': minor +'@backstage/core-plugin-api': minor +--- + +- Removed deprecation configuration option `theme` from `AppTheme` of the `AppThemeApi` +- Removed reference to `theme` in the `app-defaults` default `AppTheme` +- Removed logic in `AppThemeProvider` that creates `ThemeProvider` from `appTheme.theme` diff --git a/.changeset/sixty-pandas-switch.md b/.changeset/sixty-pandas-switch.md new file mode 100644 index 0000000000..91e767e154 --- /dev/null +++ b/.changeset/sixty-pandas-switch.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-search-backend': minor +--- + +Search result location filtering + +This change introduces a filter for search results based on their location protocol. The intention is to filter out unsafe or +malicious values before they can be consumed by the frontend. By default locations must be http/https URLs (or paths). diff --git a/.changeset/thick-deers-trade.md b/.changeset/thick-deers-trade.md new file mode 100644 index 0000000000..9b023d709a --- /dev/null +++ b/.changeset/thick-deers-trade.md @@ -0,0 +1,17 @@ +--- +'@backstage/core-components': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-circleci': patch +'@backstage/plugin-cloudbuild': patch +'@backstage/plugin-github-actions': patch +'@backstage/plugin-gitops-profiles': patch +'@backstage/plugin-jenkins': patch +'@backstage/plugin-lighthouse': patch +'@backstage/plugin-org': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-search': patch +'@backstage/plugin-sentry': patch +'@backstage/plugin-sonarqube': patch +--- + +Prefer using `Link` from `@backstage/core-components` rather than material-UI. diff --git a/.changeset/twenty-tigers-smash.md b/.changeset/twenty-tigers-smash.md new file mode 100644 index 0000000000..3ad694fbba --- /dev/null +++ b/.changeset/twenty-tigers-smash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins-backend': patch +--- + +feature: add crumbIssuer option to Jenkins (optional) configuration, improve the UI to show a notification after executing the action re-build diff --git a/.changeset/twenty-tigers-ymash.md b/.changeset/twenty-tigers-ymash.md new file mode 100644 index 0000000000..c3e03ba75d --- /dev/null +++ b/.changeset/twenty-tigers-ymash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-jenkins': patch +--- + +feature: add crumbIssuer option to Jenkins (optional) configuration, improve the UI to show a notification after executing the action re-build diff --git a/.changeset/wise-melons-hope.md b/.changeset/wise-melons-hope.md new file mode 100644 index 0000000000..24a615a7bc --- /dev/null +++ b/.changeset/wise-melons-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Schema-validate local storage cached session info on load diff --git a/.changeset/young-dodos-bake.md b/.changeset/young-dodos-bake.md new file mode 100644 index 0000000000..f620c6a1a4 --- /dev/null +++ b/.changeset/young-dodos-bake.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': minor +--- + +Removed deprecated types `ApiRefType` and `ApiRefsToTypes` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ed4b61099f..9cc5a2378e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -42,3 +42,4 @@ /.changeset/search-* @backstage/techdocs-core /.changeset/techdocs-* @backstage/techdocs-core /cypress/src/integration/plugins/techdocs.spec.ts @backstage/techdocs-core +/plugins/apache-airflow @backstage/reviewers @cmpadden diff --git a/.github/labeler.yml b/.github/labeler.yml index 5f513b3db8..f141107482 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -9,3 +9,16 @@ search: - plugins/search/**/* - plugins/search-*/**/* - packages/search-*/**/* +docs-like-code: + - plugins/techdocs/**/* + - plugins/techdocs-*/**/* + - packages/techdocs-*/**/* +documentation: + - docs/**/* +microsite: + - microsite/**/* +auth: + - plugins/auth-backend/**/* + - packages/core-app-api/src/apis/implementations/auth/**/* + - packages/core-app-api/src/lib/Auth*/**/* + - packages/core-plugin-api/src/apis/definitions/auth.ts diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 5233992cf9..8199855c9e 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -92,10 +92,12 @@ Figma firehydrant FireHydrant Firekube +Firestore Fiverr gitbeaker GitHub GitLab +Gource Grafana graphql GraphQL @@ -189,6 +191,7 @@ OAuth octokit oidc Okta +Oldsberg onboarding Onboarding OpenShift @@ -196,6 +199,8 @@ orgs pagerduty pageview parallelization +Patrik +Peloton plantuml Platformize Podman @@ -295,6 +300,7 @@ truthy typeahead ui unbreak +Unconference unmanaged unregister unregistration @@ -306,6 +312,7 @@ utils validator validators varchar +Wayfair Weaveworks Webpack winston @@ -318,4 +325,3 @@ Zalando Zhou zoomable zsh -Firestore diff --git a/README.md b/README.md index 1078631926..3e91cfe7c3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ # [Backstage](https://backstage.io) +> 🎄 The maintainers will be taking a break over the holidays from week beginning 20th Dec. The Repository and Discord may be quieter than usual. We will be back next year, rested and restored, on Jan. 3. 🎄 + +> 🎅 Happy holidays and a Happy New Year to all, and especially those that have made this year special for Backstage! 🎅 + [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![CNCF Status](https://img.shields.io/badge/cncf%20status-sandbox-blue.svg)](https://www.cncf.io/projects) [![Main CI Build](https://github.com/backstage/backstage/workflows/Main%20Master%20Build/badge.svg)](https://github.com/backstage/backstage/actions?query=workflow%3A%22Main+Master+Build%22) diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md index 14920513af..9ebcfc0945 100644 --- a/docs/api/utility-apis.md +++ b/docs/api/utility-apis.md @@ -128,8 +128,8 @@ the core APIs. The core APIs are the ones exported by [configApiRef](../reference/core-plugin-api.configapiref.md). The core APIs are loaded for any app created with -[createApp](../reference/core-app-api.createapp.md) from -[@backstage/core-plugin-api](../reference/core-plugin-api.md), which means that +[createApp](../reference/app-defaults.createapp.md) from +[@backstage/core-plugin-api](../reference/app-defaults.md), which means that there is no step that needs to be taken to include these APIs in an app. ### Plugin APIs @@ -168,7 +168,7 @@ Lastly, the app itself is the final point where APIs can be added, and what has the final say in what APIs will be loaded at runtime. The app may override the factories for any of the core or plugin APIs, with the exception of the config, app theme, and identity APIs. These are static APIs that are tied into the -[createApp](../reference/core-app-api.createapp.md) implementation, and +[createApp](../reference/app-defaults.createapp.md) implementation, and therefore not possible to override. Overriding APIs is useful for apps that want to switch out behavior to tailor it @@ -313,7 +313,7 @@ The common development environment for plugins is included in [createDevApp](../reference/dev-utils.createdevapp.md) function creates an application with implementations for all core APIs already present. Contrary to the method for wiring up Utility API implementations in an app created with -[createApp](../reference/core-app-api.createapp.md), +[createApp](../reference/app-defaults.createapp.md), [createDevApp](../reference/dev-utils.createdevapp.md) uses automatic dependency injection. This is to make it possible to replace any API implementation, and having that be reflected in dependents of that API. diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md index 1e24235f1a..ef79127223 100644 --- a/docs/auth/microsoft/provider.md +++ b/docs/auth/microsoft/provider.md @@ -48,6 +48,6 @@ The Microsoft provider is a structure with three configuration keys: ## Adding the provider to the Backstage frontend -To add the provider to the frontend, add the `microsoftAuthApi` reference and +To add the provider to the frontend, add the `microsoftAuthApiRef` reference and `SignInPage` component as shown in [Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). diff --git a/docs/conf/writing.md b/docs/conf/writing.md index 7945d6c980..a4da1e3df0 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -67,7 +67,7 @@ production build. ## Configuration Files -It is possible to have multiple configuration files (bundled and/or remote), +It is possible to have multiple configuration files (bundled and/or remote\*), both to support different environments, but also to define configuration that is local to specific packages. The configuration files to load are selected using a `--config ` flag, and it is possible to load any number of @@ -77,6 +77,9 @@ root when running the backend, you would use `--config ../../my-config.yaml`, and for config file on a config server you would use `--config https://some.domain.io/app-config.yaml` +**Note**: In case URLs are passed, it is also needed to set the remote option in +the loadBackendConfig call. + If no `config` flags are specified, the default behavior is to load `app-config.yaml` and, if it exists, `app-config.local.yaml` from the repo root. In the provided project setup, `app-config.local.yaml` is `.gitignore`'d, making diff --git a/docs/features/software-templates/writing-custom-field-extensions.md b/docs/features/software-templates/writing-custom-field-extensions.md index ebb7af4398..fab2e1e2cb 100644 --- a/docs/features/software-templates/writing-custom-field-extensions.md +++ b/docs/features/software-templates/writing-custom-field-extensions.md @@ -26,7 +26,7 @@ You can create your own Field Extension by using the `API` like below: ```tsx -//packages/app/scaffolder/MyCustomExtension/MyCustomExtension.tsx +//packages/app/src/scaffolder/MyCustomExtension/MyCustomExtension.tsx import React from 'react'; import { FieldProps, FieldValidation } from '@rjsf/core'; import FormControl from '@material-ui/core/FormControl'; @@ -68,7 +68,7 @@ export const myCustomValidation = ( ``` ```tsx -// packages/app/scaffolder/MyCustomExtension/extensions.ts +// packages/app/src/scaffolder/MyCustomExtension/extensions.ts /* This is where the magic happens and creates the custom field extension. @@ -94,7 +94,7 @@ export const MyCustomFieldExtension = plugin.provide( ``` ```tsx -// packages/app/scaffolder/MyCustomExtension/index.ts +// packages/app/src/scaffolder/MyCustomExtension/index.ts export { MyCustomFieldExtension } from './extension'; ``` @@ -102,7 +102,7 @@ export { MyCustomFieldExtension } from './extension'; Once all these files are in place, you then need to provide your custom extension to the `scaffolder` plugin. -You do this in `packages/app/App.tsx`. You need to provide the +You do this in `packages/app/src/App.tsx`. You need to provide the `customFieldExtensions` as children to the `ScaffolderPage`. ```tsx @@ -118,7 +118,7 @@ const routes = ( Should look something like this instead: ```tsx -import { MyCustomFieldExtension } from './scafffolder/MyCustomExtension'; +import { MyCustomFieldExtension } from './scaffolder/MyCustomExtension'; const routes = ( ... diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index 241ae7ef03..a4ca0771df 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -54,7 +54,7 @@ For example, adding the theme that we created in the previous section can be done like this: ```ts -import { createApp } from '@backstage/core-app-api'; +import { createApp } from '@backstage/app-defaults'; const app = createApp({ apis: ..., diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md index 33568ac561..05af512f92 100644 --- a/docs/plugins/composability.md +++ b/docs/plugins/composability.md @@ -368,7 +368,7 @@ The following is an example of creation and usage of a parameterized route: ```tsx // Creation of a parameterized route const myRouteRef = createRouteRef({ - title: 'My Named Route', + id: 'myroute', params: ['name'] }) diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md index 249507ddf0..126bb5ae0d 100644 --- a/docs/tutorials/journey.md +++ b/docs/tutorials/journey.md @@ -48,7 +48,6 @@ that implements the `OAuthApi` type, it's now working in the frontend too. ```ts const spotifyAuthApiRef = createApiRef({ id: 'core.auth.spotify', - description: 'Provides authentication towards Spotify APIs', }); ``` diff --git a/docs/tutorials/using-backstage-proxy-within-plugin.md b/docs/tutorials/using-backstage-proxy-within-plugin.md index b0c6bef8ef..771aa7a652 100644 --- a/docs/tutorials/using-backstage-proxy-within-plugin.md +++ b/docs/tutorials/using-backstage-proxy-within-plugin.md @@ -99,7 +99,6 @@ export interface MyAwesomeApi { export const myAwesomeApiRef = createApiRef({ id: 'plugin.my-awesome-api.service', - description: 'Example API definition', }); ``` diff --git a/microsite/blog/2021-12-20-backstage-wrapped-2021.md b/microsite/blog/2021-12-20-backstage-wrapped-2021.md new file mode 100644 index 0000000000..2a6234a9f6 --- /dev/null +++ b/microsite/blog/2021-12-20-backstage-wrapped-2021.md @@ -0,0 +1,145 @@ +--- +title: Backstage Wrapped 2021 +author: Lee Mills, Spotify +authorURL: https://github.com/leemills83 +authorImageURL: https://avatars1.githubusercontent.com/u/1236238?s=460&v=4 +--- + +![Backstage in 2021: 75+ public adopters, 500+ contributors, almost 2,000 forks, 9,000+ contributions, 4,000+ Discord members](assets/21-12-20/backstage-wrapped-2021.png) + +What a year 2021 has been for the Backstage project and community. As the year comes to a close, we can look back at some of the happenings, the successes, and welcome all the newcomers to our growing community. + +In a year that has had its fair share of ups and downs, this community has been something that has brought me so much in the support you provide each other, your eagerness, passion, and willingness to share. Thank you to all of you for creating such a great community, and welcome to everyone joining us. I can’t wait to meet you all and see what great things we can create together in the new year. + +In 2021 we’ve seen the project and community grow in every dimension, reaching over 75 public adopters, over 500 contributors from around the world all bringing new ideas and perspectives. We’ve had so many of you sharing and demoing your hard work in our monthly community sessions, added new plugins, built new features and really started to see Backstage have an impact for engineering teams across so many of our users. + +Now let’s hear from the maintainers and look at the stats! + + + +## The community grew…and grew + +This year, Netflix, [Expedia][metrics], Wayfair, Peloton, [Box][10], [DAZN][dazn], TELUS, and Unity all officially joined the community — along with more than 50 other public [adopters][md]. Contributors grew by leaps and bounds, too. + +Patrik Oldsberg ([@Rugvip][rugvip]): + +> It’s been awesome to see the engagement by the community grow even more in 2021, especially the way people within the community support each other. Thanks to our growing number of stable, experienced, and knowledgeable contributors it’s now possible to get help in places like Discord anytime, day or night. + +### More adopters, more contributors + +![Accelerated growth of Public Backstage Adopters, from July 2020–December 2021.](assets/21-12-20/public-backstage-adopters-2021.png) + +In January, Backstage had 17 public adopters. By September, [we had 50][50]. And the curve only got steeper from there. + +- **75+ public adopters** +- **500+ contributors** +- **4,000+ Discord members** +- **9,000+ PRs & issues** + +### A very busy repo + +Along with the growth in contributors, PRs, and issues, our active, thriving community has crossed (or is just about to cross) other big milestones on GitHub, too. + +- **Nearly 2,000 forks** +- **20,000+ commits** +- **14,000+ GitHub stars** + +### Where in the world is the Backstage community? + +Watch the visualization below to see where in the world all those contributions came from this past year. + + + +### What does 9,000 contributions look like? + + + +Can you spot your name in the video above? + +Picking up from the end of 2020, the video shows every single contribution to the Backstage source code over this past year. The visualization was built using [Gource.io][gource]: + +> Software projects are displayed by Gource as an animated tree with the root directory of the project at its centre. Directories appear as branches with files as leaves. Developers can be seen working on the tree at the times they contributed to the project. + +And if your name isn’t in there, it’s never too late to get started [contributing][contributing] — so that you too can become a bright, sparkling, laser-beam-shooting spider-node thing in 2022! + +## Other 2021 milestones + +As the community grew, so did the product — with the addition of new features as well as important performance and stability improvements to the core. + +Ben Lambert ([@benjdlambert][blam]): + +> We’ve had a lot of priorities this year. Our first priority was rebuilding the catalog to make it more efficient. Then, toward the later part of the year, we’ve been stabilizing the core with deprecations and API changes ahead of our 1.0 release of the Core Framework next year. +> +> It’s been a super productive year for us all as Core Maintainers, and the product has come on leaps and bounds with the help of everyone involved in the project! + +### New features + +These new features were built with the community, and made with customization and extensibility in mind. + +- **Kubernetes Service Monitoring ([RFC][k8s])** +- **Search Platform ([Alpha][search])** +- **Composable Homepage ([README][home])** +- **Authorization Framework ([RFC][auth])** + +### Major stability updates + +As we get the ball rolling for [CNCF Incubation][cncf], we’ve been busy updating, refining, documenting, deprecating, and stabilizing the core. + +- **Software Templates ([Beta][scaffolder])** +- **TechDocs ([Beta][techdocs])** +- **API Reference ([docs][api])** + +### 50+ open source plugins + +We reached [50+ plugins][plugins] built by the community this year, including official plugins (**Snyk**, **FireHydrant**), highly requested plugins (**PagerDuty**, **Prometheus**), and plugins based on new ideas and functionality (**XCMetrics**, **[Tech Insights][techrfc]**) — all tailor-made for Backstage. + +### In the spotlight + +This year Backstage was center stage at both community-organized events and at major conferences. + +- **New (and [expanded][09]) Community Sessions** +- **Backstage Users Unconference + [Open Mic][openmic]** +- **KubeCon + CloudNativeCon [Europe][europe] & [North America][na]** +- **GitHub Universe 2021 [Keynote][day2]** + +Find more talks, podcasts, blog posts, and Backstage in the news [here][news]. + +## Goodbye, 2021. Hello, 2022! + +Lastly, thanks to everyone in the global Backstage community who contributed to the open source project in 2021. + +This was the first year Spotify hosted [Backstage Community Sessions][meetups], and it’s been a great source of inspiration, motivation, and joy getting to know all the people behind the contributions. We look forward to seeing all your faces again in 2022 — and hopefully many new ones as well. + +Until then, [grab this Backstage Zoom background][zoom] to use at the next Community Session. + +![Backstage Zoom Background](assets/21-12-20/Backstage-background-02-2021.png) + +See you then! Happy holidays from everyone on the Backstage team at Spotify! + +[metrics]: https://backstage.spotify.com/blog/measuring-backstage-proof-of-value-at-expedia/ +[10]: https://backstage.spotify.com/blog/community-session/10-box-and-spotify-paid-plugins/ +[dazn]: https://backstage.spotify.com/blog/adopter-spotlight/dazn/ +[md]: https://github.com/backstage/backstage/blob/master/ADOPTERS.md +[rugvip]: https://github.com/Rugvip +[50]: https://backstage.io/blog/2021/09/30/50-public-adopters +[gource]: https://gource.io +[contributing]: https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md +[blam]: https://github.com/benjdlambert +[k8s]: https://github.com/backstage/backstage/issues/2857 +[search]: https://backstage.io/blog/2021/06/24/announcing-backstage-search-platform +[home]: https://github.com/backstage/backstage/blob/master/plugins/home/README.md +[auth]: https://backstage.spotify.com/blog/authorization-framework-rfc/ +[scaffolder]: https://backstage.io/blog/2021/07/26/software-templates-are-now-in-beta +[techdocs]: https://backstage.io/blog/2021/09/16/the-techdocs-beta-has-landed +[api]: https://backstage.io/docs/reference/ +[cncf]: https://github.com/cncf/toc/pull/717 +[plugins]: https://backstage.io/plugins +[techrfc]: https://github.com/backstage/backstage/issues/7576 +[09]: https://backstage.spotify.com/blog/community-session/09-prometheus-homepage-templates/ +[openmic]: https://backstage-openmic.com +[europe]: https://www.youtube.com/watch?v=aIURaocR5D8 +[na]: https://backstage.spotify.com/blog/kubecon-na-2021/ +[day2]: https://www.youtube.com/watch?v=7yQJlYvpoYs&t=269s +[news]: https://github.com/backstage/community#newsletters +[meetups]: https://github.com/backstage/community#meetups +[zoom]: https://drive.google.com/file/d/14wOPXEBZ5Qmi29ms4Sqp1m56dM0AIHbB/view?usp=sharing diff --git a/microsite/blog/assets/21-12-20/Backstage-background-02-2021.png b/microsite/blog/assets/21-12-20/Backstage-background-02-2021.png new file mode 100644 index 0000000000..bf3c72ba70 Binary files /dev/null and b/microsite/blog/assets/21-12-20/Backstage-background-02-2021.png differ diff --git a/microsite/blog/assets/21-12-20/backstage-wrapped-2021.png b/microsite/blog/assets/21-12-20/backstage-wrapped-2021.png new file mode 100644 index 0000000000..6ac5d5f67c Binary files /dev/null and b/microsite/blog/assets/21-12-20/backstage-wrapped-2021.png differ diff --git a/microsite/blog/assets/21-12-20/public-backstage-adopters-2021.png b/microsite/blog/assets/21-12-20/public-backstage-adopters-2021.png new file mode 100644 index 0000000000..aac981d5f9 Binary files /dev/null and b/microsite/blog/assets/21-12-20/public-backstage-adopters-2021.png differ diff --git a/packages/app-defaults/src/defaults/components.tsx b/packages/app-defaults/src/defaults/components.tsx index 139bea4862..93a5d9cd50 100644 --- a/packages/app-defaults/src/defaults/components.tsx +++ b/packages/app-defaults/src/defaults/components.tsx @@ -73,7 +73,7 @@ const DefaultErrorBoundaryFallback = ({ }; /** - * Creates a set of default components to pass along to {@link @backstage/core-app-api#createApp}. + * Creates a set of default components to pass along to {@link @backstage/core-app-api#createSpecializedApp}. * * @public */ diff --git a/packages/app-defaults/src/defaults/themes.tsx b/packages/app-defaults/src/defaults/themes.tsx index 90a4a0f47f..397c160168 100644 --- a/packages/app-defaults/src/defaults/themes.tsx +++ b/packages/app-defaults/src/defaults/themes.tsx @@ -28,7 +28,6 @@ export const themes: AppTheme[] = [ title: 'Light Theme', variant: 'light', icon: , - theme: lightTheme, Provider: ({ children }) => ( {children} @@ -40,7 +39,6 @@ export const themes: AppTheme[] = [ title: 'Dark Theme', variant: 'dark', icon: , - theme: darkTheme, Provider: ({ children }) => ( {children} diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index d20f568e98..cbdfba3c38 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -21,6 +21,7 @@ import { GitLabIntegration } from '@backstage/integration'; import { isChildPath } from '@backstage/cli-common'; import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; +import { LoadConfigOptionsRemote } from '@backstage/config-loader'; import { Logger as Logger_2 } from 'winston'; import { MergeResult } from 'isomorphic-git'; import { PushResult } from 'isomorphic-git'; @@ -338,6 +339,7 @@ export function isDatabaseConflictError(e: unknown): boolean; // @public export function loadBackendConfig(options: { logger: Logger_2; + remote?: LoadConfigOptionsRemote; argv: string[]; }): Promise; diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 941ad26e65..399898cfc4 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -23,6 +23,7 @@ import { loadConfig, ConfigSchema, ConfigTarget, + LoadConfigOptionsRemote, } from '@backstage/config-loader'; import { AppConfig, Config, ConfigReader } from '@backstage/config'; import { JsonValue } from '@backstage/types'; @@ -178,6 +179,7 @@ let currentCancelFunc: () => void; export async function loadBackendConfig(options: { logger: Logger; // process.argv or any other overrides + remote?: LoadConfigOptionsRemote; argv: string[]; }): Promise { const args = parseArgs(options.argv); @@ -202,8 +204,8 @@ export async function loadBackendConfig(options: { const config = new ObservableConfigProxy(options.logger); const { appConfigs } = await loadConfig({ configRoot: paths.targetRoot, - configPaths: [], configTargets: configTargets, + remote: options.remote, watch: { onChange(newConfigs) { options.logger.info( diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 4426ed7767..7a79c278ff 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -89,6 +89,7 @@ async function main() { argv: process.argv, logger, }); + const createEnv = makeCreateEnv(config); const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); diff --git a/packages/cli/src/commands/install/install.ts b/packages/cli/src/commands/install/install.ts index cc6e46cfa2..bd3a05ebc1 100644 --- a/packages/cli/src/commands/install/install.ts +++ b/packages/cli/src/commands/install/install.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { Step, PackageWithInstallRecipe } from './types'; +import { + Step, + PackageWithInstallRecipe, + PeerPluginDependencies, +} from './types'; import { fetchPackageInfo } from '../../lib/versioning'; import { NotFoundError } from '../../lib/errors'; import * as stepDefinitionMap from './steps'; @@ -52,7 +56,10 @@ type Steps = Array<{ }>; class PluginInstaller { - static async resolveSteps(pkg: PackageWithInstallRecipe) { + static async resolveSteps( + pkg: PackageWithInstallRecipe, + versionToInstall?: string, + ) { const steps: Steps = []; // collectDependencies @@ -62,7 +69,7 @@ class PluginInstaller { target: 'packages/app', type: 'dependencies' as const, name: pkg.name, - query: `^${pkg.version}`, + query: versionToInstall || `^${pkg.version}`, }); steps.push({ type: 'dependencies', @@ -97,22 +104,64 @@ class PluginInstaller { } } -export default async (pluginId?: string, cmd?: Command) => { - // TODO(himanshu): If no plugin id is provided, it should list all plugins available. Maybe in some other command? +async function installPluginAndPeerPlugins(pkg: PackageWithInstallRecipe) { + const pluginDeps: PeerPluginDependencies = new Map(); + pluginDeps.set(pkg.name, { pkg }); + await loadPeerPluginDeps(pkg, pluginDeps); - let pkg: PackageWithInstallRecipe; - if (pluginId) { - pkg = await fetchPluginPackage(pluginId); - } else if (cmd?.from) { - // TODO(himanshu): Also support reading directly from url - pkg = await fs.readJson(cmd.from); - } else { + console.log(`Installing ${pkg.name} AND any peer plugin dependencies.`); + for (const [_pluginDepName, pluginDep] of pluginDeps.entries()) { + const { pkg: pluginDepPkg, versionToInstall } = pluginDep; + console.log( + `Installing plugin: ${pluginDepPkg.name}: ${ + versionToInstall || pluginDepPkg.version + }`, + ); + const steps = await PluginInstaller.resolveSteps( + pluginDepPkg, + versionToInstall, + ); + const installer = new PluginInstaller(steps); + await installer.run(); + } +} + +async function loadPackageJson( + plugin: string, +): Promise { + if (plugin.endsWith('package.json')) { + // Install from local package if pluginId is a package.json file - needs to be absolute path + return await fs.readJson(plugin); + } + return await fetchPluginPackage(plugin); +} + +async function loadPeerPluginDeps( + pkg: PackageWithInstallRecipe, + pluginMap: PeerPluginDependencies, +) { + for (const [pluginId, pluginVersion] of Object.entries( + pkg.experimentalInstallationRecipe?.peerPluginDependencies ?? {}, + )) { + const depPkg = await loadPackageJson(pluginId); + if (!pluginMap.get(depPkg.name)) { + pluginMap.set(depPkg.name, { + pkg: depPkg, + versionToInstall: pluginVersion, + }); + await loadPeerPluginDeps(depPkg, pluginMap); + } + } +} + +export default async (pluginId?: string, cmd?: Command) => { + const from = pluginId || cmd?.from; + // TODO(himanshu): If no plugin id is provided, it should list all plugins available. Maybe in some other command? + if (!from) { throw new Error( 'Missing both or a package.json file path in the --from flag.', ); } - - const steps = await PluginInstaller.resolveSteps(pkg); - const installer = new PluginInstaller(steps); - await installer.run(); + const pkg = await loadPackageJson(from); + await installPluginAndPeerPlugins(pkg); }; diff --git a/packages/cli/src/commands/install/types.ts b/packages/cli/src/commands/install/types.ts index 3105f78822..1fa52a5e82 100644 --- a/packages/cli/src/commands/install/types.ts +++ b/packages/cli/src/commands/install/types.ts @@ -38,6 +38,7 @@ export type SerializedStep = { export type InstallationRecipe = { type?: 'frontend' | 'backend'; steps: SerializedStep[]; + peerPluginDependencies: { [pluginId: string]: string }; }; /** package.json data */ @@ -46,6 +47,14 @@ export type PackageWithInstallRecipe = YarnInfoInspectData & { experimentalInstallationRecipe?: InstallationRecipe; }; +export type PeerPluginDependencies = Map< + string, + { + pkg: PackageWithInstallRecipe; + versionToInstall?: string; + } +>; + export interface Step { run(): Promise; } diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index d343a554f8..f85db276b2 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -59,7 +59,6 @@ export async function loadCliConfig(options: Options) { ? async name => process.env[name] || 'x' : undefined, configRoot: paths.targetRoot, - configPaths: [], configTargets: configTargets, }); diff --git a/packages/codemods/src/tests/core-imports.test.ts b/packages/codemods/src/tests/core-imports.test.ts index d8dea7eb90..0b416e5c8e 100644 --- a/packages/codemods/src/tests/core-imports.test.ts +++ b/packages/codemods/src/tests/core-imports.test.ts @@ -45,9 +45,9 @@ return something() it('should refactor imports', () => { const input = ` /* COPYRIGHT: ME */ -import { Button as MyButton, createApiRef, createApp } from '@backstage/core'; +import { Button as MyButton, createApiRef, createSpecializedApp } from '@backstage/core'; -const app = createApp(); +const app = createSpecializedApp(); const apiRef = createApiRef(); const button = `; @@ -57,9 +57,9 @@ const button = import { Button as MyButton } from '@backstage/core-components'; import { createApiRef } from '@backstage/core-plugin-api'; -import { createApp } from '@backstage/core-app-api'; +import { createSpecializedApp } from '@backstage/core-app-api'; -const app = createApp(); +const app = createSpecializedApp(); const apiRef = createApiRef(); const button = `; diff --git a/packages/config-loader/api-report.md b/packages/config-loader/api-report.md index e0fab50e5b..269159aab4 100644 --- a/packages/config-loader/api-report.md +++ b/packages/config-loader/api-report.md @@ -45,9 +45,7 @@ export function loadConfig( // @public export type LoadConfigOptions = { configRoot: string; - configPaths: string[]; configTargets: ConfigTarget[]; - env?: string; experimentalEnvFunc?: (name: string) => Promise; remote?: LoadConfigOptionsRemote; watch?: LoadConfigOptionsWatch; @@ -103,8 +101,4 @@ export type TransformFunc = ( visibility: ConfigVisibility; }, ) => T | undefined; - -// Warnings were encountered during analysis: -// -// src/loader.d.ts:33:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/config-loader" does not have an export "configTargets" ``` diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index b5c986eeb9..9faeda64ea 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -118,9 +118,7 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [], - env: 'production', }), ).resolves.toEqual({ appConfigs: [ @@ -146,9 +144,7 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [{ url: configUrl }], - env: 'production', remote: { reloadIntervalSeconds: 30, }, @@ -173,9 +169,10 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: ['/root/app-config2.yaml'], - configTargets: [{ path: '/root/app-config.yaml' }], - env: 'production', + configTargets: [ + { path: '/root/app-config.yaml' }, + { path: '/root/app-config2.yaml' }, + ], }), ).resolves.toEqual({ appConfigs: [ @@ -207,9 +204,7 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: ['/root/app-config.yaml'], configTargets: [{ path: '/root/app-config.yaml' }], - env: 'production', }), ).resolves.toEqual({ appConfigs: [ @@ -231,12 +226,10 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [ { path: '/root/app-config.yaml' }, { path: '/root/app-config.development.yaml' }, ], - env: 'development', }), ).resolves.toEqual({ appConfigs: [ @@ -274,9 +267,7 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [{ path: '/root/app-config.substitute.yaml' }], - env: 'development', }), ).resolves.toEqual({ appConfigs: [ @@ -302,7 +293,6 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [], watch: { onChange: onChange.resolve, @@ -353,7 +343,6 @@ describe('loadConfig', () => { await expect( loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [{ url: configUrl }], watch: { onChange: onChange.resolve, @@ -401,7 +390,6 @@ describe('loadConfig', () => { await loadConfig({ configRoot: '/root', - configPaths: [], configTargets: [], watch: { onChange: () => { diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index 6a92ed519d..e16a49df89 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -45,7 +45,7 @@ export type LoadConfigOptionsWatch = { export type LoadConfigOptionsRemote = { /** - * An optional remote config reloading period, in seconds + * A remote config reloading period, in seconds */ reloadIntervalSeconds: number; }; @@ -59,17 +59,9 @@ export type LoadConfigOptions = { // The root directory of the config loading context. Used to find default configs. configRoot: string; - /** Absolute paths to load config files from. Configs from earlier paths have lower priority. - * @deprecated Use {@link configTargets} instead. - */ - configPaths: string[]; - // Paths to load config files from. Configs from earlier paths have lower priority. configTargets: ConfigTarget[]; - /** @deprecated This option has been removed */ - env?: string; - /** * Custom environment variable loading function * @@ -114,20 +106,21 @@ export async function loadConfig( .filter((e): e is { path: string } => e.hasOwnProperty('path')) .map(configTarget => configTarget.path); - // Append deprecated configPaths to the absolute config paths received via configTargets. - options.configPaths.forEach(cp => { - if (!configPaths.includes(cp)) { - configPaths.push(cp); - } - }); - const configUrls: string[] = options.configTargets .slice() .filter((e): e is { url: string } => e.hasOwnProperty('url')) .map(configTarget => configTarget.url); - if (remote === undefined && configUrls.length > 0) { - throw new Error(`Remote config detected but this feature is turned off`); + if (remote === undefined) { + if (configUrls.length > 0) { + throw new Error( + `Please make sure you are passing the remote option when loading remote configurations. See https://backstage.io/docs/conf/writing#configuration-files for detailed info.`, + ); + } + } else if (remote.reloadIntervalSeconds <= 0) { + throw new Error( + `Remote config must be contain a non zero reloadIntervalSeconds: value`, + ); } // If no paths are provided, we default to reading diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 78bfd83251..8746211a45 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -26,7 +26,6 @@ import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; import { ConfigReader } from '@backstage/config'; -import { createApp as createApp_2 } from '@backstage/app-defaults'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; import { ErrorApiError } from '@backstage/core-plugin-api'; @@ -277,19 +276,6 @@ export type BackstageApp = { getRouter(): ComponentType<{}>; }; -// @public @deprecated -export type BackstagePluginWithAnyOutput = Omit< - BackstagePlugin, - 'output' -> & { - output(): ( - | PluginOutput - | { - type: string; - } - )[]; -}; - // @public export class BitbucketAuth { // (undocumented) @@ -315,11 +301,6 @@ export type BootErrorPageProps = { export { ConfigReader }; -// @public @deprecated -export function createApp( - options?: Parameters[0], -): BackstageApp & AppContext; - // @public export function createFetchApi(options: { baseImplementation?: typeof fetch | undefined; @@ -408,10 +389,6 @@ export type FlatRoutesProps = { // @public export class GithubAuth implements OAuthApi, SessionApi { - // Warning: (ae-forgotten-export) The symbol "SessionManager" needs to be exported by the entry point index.d.ts - // - // @deprecated - constructor(sessionManager: SessionManager); // (undocumented) static create(options: OAuthApiCreateOptions): GithubAuth; // (undocumented) @@ -488,11 +465,6 @@ export class OAuth2 BackstageIdentityApi, SessionApi { - // @deprecated - constructor(options: { - sessionManager: SessionManager; - scopeTransform: (scopes: string[]) => string[]; - }); // (undocumented) static create(options: OAuth2CreateOptions): OAuth2; // (undocumented) @@ -573,8 +545,6 @@ export type OneLoginAuthCreateOptions = { export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi { - // @deprecated - constructor(sessionManager: SessionManager); // (undocumented) static create(options: AuthApiCreateOptions): SamlAuth; // (undocumented) diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 166f39a484..e1a982b7bd 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -42,7 +42,8 @@ "prop-types": "^15.7.2", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", - "zen-observable": "^0.8.15" + "zen-observable": "^0.8.15", + "zod": "^3.11.6" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts index 4da92efbdf..cb0f792524 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -14,25 +14,25 @@ * limitations under the License. */ -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { GithubSession } from './types'; import { + AuthRequestOptions, + BackstageIdentity, OAuthApi, + ProfileInfo, SessionApi, SessionState, - ProfileInfo, - BackstageIdentity, - AuthRequestOptions, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { AuthSessionStore, RefreshingAuthSessionManager, StaticAuthSessionManager, } from '../../../../lib/AuthSessionManager'; -import { OAuthApiCreateOptions } from '../types'; import { OptionalRefreshSessionManagerMux } from '../../../../lib/AuthSessionManager/OptionalRefreshSessionManagerMux'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { OAuthApiCreateOptions } from '../types'; +import { GithubSession, githubSessionSchema } from './types'; export type GithubAuthResponse = { providerInfo: { @@ -105,6 +105,7 @@ export default class GithubAuth implements OAuthApi, SessionApi { sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }), storageKey: `${provider.id}Session`, + schema: githubSessionSchema, sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); @@ -118,10 +119,9 @@ export default class GithubAuth implements OAuthApi, SessionApi { return new GithubAuth(sessionManagerMux); } - /** - * @deprecated will be made private in the future. Use create method instead. - */ - constructor(private readonly sessionManager: SessionManager) {} + private constructor( + private readonly sessionManager: SessionManager, + ) {} async signIn() { await this.getAccessToken(); diff --git a/packages/core-app-api/src/apis/implementations/auth/github/index.ts b/packages/core-app-api/src/apis/implementations/auth/github/index.ts index ee4334f6fc..b5aa1a0a25 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export * from './types'; +export type { GithubSession } from './types'; export { default as GithubAuth } from './GithubAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/github/types.ts b/packages/core-app-api/src/apis/implementations/auth/github/types.ts index e1c88b9173..fc289635ed 100644 --- a/packages/core-app-api/src/apis/implementations/auth/github/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/github/types.ts @@ -15,6 +15,7 @@ */ import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; +import { z } from 'zod'; /** * Session information for GitHub auth. @@ -30,3 +31,25 @@ export type GithubSession = { profile: ProfileInfo; backstageIdentity: BackstageIdentity; }; + +export const githubSessionSchema: z.ZodSchema = z.object({ + providerInfo: z.object({ + accessToken: z.string(), + scopes: z.set(z.string()), + expiresAt: z.date().optional(), + }), + profile: z.object({ + email: z.string().optional(), + displayName: z.string().optional(), + picture: z.string().optional(), + }), + backstageIdentity: z.object({ + id: z.string(), + token: z.string(), + identity: z.object({ + type: z.literal('user'), + userEntityRef: z.string(), + ownershipEntityRefs: z.array(z.string()), + }), + }), +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts index 4f030d1c64..b2398f64de 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts @@ -15,6 +15,8 @@ */ import OAuth2 from './OAuth2'; +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; const theFuture = new Date(Date.now() + 3600000); const thePast = new Date(Date.now() - 10); @@ -23,14 +25,24 @@ const PREFIX = 'https://www.googleapis.com/auth/'; const scopeTransform = (x: string[]) => x; +let getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + describe('OAuth2', () => { it('should get refreshed access token', async () => { - const getSession = jest.fn().mockResolvedValue({ + getSession = jest.fn().mockResolvedValue({ providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopeTransform, + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe( @@ -43,12 +55,13 @@ describe('OAuth2', () => { }); it('should transform scopes', async () => { - const getSession = jest.fn().mockResolvedValue({ + getSession = jest.fn().mockResolvedValue({ providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, + const oauth2 = OAuth2.create({ scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); expect(await oauth2.getAccessToken('my-scope')).toBe('access-token'); @@ -59,12 +72,13 @@ describe('OAuth2', () => { }); it('should get refreshed id token', async () => { - const getSession = jest.fn().mockResolvedValue({ + getSession = jest.fn().mockResolvedValue({ providerInfo: { idToken: 'id-token', expiresAt: theFuture }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopeTransform, + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); expect(await oauth2.getIdToken()).toBe('id-token'); @@ -72,12 +86,13 @@ describe('OAuth2', () => { }); it('should get optional id token', async () => { - const getSession = jest.fn().mockResolvedValue({ + getSession = jest.fn().mockResolvedValue({ providerInfo: { idToken: 'id-token', expiresAt: theFuture }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); expect(await oauth2.getIdToken({ optional: true })).toBe('id-token'); @@ -87,7 +102,7 @@ describe('OAuth2', () => { it('should share popup closed errors', async () => { const error = new Error('NOPE'); error.name = 'RejectedError'; - const getSession = jest + getSession = jest .fn() .mockResolvedValueOnce({ providerInfo: { @@ -97,9 +112,10 @@ describe('OAuth2', () => { }, }) .mockRejectedValue(error); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check @@ -120,7 +136,7 @@ describe('OAuth2', () => { scopes: new Set(), }, }; - const getSession = jest + getSession = jest .fn() .mockResolvedValueOnce(initialSession) .mockResolvedValue({ @@ -130,9 +146,10 @@ describe('OAuth2', () => { scopes: new Set(), }, }); - const oauth2 = new OAuth2({ - sessionManager: { getSession } as any, - scopeTransform, + const oauth2 = OAuth2.create({ + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), }); // Grab the expired session first diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts index 582526083b..18644a037f 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -120,10 +120,7 @@ export default class OAuth2 private readonly sessionManager: SessionManager; private readonly scopeTransform: (scopes: string[]) => string[]; - /** - * @deprecated will be made private in the future. Use create method instead. - */ - constructor(options: { + private constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; }) { diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts index 5988e81c48..98038b2f41 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -14,24 +14,24 @@ * limitations under the License. */ -import { DirectAuthConnector } from '../../../../lib/AuthConnector'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { - ProfileInfo, - BackstageIdentity, - SessionState, AuthRequestOptions, - ProfileInfoApi, + BackstageIdentity, BackstageIdentityApi, + ProfileInfo, + ProfileInfoApi, SessionApi, + SessionState, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; -import { SamlSession } from './types'; +import { DirectAuthConnector } from '../../../../lib/AuthConnector'; import { AuthSessionStore, StaticAuthSessionManager, } from '../../../../lib/AuthSessionManager'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { AuthApiCreateOptions } from '../types'; +import { SamlSession, samlSessionSchema } from './types'; export type SamlAuthResponse = { profile: ProfileInfo; @@ -72,6 +72,7 @@ export default class SamlAuth const authSessionStore = new AuthSessionStore({ manager: sessionManager, storageKey: `${provider.id}Session`, + schema: samlSessionSchema, }); return new SamlAuth(authSessionStore); @@ -81,10 +82,9 @@ export default class SamlAuth return this.sessionManager.sessionState$(); } - /** - * @deprecated will be made private in the future. Use create method instead. - */ - constructor(private readonly sessionManager: SessionManager) {} + private constructor( + private readonly sessionManager: SessionManager, + ) {} async signIn() { await this.getBackstageIdentity({}); diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/types.ts b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts index 724f0aa5d5..2e7c67553e 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/types.ts @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api'; + +import { BackstageIdentity, ProfileInfo } from '@backstage/core-plugin-api'; +import { z } from 'zod'; /** * Session information for SAML auth. @@ -25,3 +27,21 @@ export type SamlSession = { profile: ProfileInfo; backstageIdentity: BackstageIdentity; }; + +export const samlSessionSchema: z.ZodSchema = z.object({ + userId: z.string(), + profile: z.object({ + email: z.string().optional(), + displayName: z.string().optional(), + picture: z.string().optional(), + }), + backstageIdentity: z.object({ + id: z.string(), + token: z.string(), + identity: z.object({ + type: z.literal('user'), + userEntityRef: z.string(), + ownershipEntityRefs: z.array(z.string()), + }), + }), +}); diff --git a/packages/core-app-api/src/app/AppThemeProvider.tsx b/packages/core-app-api/src/app/AppThemeProvider.tsx index 2e145025d5..b2c800f673 100644 --- a/packages/core-app-api/src/app/AppThemeProvider.tsx +++ b/packages/core-app-api/src/app/AppThemeProvider.tsx @@ -15,8 +15,6 @@ */ import React, { useMemo, useEffect, useState, PropsWithChildren } from 'react'; -import { ThemeProvider } from '@material-ui/core/styles'; -import CssBaseline from '@material-ui/core/CssBaseline'; import { useApi, appThemeApiRef, AppTheme } from '@backstage/core-plugin-api'; import { useObservable } from 'react-use'; @@ -90,20 +88,5 @@ export function AppThemeProvider({ children }: PropsWithChildren<{}>) { throw new Error('App has no themes'); } - if (appTheme.Provider) { - return ; - } - - // eslint-disable-next-line no-console - console.warn( - "DEPRECATION WARNING: A provided app theme is using the deprecated 'theme' property " + - 'and should be migrated to use a Provider instead. ' + - 'See https://backstage.io/docs/api/deprecations#app-theme for more info.', - ); - - return ( - - {children} - - ); + return ; } diff --git a/packages/core-app-api/src/app/createApp.tsx b/packages/core-app-api/src/app/createApp.tsx deleted file mode 100644 index b2a21634d3..0000000000 --- a/packages/core-app-api/src/app/createApp.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createApp as createDefaultApp } from '@backstage/app-defaults'; -import { AppContext, BackstageApp } from './types'; - -/** - * Creates a new Backstage App. - * - * @deprecated Use {@link @backstage/app-defaults#createApp} from `@backstage/app-defaults` instead - * @param options - A set of options for creating the app - * @public - */ -export function createApp( - options?: Parameters[0], -): BackstageApp & AppContext { - // eslint-disable-next-line no-console - console.warn( - 'DEPRECATION WARNING: The createApp function from @backstage/core-app-api will soon be removed, ' + - 'migrate to importing createApp from the @backstage/app-defaults package instead. ' + - 'If you do not wish to use a standard app configuration but instead supply all options yourself ' + - ' you can use createSpecializedApp from @backstage/core-app-api instead.', - ); - return createDefaultApp(options) as BackstageApp & AppContext; -} diff --git a/packages/core-app-api/src/app/createSpecializedApp.tsx b/packages/core-app-api/src/app/createSpecializedApp.tsx index 9bda223722..40906ef5c7 100644 --- a/packages/core-app-api/src/app/createSpecializedApp.tsx +++ b/packages/core-app-api/src/app/createSpecializedApp.tsx @@ -23,6 +23,13 @@ import { AppOptions, BackstageApp } from './types'; * @public * @param options - A set of options for creating the app * @returns + * @remarks + * + * You will most likely want to use {@link @backstage/app-defaults#createApp}, + * however, this low-level API allows you to provide a full set of options, + * including your own `components`, `icons`, `defaultApis`, and `themes`. This + * is particularly useful if you are not using `@backstage/core-components` or + * MUI, as it allows you to avoid those dependencies completely. */ export function createSpecializedApp(options: AppOptions): BackstageApp { return new AppManager(options); diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts index 5ea5405632..7843b36339 100644 --- a/packages/core-app-api/src/app/index.ts +++ b/packages/core-app-api/src/app/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export { createApp } from './createApp'; export { createSpecializedApp } from './createSpecializedApp'; export { defaultConfigLoader } from './defaultConfigLoader'; export * from './types'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index a538ccd366..4b8d2741cb 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -189,7 +189,7 @@ type TargetRouteMap< /** * A function that can bind from external routes of a given plugin, to concrete - * routes of other plugins. See {@link createApp}. + * routes of other plugins. See {@link createSpecializedApp}. * * @public */ @@ -204,30 +204,7 @@ export type AppRouteBinder = < ) => void; /** - * Internal helper type that represents a plugin with any type of output. - * - * @public - * @remarks - * @deprecated Will be removed - * - * The `type: string` type is there to handle output from newer or older plugin - * API versions that might not be supported by this version of the app API, but - * we don't want to break at the type checking level. We only use this more - * permissive type for the `createApp` options, as we otherwise want to stick - * to using the type for the outputs that we know about in this version of the - * app api. - * - * TODO(freben): This should be marked internal but that's not supported by the api report generation tools yet - */ -export type BackstagePluginWithAnyOutput = Omit< - BackstagePlugin, - 'output' -> & { - output(): (PluginOutput | { type: string })[]; -}; - -/** - * The options accepted by {@link createApp}. + * The options accepted by {@link createSpecializedApp}. * * @public */ @@ -329,7 +306,7 @@ export type AppOptions = { }; /** - * The public API of the output of {@link createApp}. + * The public API of the output of {@link createSpecializedApp}. * * @public */ diff --git a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts index ded26c59e5..83d6035171 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts @@ -13,11 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { withLogCollector } from '@backstage/test-utils'; +import { z } from 'zod'; import { AuthSessionStore } from './AuthSessionStore'; import { SessionManager } from './types'; const defaultOptions = { storageKey: 'my-key', + schema: z.any(), sessionScopes: (session: string) => new Set(session.split(' ')), }; @@ -147,4 +151,48 @@ describe('GheAuth AuthSessionStore', () => { store.sessionState$(); expect(manager.sessionState$).toHaveBeenCalled(); }); + + it('should schema-validate stored data', async () => { + const manager = new MockManager(); + + const firstStore = new AuthSessionStore({ + manager, + storageKey: 'a', + schema: z.boolean(), + sessionScopes: () => new Set(), + }); + const secondStore = new AuthSessionStore({ + manager, + storageKey: 'a', + schema: z.number(), + sessionScopes: () => new Set(), + }); + + firstStore.setSession(true); + await expect(firstStore.getSession({})).resolves.toBe(true); + + await expect( + withLogCollector(async () => { + await expect(secondStore.getSession({})).resolves.toBeUndefined(); + }), + ).resolves.toMatchObject({ + log: [ + expect.stringContaining( + 'Failed to load session from local storage because it did not conform to the expected schema', + ), + ], + }); + + await expect( + withLogCollector(async () => { + await secondStore.setSession('no' as any); + }), + ).resolves.toMatchObject({ + warn: [ + expect.stringContaining( + 'Failed to save session to local storage because it did not conform to the expected schema', + ), + ], + }); + }); }); diff --git a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts index fc15da7af8..e1fb80d41f 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ZodSchema } from 'zod'; import { MutableSessionManager, SessionScopesFunc, @@ -27,6 +28,8 @@ type Options = { manager: MutableSessionManager; /** Storage key to use to store sessions */ storageKey: string; + /** The schema used to validate the stored data */ + schema: ZodSchema; /** Used to get the scope of the session */ sessionScopes?: SessionScopesFunc; /** Used to check if the session needs to be refreshed, defaults to never refresh */ @@ -42,6 +45,7 @@ type Options = { export class AuthSessionStore implements MutableSessionManager { private readonly manager: MutableSessionManager; private readonly storageKey: string; + private readonly schema: ZodSchema; private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc; private readonly helper: SessionScopeHelper; @@ -49,12 +53,14 @@ export class AuthSessionStore implements MutableSessionManager { const { manager, storageKey, + schema, sessionScopes, sessionShouldRefresh = () => false, } = options; this.manager = manager; this.storageKey = storageKey; + this.schema = schema; this.sessionShouldRefreshFunc = sessionShouldRefresh; this.helper = new SessionScopeHelper({ sessionScopes, @@ -104,7 +110,16 @@ export class AuthSessionStore implements MutableSessionManager { } return value; }); - return session; + + try { + return this.schema.parse(session); + } catch (e) { + // eslint-disable-next-line no-console + console.log( + `Failed to load session from local storage because it did not conform to the expected schema, ${e}`, + ); + throw e; + } } return undefined; @@ -117,19 +132,30 @@ export class AuthSessionStore implements MutableSessionManager { private saveSession(session: T | undefined) { if (session === undefined) { localStorage.removeItem(this.storageKey); - } else { - localStorage.setItem( - this.storageKey, - JSON.stringify(session, (_key, value) => { - if (value instanceof Set) { - return { - __type: 'Set', - __value: Array.from(value), - }; - } - return value; - }), - ); + return; } + + try { + this.schema.parse(session); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `Failed to save session to local storage because it did not conform to the expected schema, ${e}`, + ); + return; + } + + localStorage.setItem( + this.storageKey, + JSON.stringify(session, (_key, value) => { + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value), + }; + } + return value; + }), + ); } } diff --git a/packages/core-components/src/components/Button/Button.stories.tsx b/packages/core-components/src/components/Button/Button.stories.tsx index 32c3a98bcc..ca95316ee8 100644 --- a/packages/core-components/src/components/Button/Button.stories.tsx +++ b/packages/core-components/src/components/Button/Button.stories.tsx @@ -18,13 +18,13 @@ import { Button } from './Button'; import { useLocation } from 'react-router-dom'; import { createRouteRef, useRouteRef } from '@backstage/core-plugin-api'; import Divider from '@material-ui/core/Divider'; -import Link from '@material-ui/core/Link'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import Typography from '@material-ui/core/Typography'; import MaterialButton from '@material-ui/core/Button'; import { wrapInTestApp } from '@backstage/test-utils'; +import { Link } from '../Link'; const routeRef = createRouteRef({ id: 'storybook.test-route', @@ -45,7 +45,7 @@ export default { A collection of buttons that should be used in the Backstage interface. These leverage the properties inherited from{' '} - + Material-UI Button , but include an opinionated set that align to the Backstage design. diff --git a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx index 5487d2e7b5..449d4a987b 100644 --- a/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx +++ b/packages/core-components/src/components/DismissableBanner/DismissableBanner.stories.tsx @@ -16,7 +16,6 @@ import React from 'react'; import { DismissableBanner } from './DismissableBanner'; -import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; import { WebStorage } from '@backstage/core-app-api'; import { @@ -25,6 +24,7 @@ import { StorageApi, } from '@backstage/core-plugin-api'; import { TestApiProvider } from '@backstage/test-utils'; +import { Link } from '../Link'; export default { title: 'Feedback/DismissableBanner', @@ -83,7 +83,7 @@ export const WithLink = () => ( message={ This is a dismissable banner with a link:{' '} - + example.com @@ -94,6 +94,7 @@ export const WithLink = () => ( ); + export const Fixed = () => (
@@ -106,6 +107,7 @@ export const Fixed = () => (
); + export const Warning = () => (
diff --git a/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx b/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx index 838cb02a76..2a8918c055 100644 --- a/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx +++ b/packages/core-components/src/components/WarningPanel/WarningPanel.stories.tsx @@ -17,8 +17,8 @@ import React from 'react'; import { WarningPanel } from './WarningPanel'; import Button from '@material-ui/core/Button'; -import Link from '@material-ui/core/Link'; import Typography from '@material-ui/core/Typography'; +import { Link } from '../Link'; export default { title: 'Feedback/Warning Panel', @@ -32,7 +32,7 @@ export const Default = () => ( <> This example entity is missing an annotation. If this is unexpected, please make sure you have set up everything correctly by following{' '} - this guide. + this guide. } /> diff --git a/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx b/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx index e3bcc372dc..2daef184e3 100644 --- a/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx +++ b/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx @@ -16,11 +16,11 @@ import { BackstageTheme } from '@backstage/theme'; import Grid from '@material-ui/core/Grid'; -import Link from '@material-ui/core/Link'; import { makeStyles } from '@material-ui/core/styles'; import Typography from '@material-ui/core/Typography'; import React from 'react'; import { useNavigate } from 'react-router'; +import { Link } from '../../components/Link'; import { useSupportConfig } from '../../hooks'; import { MicDrop } from './MicDrop'; @@ -86,14 +86,11 @@ export function ErrorPage(props: IErrorPageProps) { Looks like someone dropped the mic! - navigate(-1)}> + navigate(-1)}> Go back - ... or please{' '} - - contact support - {' '} - if you think this is a bug. + ... or please contact support if you + think this is a bug. diff --git a/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx b/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx index 31e8998477..d29d8cf96b 100644 --- a/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx +++ b/packages/core-components/src/layout/HeaderLabel/HeaderLabel.tsx @@ -15,10 +15,10 @@ */ import Grid from '@material-ui/core/Grid'; -import Link from '@material-ui/core/Link'; import { makeStyles } from '@material-ui/core/styles'; import Typography from '@material-ui/core/Typography'; import React from 'react'; +import { Link } from '../../components/Link'; /** @public */ export type HeaderLabelClassKey = 'root' | 'label' | 'value'; @@ -80,7 +80,7 @@ export function HeaderLabel(props: HeaderLabelProps) { {label} - {url ? {content} : content} + {url ? {content} : content} ); diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index e142c1ab96..c9045a21d4 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -6,24 +6,21 @@ /// import { BackstagePlugin as BackstagePlugin_2 } from '@backstage/core-plugin-api'; -import { BackstageTheme } from '@backstage/theme'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; import { IconComponent as IconComponent_2 } from '@backstage/core-plugin-api'; import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api'; -import { Observable as Observable_2 } from '@backstage/types'; -import { Observer as Observer_2 } from '@backstage/types'; +import { Observable } from '@backstage/types'; import { ProfileInfo as ProfileInfo_2 } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; -import { Subscription as Subscription_2 } from '@backstage/types'; import { SvgIconProps } from '@material-ui/core'; // @public export type AlertApi = { post(alert: AlertMessage): void; - alert$(): Observable_2; + alert$(): Observable; }; // @public @@ -141,21 +138,8 @@ export type ApiRef = { // @public export type ApiRefConfig = { id: string; - description?: string; }; -// @public @deprecated -export type ApiRefsToTypes< - T extends { - [key in string]: ApiRef; - }, -> = { - [key in keyof T]: ApiRefType; -}; - -// @public @deprecated -export type ApiRefType = T extends ApiRef ? U : never; - // @public export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; @@ -179,15 +163,14 @@ export type AppTheme = { id: string; title: string; variant: 'light' | 'dark'; - theme: BackstageTheme; icon?: React.ReactElement; - Provider?(props: { children: ReactNode }): JSX.Element | null; + Provider(props: { children: ReactNode }): JSX.Element | null; }; // @public export type AppThemeApi = { getInstalledThemes(): AppTheme[]; - activeThemeId$(): Observable_2; + activeThemeId$(): Observable; getActiveThemeId(): string | undefined; setActiveThemeId(themeId?: string): void; }; @@ -374,11 +357,8 @@ export function createRouteRef< }, ParamKey extends string = never, >(config: { - id?: string; + id: string; params?: ParamKey[]; - path?: string; - icon?: OldIconComponent; - title?: string; }): RouteRef>; // @public @@ -413,14 +393,10 @@ export interface ElementCollection { }): ElementCollection; } -// @public @deprecated (undocumented) -type Error_2 = ErrorApiError; -export { Error_2 as Error }; - // @public export type ErrorApi = { post(error: ErrorApiError, context?: ErrorApiErrorContext): void; - error$(): Observable_2<{ + error$(): Observable<{ error: ErrorApiError; context?: ErrorApiErrorContext; }>; @@ -448,9 +424,6 @@ export type ErrorBoundaryFallbackProps = { resetError: () => void; }; -// @public @deprecated (undocumented) -export type ErrorContext = ErrorApiErrorContext; - // @public export type Extension = { expose(plugin: BackstagePlugin): T; @@ -609,7 +582,7 @@ export type OAuthRequestApi = { createAuthRequester( options: OAuthRequesterOptions, ): OAuthRequester; - authRequest$(): Observable_2; + authRequest$(): Observable; }; // @public @@ -631,12 +604,6 @@ export type OAuthRequesterOptions = { // @public export type OAuthScope = string | string[]; -// @public @deprecated -export type Observable = Observable_2; - -// @public @deprecated -export type Observer = Observer_2; - // @public @deprecated export const oidcAuthApiRef: ApiRef< OAuthApi & @@ -759,9 +726,6 @@ export type RouteFunc = ( export type RouteRef = { $$routeRefType: 'absolute'; params: ParamKeys; - path: string; - icon?: OldIconComponent; - title?: string; }; // @public @deprecated @@ -773,7 +737,7 @@ export const samlAuthApiRef: ApiRef< export type SessionApi = { signIn(): Promise; signOut(): Promise; - sessionState$(): Observable_2; + sessionState$(): Observable; }; // @public @@ -799,7 +763,7 @@ export type SignInResult = { export interface StorageApi { forBucket(name: string): StorageApi; get(key: string): T | undefined; - observe$(key: string): Observable_2>; + observe$(key: string): Observable>; remove(key: string): Promise; set(key: string, data: any): Promise; } @@ -821,9 +785,6 @@ export type SubRouteRef = { params: ParamKeys; }; -// @public @deprecated -export type Subscription = Subscription_2; - // @public export type TypesToApiRefs = { [key in keyof T]: ApiRef; diff --git a/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts b/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts index 8053c449a1..e771fad597 100644 --- a/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts @@ -16,7 +16,6 @@ import { ReactNode } from 'react'; import { ApiRef, createApiRef } from '../system'; -import { BackstageTheme } from '@backstage/theme'; import { Observable } from '@backstage/types'; /** @@ -40,18 +39,12 @@ export type AppTheme = { */ variant: 'light' | 'dark'; - /** - * The specialized MaterialUI theme instance. - * @deprecated use Provider instead, see https://backstage.io/docs/api/deprecations#app-theme - */ - theme: BackstageTheme; - /** * An Icon for the theme mode setting. */ icon?: React.ReactElement; - Provider?(props: { children: ReactNode }): JSX.Element | null; + Provider(props: { children: ReactNode }): JSX.Element | null; }; /** diff --git a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts index 364a7bd29a..9c73d94cac 100644 --- a/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/ErrorApi.ts @@ -29,12 +29,6 @@ export type ErrorApiError = { stack?: string; }; -/** - * @public - * @deprecated Use ErrorApiError instead - */ -export type Error = ErrorApiError; - /** * Provides additional information about an error that was posted to the application. * @@ -53,12 +47,6 @@ export type ErrorApiErrorContext = { hidden?: boolean; }; -/** - * @public - * @deprecated Use ErrorApiErrorContext instead - */ -export type ErrorContext = ErrorApiErrorContext; - /** * The error API is used to report errors to the app, and display them to the user. * diff --git a/packages/core-plugin-api/src/apis/system/ApiRef.ts b/packages/core-plugin-api/src/apis/system/ApiRef.ts index 7e13a1963f..adedff9f73 100644 --- a/packages/core-plugin-api/src/apis/system/ApiRef.ts +++ b/packages/core-plugin-api/src/apis/system/ApiRef.ts @@ -23,10 +23,6 @@ import type { ApiRef } from './types'; */ export type ApiRefConfig = { id: string; - /** - * @deprecated Will be removed in the future - */ - description?: string; }; class ApiRefImpl implements ApiRef { @@ -46,12 +42,6 @@ class ApiRefImpl implements ApiRef { return this.config.id; } - get description() { - // eslint-disable-next-line no-console - console.warn('Deprecated use of ApiRef.description'); - return this.config.description; - } - // Utility for getting type of an api, using `typeof apiRef.T` get T(): T { throw new Error(`tried to read ApiRef.T of ${this}`); diff --git a/packages/core-plugin-api/src/apis/system/types.ts b/packages/core-plugin-api/src/apis/system/types.ts index a449e83450..96614c320c 100644 --- a/packages/core-plugin-api/src/apis/system/types.ts +++ b/packages/core-plugin-api/src/apis/system/types.ts @@ -31,33 +31,13 @@ export type ApiRef = { */ export type AnyApiRef = ApiRef; -/** - * Transforms ApiRef type into its inner API type. - * - * @public - * @deprecated unused type. - */ -export type ApiRefType = T extends ApiRef ? U : never; - /** * Wraps a type with API properties into a type holding their respective {@link ApiRef}s. - * Reverse type transform of {@link ApiRefsToTypes}. * * @public */ export type TypesToApiRefs = { [key in keyof T]: ApiRef }; -/** - * Unwraps type with {@link ApiRef} properties into a type holding their respective API types. - * Reverse type transform of {@link TypesToApiRefs}. - * - * @public - * @deprecated unused type. - */ -export type ApiRefsToTypes }> = { - [key in keyof T]: ApiRefType; -}; - /** * Provides lookup of APIs through their {@link ApiRef}s. * diff --git a/packages/core-plugin-api/src/deprecatedTypes.ts b/packages/core-plugin-api/src/deprecatedTypes.ts deleted file mode 100644 index 5596ff9f46..0000000000 --- a/packages/core-plugin-api/src/deprecatedTypes.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Observer as CoreObserver, - Subscription as CoreSubscription, - Observable as CoreObservable, -} from '@backstage/types'; - -/** - * Observer interface for consuming an Observer, see TC39. - * - * @public - * @deprecated Please use the same type from `@backstage/types` instead - */ -export type Observer = CoreObserver; - -/** - * Subscription returned when subscribing to an Observable, see TC39. - * - * @public - * @deprecated Please use the same type from `@backstage/types` instead - */ -export type Subscription = CoreSubscription; - -/** - * Observable sequence of values and errors, see TC39. - * - * @remarks - * - * {@link https://github.com/tc39/proposal-observable} - * This is used as a common return type for observable values and can be created - * using many different observable implementations, such as zen-observable or RxJS 5. - * - * @public - * @deprecated Please use the same type from `@backstage/types` instead - */ -export type Observable = CoreObservable; diff --git a/packages/core-plugin-api/src/index.ts b/packages/core-plugin-api/src/index.ts index 7a4f2e72ba..30fc35b62a 100644 --- a/packages/core-plugin-api/src/index.ts +++ b/packages/core-plugin-api/src/index.ts @@ -27,4 +27,3 @@ export * from './extensions'; export * from './icons'; export * from './plugin'; export * from './routing'; -export * from './deprecatedTypes'; diff --git a/packages/core-plugin-api/src/routing/RouteRef.ts b/packages/core-plugin-api/src/routing/RouteRef.ts index 8bd3d604f6..36ca3f073d 100644 --- a/packages/core-plugin-api/src/routing/RouteRef.ts +++ b/packages/core-plugin-api/src/routing/RouteRef.ts @@ -21,18 +21,6 @@ import { ParamKeys, OptionalParams, } from './types'; -import { OldIconComponent } from '../icons/types'; - -/** - * @deprecated - * @internal - */ -export type RouteRefConfig = { - params?: ParamKeys; - path?: string; - icon?: OldIconComponent; - title: string; -}; /** * @internal @@ -47,48 +35,10 @@ export class RouteRefImpl constructor( private readonly id: string, readonly params: ParamKeys, - private readonly config: { - /** @deprecated */ - path?: string; - /** @deprecated */ - icon?: OldIconComponent; - /** @deprecated */ - title?: string; - }, - ) { - if (config.path) { - // eslint-disable-next-line no-console - console.warn( - `DEPRECATION WARNING: Passing a path to createRouteRef is deprecated, please remove the path for ${this}.`, - ); - } - - if (config.icon) { - // eslint-disable-next-line no-console - console.warn( - `DEPRECATION WARNING: Passing an icon to createRouteRef is deprecated, please remove the icon for ${this}.`, - ); - } - - if (config.title) { - // eslint-disable-next-line no-console - console.warn( - `DEPRECATION WARNING: Passing a title to createRouteRef is deprecated, please remove the title for ${this}.`, - ); - } - } - - /** @deprecated use `useRouteRef` instead */ - get path() { - return this.config.path ?? ''; - } - - get icon() { - return this.config.icon; - } + ) {} get title() { - return this.config.title ?? this.id; + return this.id; } toString() { @@ -112,23 +62,12 @@ export function createRouteRef< ParamKey extends string = never, >(config: { /** The id of the route ref, used to identify it when printed */ - id?: string; + id: string; /** A list of parameter names that the path that this route ref is bound to must contain */ params?: ParamKey[]; - /** @deprecated Route refs no longer decide their own path */ - path?: string; - /** @deprecated Route refs no longer decide their own icon */ - icon?: OldIconComponent; - /** @deprecated Route refs no longer decide their own title */ - title?: string; }): RouteRef> { - const id = config.id || config.title; - if (!id) { - throw new Error('RouteRef must be provided a non-empty id'); - } return new RouteRefImpl( - id, + config.id, (config.params ?? []) as ParamKeys>, - config, ); } diff --git a/packages/core-plugin-api/src/routing/types.ts b/packages/core-plugin-api/src/routing/types.ts index bd419abb76..80653518bb 100644 --- a/packages/core-plugin-api/src/routing/types.ts +++ b/packages/core-plugin-api/src/routing/types.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { OldIconComponent } from '../icons/types'; import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; /** @@ -85,14 +84,6 @@ export type RouteRef = { $$routeRefType: 'absolute'; // See routeRefType above params: ParamKeys; - - // TODO(Rugvip): Remove all of these once plugins don't rely on the path - /** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */ - path: string; - /** @deprecated icons are no longer accessed via RouteRefs */ - icon?: OldIconComponent; - /** @deprecated titles are no longer accessed via RouteRefs */ - title?: string; }; /** @@ -142,23 +133,6 @@ export type AnyRouteRef = | SubRouteRef | ExternalRouteRef; -// TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release -/** - * @deprecated - * @internal - */ -export type ConcreteRoute = {}; -/** - * @deprecated - * @internal - */ -export type AbsoluteRouteRef = RouteRef<{}>; -/** - * @deprecated - * @internal - */ -export type MutableRouteRef = RouteRef<{}>; - /** * A duplicate of the react-router RouteObject, but with routeRef added * @internal diff --git a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx index 43eaa6218c..9229bc61dc 100644 --- a/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx +++ b/packages/dev-utils/src/devApp/SidebarThemeSwitcher.test.tsx @@ -16,7 +16,6 @@ import { AppThemeApi, appThemeApiRef } from '@backstage/core-plugin-api'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { BackstageTheme } from '@backstage/theme'; import userEvent from '@testing-library/user-event'; import React from 'react'; import ObservableImpl from 'zen-observable'; @@ -41,13 +40,13 @@ describe('SidebarThemeSwitcher', () => { id: 'dark', title: 'Dark Theme', variant: 'dark', - theme: {} as unknown as BackstageTheme, + Provider: jest.fn(), }, { id: 'light', title: 'Light Theme', variant: 'light', - theme: {} as unknown as BackstageTheme, + Provider: jest.fn(), }, ]); }); diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index adea9e14f2..36d6abcfd1 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -159,7 +159,7 @@ export class DevAppBuilder { * Build a DevApp component using the resources registered so far */ build(): ComponentType<{}> { - const dummyRouteRef = createRouteRef({ title: 'Page of another plugin' }); + const dummyRouteRef = createRouteRef({ id: 'dummy' }); const DummyPage = () => Page belonging to another plugin.; attachComponentData(DummyPage, 'core.mountPoint', dummyRouteRef); diff --git a/packages/integration-react/src/api/ScmIntegrationsApi.ts b/packages/integration-react/src/api/ScmIntegrationsApi.ts index 06f28a6c4d..ec2185701b 100644 --- a/packages/integration-react/src/api/ScmIntegrationsApi.ts +++ b/packages/integration-react/src/api/ScmIntegrationsApi.ts @@ -45,5 +45,4 @@ export class ScmIntegrationsApi { export const scmIntegrationsApiRef: ApiRef = createApiRef({ id: 'integration.scmintegrations', - description: 'All of the registered SCM integrations of your config', }); diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index a01f306fab..24f17e0b84 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -34,52 +34,6 @@ export type ErrorWithContext = { context?: ErrorApiErrorContext; }; -// @public @deprecated (undocumented) -export class Keyboard { - constructor( - target: any, - { - debug, - }?: { - debug?: boolean | undefined; - }, - ); - // (undocumented) - click(): Promise; - // (undocumented) - debug: boolean; - // (undocumented) - document: any; - // (undocumented) - enter(value: any): Promise; - // (undocumented) - escape(): Promise; - // (undocumented) - get focused(): any; - // (undocumented) - static fromReadableInput(input: any): any; - // (undocumented) - _log(message: any, ...args: any[]): void; - // (undocumented) - _pretty(element: any): string; - // (undocumented) - send(chars: any): Promise; - // (undocumented) - _sendKey(key: any, charCode: any, action: any): Promise; - // (undocumented) - tab(): Promise; - // (undocumented) - static toReadableInput(chars: any): any; - // (undocumented) - toString(): string; - // (undocumented) - static type(target: any, input: any): Promise; - // (undocumented) - type(input: any): Promise; - // (undocumented) - static typeDebug(target: any, input: any): Promise; -} - // @public export type LogCollector = AsyncLogCollector | SyncLogCollector; @@ -139,15 +93,6 @@ export type MockStorageBucket = { [key: string]: any; }; -// @public @deprecated (undocumented) -export const msw: { - setupDefaultHandlers: (worker: { - listen: (t: any) => void; - close: () => void; - resetHandlers: () => void; - }) => void; -}; - // @public export function renderInTestApp( Component: ComponentType | ReactNode, diff --git a/packages/test-utils/src/testUtils/Keyboard.js b/packages/test-utils/src/testUtils/Keyboard.js deleted file mode 100644 index 2a7928f8cd..0000000000 --- a/packages/test-utils/src/testUtils/Keyboard.js +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { act, fireEvent } from '@testing-library/react'; - -const codes = { - Tab: 9, - Enter: 10, - Click: 17 /* This keyboard can click, deal with it */, - Esc: 27, -}; - -/** - * @public - * @deprecated superseded by {@link @testing-library/user-event#userEvent} - */ -export class Keyboard { - static async type(target, input) { - await new Keyboard(target).type(input); - } - - static async typeDebug(target, input) { - await new Keyboard(target, { debug: true }).type(input); - } - - static toReadableInput(chars) { - return chars.split('').map(char => { - switch (char.charCodeAt(0)) { - case codes.Tab: - return ''; - case codes.Enter: - return ''; - case codes.Click: - return ''; - case codes.Esc: - return ''; - default: - return char; - } - }); - } - - static fromReadableInput(input) { - return input.trim().replace(/\s*<([a-zA-Z]+)>\s*/g, (match, name) => { - if (name in codes) { - return String.fromCharCode(codes[name]); - } - throw new Error(`Unknown char name: '${name}'`); - }); - } - - constructor(target, { debug = false } = {}) { - this.debug = debug; - - if (target.ownerDocument) { - this.document = target.ownerDocument; - } else if (target.baseElement) { - this.document = target.baseElement.ownerDocument; - } else { - throw new TypeError( - 'Keyboard(target): target must be DOM node or react-testing-library render() output', - ); - } - } - - toString() { - return `Keyboard{document=${this.document}, debug=${this.debug}}`; - } - - _log(message, ...args) { - if (this.debug) { - // eslint-disable-next-line no-console - console.log(`[Keyboard] ${message}`, ...args); - } - } - - _pretty(element) { - const attrs = [...element.attributes] - .map(attr => `${attr.name}="${attr.value}"`) - .join(' '); - return `<${element.nodeName.toLocaleLowerCase('en-US')} ${attrs}>`; - } - - get focused() { - return this.document.activeElement; - } - - async type(input) { - this._log( - `sending sequence '${input}' with initial focus ${this._pretty( - this.focused, - )}`, - ); - await this.send(Keyboard.fromReadableInput(input)); - } - - async send(chars) { - for (const key of chars.split('')) { - const charCode = key.charCodeAt(0); - - if (charCode === codes.Tab) { - await this.tab(); - continue; - } - - const focused = this.focused; - if (!focused || focused === this.document.body) { - throw Error( - `No element focused in document while trying to type '${Keyboard.toReadableInput( - chars, - )}'`, - ); - } - const nextValue = (focused.value || '') + key; - - if (charCode >= 32) { - await this._sendKey(key, charCode, () => { - this._log( - `sending +${key} = '${nextValue}' to ${this._pretty(focused)}`, - ); - fireEvent.change(focused, { - target: { value: nextValue }, - bubbles: true, - cancelable: true, - }); - }); - } else if (charCode === codes.Enter) { - await this.enter(focused.value || ''); - } else if (charCode === codes.Esc) { - await this.escape(); - } else if (charCode === codes.Click) { - await this.click(); - } else { - throw new Error(`Unsupported char code, ${charCode}`); - } - } - } - - async click() { - this._log(`clicking ${this._pretty(this.focused)}`); - await act(async () => fireEvent.click(this.focused)); - } - - async tab() { - await this._sendKey('Tab', codes.Tab, () => { - const focusable = this.document.querySelectorAll( - [ - 'a[href]', - 'area[href]', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - 'button:not([disabled])', - 'iframe', - 'object', - 'embed', - '*[tabindex]', - '*[contenteditable]', - ].join(','), - ); - - const tabbable = [...focusable].filter(el => { - return el.tabIndex >= 0; - }); - - const focused = this.document.activeElement; - const focusedIndex = tabbable.indexOf(focused); - const nextFocus = tabbable[focusedIndex + (1 % tabbable.length)]; - - this._log( - `tabbing to ${this._pretty(nextFocus)} ${this.focused.textContent}`, - ); - nextFocus.focus(); - }); - } - - async enter(value) { - this._log(`submitting '${value}' via ${this._pretty(this.focused)}`); - await act(() => - this._sendKey('Enter', codes.Enter, () => { - if (this.focused.type === 'button') { - fireEvent.click(this.focused, { target: { value } }); - } else { - fireEvent.submit(this.focused, { - target: { value }, - bubbles: true, - cancelable: true, - }); - } - }), - ); - } - - async escape() { - this._log(`escape from ${this._pretty(this.focused)}`); - await act(async () => this._sendKey('Escape', codes.Esc)); - } - - async _sendKey(key, charCode, action) { - const event = { key, charCode, keyCode: charCode, which: charCode }; - const focused = this.focused; - - if (fireEvent.keyDown(focused, event)) { - if (fireEvent.keyPress(focused, event)) { - if (action) { - action(); - } - } - } - fireEvent.keyUp(focused, event); - } -} diff --git a/packages/test-utils/src/testUtils/Keyboard.test.js b/packages/test-utils/src/testUtils/Keyboard.test.js deleted file mode 100644 index 41ee3a12d6..0000000000 --- a/packages/test-utils/src/testUtils/Keyboard.test.js +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Keyboard } from './Keyboard'; -import { render } from '@testing-library/react'; - -describe('testUtils.Keyboard', () => { - it('types into some inputs with focus and submits a form', async () => { - const typed1 = []; - const typed2 = []; - const typed3 = []; - - let submitted = false; - const handleSubmit = event => { - event.preventDefault(); - submitted = true; - }; - - const rendered = render( -
- typed1.push(value)} /> - typed2.push(value)} - /* eslint-disable-next-line jsx-a11y/no-autofocus */ - autoFocus - /> - typed3.push(value)} /> -
, - ); - - const keyboard = new Keyboard(rendered); - await keyboard.send('xy'); - await keyboard.tab(); - await keyboard.send('abc'); - await keyboard.enter(); - - expect(typed1).toEqual([]); - expect(typed2).toEqual(['x', 'xy']); - expect(typed3).toEqual(['a', 'ab', 'abc']); - expect(submitted).toBe(true); - }); - - it('can use Keyboard.type to send readable input', async () => { - const typed1 = []; - const typed2 = []; - const typed3 = []; - - let submitted = false; - const handleSubmit = event => { - event.preventDefault(); - submitted = true; - }; - - const rendered = render( -
- typed1.push(value)} - /> - typed2.push(value)} - /> - typed3.push(value)} - /> -
, - ); - - await Keyboard.type(rendered, ' a b c '); - - expect(typed1).toEqual(['1a']); - expect(typed2).toEqual(['2b']); - expect(typed3).toEqual(['3c']); - expect(submitted).toBe(true); - }); - - it('should be able to navigate a radio input with click', async () => { - const selections = []; - - const rendered = render( -
selections.push(value)}> - - - -
, - ); - - await Keyboard.type(rendered, ' '); - - expect(selections).toEqual(['a', 'c']); - }); -}); diff --git a/packages/test-utils/src/testUtils/index.tsx b/packages/test-utils/src/testUtils/index.tsx index c778c5c837..a7850fc158 100644 --- a/packages/test-utils/src/testUtils/index.tsx +++ b/packages/test-utils/src/testUtils/index.tsx @@ -19,7 +19,6 @@ export { default as mockBreakpoint } from './mockBreakpoint'; export { wrapInTestApp, renderInTestApp } from './appWrappers'; export type { TestAppOptions } from './appWrappers'; export * from './msw'; -export * from './Keyboard'; export * from './logCollector'; export * from './testingLibrary'; export { TestApiProvider, TestApiRegistry } from './TestApiProvider'; diff --git a/packages/test-utils/src/testUtils/logCollector.ts b/packages/test-utils/src/testUtils/logCollector.ts index 25629f2c13..b9fa6220ed 100644 --- a/packages/test-utils/src/testUtils/logCollector.ts +++ b/packages/test-utils/src/testUtils/logCollector.ts @@ -73,7 +73,6 @@ export function withLogCollector( /** * Log collector that collect logs either from a sync or async collector. * @public - * @deprecated import from test-utils instead * */ export function withLogCollector( logsToCollect: LogFuncs[] | LogCollector, diff --git a/packages/test-utils/src/testUtils/msw/index.ts b/packages/test-utils/src/testUtils/msw/index.ts index 40bc47e8f0..625ef0e0fc 100644 --- a/packages/test-utils/src/testUtils/msw/index.ts +++ b/packages/test-utils/src/testUtils/msw/index.ts @@ -14,20 +14,6 @@ * limitations under the License. */ -/** - * @deprecated use {@link setupRequestMockHandlers} instead which can be called directly with the worker. - * @public - */ -export const msw = { - setupDefaultHandlers: (worker: { - listen: (t: any) => void; - close: () => void; - resetHandlers: () => void; - }) => { - setupRequestMockHandlers(worker); - }, -}; - /** * Sets up handlers for request mocking * @public diff --git a/plugins/apache-airflow/src/api/ApacheAirflowApi.ts b/plugins/apache-airflow/src/api/ApacheAirflowApi.ts index b0a0229df4..dde8c57802 100644 --- a/plugins/apache-airflow/src/api/ApacheAirflowApi.ts +++ b/plugins/apache-airflow/src/api/ApacheAirflowApi.ts @@ -19,7 +19,6 @@ import { Dag, InstanceStatus, InstanceVersion } from './types'; export const apacheAirflowApiRef = createApiRef({ id: 'plugin.apacheairflow.service', - description: 'Used by the Apache Airflow plugin to make requests', }); export type ApacheAirflowApi = { diff --git a/plugins/api-docs/src/config.ts b/plugins/api-docs/src/config.ts index e035bcfbd1..979340164b 100644 --- a/plugins/api-docs/src/config.ts +++ b/plugins/api-docs/src/config.ts @@ -20,7 +20,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const apiDocsConfigRef = createApiRef({ id: 'plugin.api-docs.config', - description: 'Used to configure api-docs widgets', }); export interface ApiDocsConfig { diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 92c76b04b7..a3fc77bc02 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -17,7 +17,7 @@ import express from 'express'; import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter'; import { encodeState } from './helpers'; -import { OAuthHandlers } from './types'; +import { OAuthHandlers, OAuthResponse } from './types'; const mockResponseData = { providerInfo: { @@ -36,6 +36,12 @@ const mockResponseData = { }, }; +function mkTokenBody(payload: unknown): string { + return Buffer.from(JSON.stringify(payload), 'utf8') + .toString('base64') + .replace(/=/g, ''); +} + describe('OAuthAdapter', () => { class MyAuthProvider implements OAuthHandlers { async start() { @@ -249,4 +255,86 @@ describe('OAuthAdapter', () => { 'Refresh token is not supported for provider test-provider', ); }); + + it('correctly populates incomplete identities', async () => { + const mockRefresh = jest.fn, [express.Request]>(); + + const oauthProvider = new OAuthAdapter( + { + refresh: mockRefresh, + start: jest.fn(), + handler: jest.fn(), + } as OAuthHandlers, + { + ...oAuthProviderOptions, + tokenIssuer: { + issueToken: async ({ claims }) => `a.${mkTokenBody(claims)}.a`, + listPublicKeys: async () => ({ keys: [] }), + }, + disableRefresh: false, + isOriginAllowed: () => false, + }, + ); + + const mockRequest = { + header: () => 'XMLHttpRequest', + cookies: { + 'test-provider-refresh-token': 'token', + }, + query: {}, + } as unknown as express.Request; + + const mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + // Without a token + mockRefresh.mockResolvedValueOnce({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: '', + }, + }); + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockResponse.json).toHaveBeenCalledTimes(1); + expect(mockResponse.json).toHaveBeenLastCalledWith({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`, + idToken: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`, + identity: { + type: 'user', + userEntityRef: 'user:default/foo', + ownershipEntityRefs: [], + }, + }, + }); + + // With a token + mockRefresh.mockResolvedValueOnce({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`, + }, + }); + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockResponse.json).toHaveBeenCalledTimes(2); + expect(mockResponse.json).toHaveBeenLastCalledWith({ + ...mockResponseData, + backstageIdentity: { + id: 'foo', + token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`, + idToken: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`, + identity: { + type: 'user', + userEntityRef: 'user:my-ns/foo', + ownershipEntityRefs: [], + }, + }, + }); + }); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index eb3f7efa42..4d5d507aa1 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -17,6 +17,11 @@ import express from 'express'; import crypto from 'crypto'; import { URL } from 'url'; +import { + ENTITY_DEFAULT_NAMESPACE, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; import { AuthProviderRouteHandlers, AuthProviderConfig, @@ -243,8 +248,14 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { return prepareBackstageIdentityResponse(identity); } + const userEntityRef = stringifyEntityRef( + parseEntityRef(identity.id, { + defaultKind: 'user', + defaultNamespace: ENTITY_DEFAULT_NAMESPACE, + }), + ); const token = await this.options.tokenIssuer.issueToken({ - claims: { sub: identity.id }, + claims: { sub: userEntityRef }, }); return prepareBackstageIdentityResponse({ ...identity, token }); diff --git a/plugins/azure-devops/src/api/AzureDevOpsApi.ts b/plugins/azure-devops/src/api/AzureDevOpsApi.ts index 80d36f0adf..d6e926b9a5 100644 --- a/plugins/azure-devops/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops/src/api/AzureDevOpsApi.ts @@ -27,8 +27,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const azureDevOpsApiRef = createApiRef({ id: 'plugin.azure-devops.service', - description: - 'Used by the Azure DevOps plugin to make requests to accompanying backend', }); export interface AzureDevOpsApi { diff --git a/plugins/badges/src/api/types.ts b/plugins/badges/src/api/types.ts index 499c144fca..884daa08ea 100644 --- a/plugins/badges/src/api/types.ts +++ b/plugins/badges/src/api/types.ts @@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const badgesApiRef = createApiRef({ id: 'plugin.badges.client', - description: 'Used to make requests to the badges backend', }); export type BadgeStyle = diff --git a/plugins/bazaar-backend/migrations/20211117092217_optional_entity_ref.js b/plugins/bazaar-backend/migrations/20211117092217_optional_entity_ref.js new file mode 100644 index 0000000000..03ed0a8d05 --- /dev/null +++ b/plugins/bazaar-backend/migrations/20211117092217_optional_entity_ref.js @@ -0,0 +1,171 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + if (knex.client.config.client === 'sqlite3') { + await knex.schema.dropTable('metadata'); + await knex.schema.createTable('metadata', table => { + table.increments('id').comment('Automatically generated unique ID'); + table.text('entity_ref').unique().comment('The ref of the entity'); + table.text('name').notNullable().comment('The name of the entity'); + table + .text('community') + .comment('Link to where the community can discuss ideas'); + table + .text('description') + .notNullable() + .comment('The description of the Bazaar project'); + table + .text('status') + .defaultTo('proposed') + .notNullable() + .comment('The status of the Bazaar project'); + table + .text('updated_at') + .notNullable() + .comment('Timestamp on ISO 8601 format when entity was last updated'); + table + .text('size') + .defaultTo('medium') + .notNullable() + .comment('The estimated magnitude of the project'); + table + .text('start_date') + .comment('Optional start date of the project (ISO 8601 format)'); + table + .text('end_date') + .comment('Optional end date of the project (ISO 8601 format)'); + table + .text('responsible') + .notNullable() + .comment('Contact person of the project'); + }); + + await knex.schema.dropTable('members'); + await knex.schema.createTable('members', table => { + table + .integer('item_id') + .references('metadata.id') + .onDelete('CASCADE') + .comment('Id of the associated item'); + table + .text('entity_ref') + .references('metadata.entity_ref') + .onDelete('CASCADE') + .comment('The ref of the entity'); + table.text('user_id').notNullable().comment('The user id of the member'); + table + .dateTime('join_date') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('The timestamp when this member joined'); + table.text('picture').comment('Link to profile picture'); + }); + } else { + await knex.schema.alterTable('metadata', table => { + table.renameColumn('announcement', 'description'); + table.increments('id').comment('Automatically generated unique ID'); + table.string('entity_ref').nullable().alter(); + }); + + await knex.schema.alterTable('members', table => { + table + .integer('item_id') + .references('metadata.id') + .onDelete('CASCADE') + .comment('Id of the associated item'); + table.dropColumn('entity_ref'); + }); + } +}; + +exports.down = async function down(knex) { + if (knex.client.config.client === 'sqlite3') { + await knex.schema.dropTable('metadata'); + await knex.schema.createTable('metadata', table => { + table + .text('entity_ref') + .notNullable() + .unique() + .comment('The ref of the entity'); + table.text('name').notNullable().comment('The name of the entity'); + table + .text('community') + .comment('Link to where the community can discuss ideas'); + table + .text('announcement') + .notNullable() + .comment('The announcement of the Bazaar project'); + table + .text('status') + .defaultTo('proposed') + .notNullable() + .comment('The status of the Bazaar project'); + table + .text('updated_at') + .notNullable() + .comment('Timestamp on ISO 8601 format when entity was last updated'); + table + .text('size') + .defaultTo('medium') + .notNullable() + .comment('The estimated magnitude of the project'); + table + .text('start_date') + .comment('Optional start date of the project (ISO 8601 format)'); + table + .text('end_date') + .comment('Optional end date of the project (ISO 8601 format)'); + table + .text('responsible') + .notNullable() + .comment('Contact person of the project'); + }); + + await knex.schema.dropTable('members'); + await knex.schema.createTable('members', table => { + table + .text('entity_ref') + .notNullable() + .references('metadata.entity_ref') + .onDelete('CASCADE') + .comment('The ref of the entity'); + table.text('user_id').notNullable().comment('The user id of the member'); + table + .dateTime('join_date') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('The timestamp when this member joined'); + table.text('picture').comment('Link to profile picture'); + }); + } else { + await knex.schema.alterTable('metadata', table => { + table.renameColumn('description', 'announcement'); + table.string('entity_ref').notNullable().alter(); + table.dropColumn('id'); + }); + + await knex.schema.alterTable('members', table => { + table.dropColumn('item_id'); + table + .text('entity_ref') + .notNullable() + .references('metadata.entity_ref') + .onDelete('CASCADE') + .comment('The ref of the entity'); + }); + } +}; diff --git a/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts b/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts index 3afde44d5e..3099e5bbb2 100644 --- a/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts +++ b/plugins/bazaar-backend/src/service/DatabaseHandler.test.ts @@ -22,13 +22,14 @@ const bazaarProject: any = { entityRef: 'ref1', community: '', status: 'proposed', - announcement: 'a', + description: 'a', membersCount: 0, startDate: '2021-11-07T13:27:00.000Z', endDate: null, size: 'small', responsible: 'r', }; + describe('DatabaseHandler', () => { const databases = TestDatabases.create({ ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], @@ -50,7 +51,7 @@ describe('DatabaseHandler', () => { await knex('metadata').insert({ entity_ref: bazaarProject.entityRef, name: bazaarProject.name, - announcement: bazaarProject.announcement, + description: bazaarProject.description, community: bazaarProject.community, status: bazaarProject.status, updated_at: new Date().toISOString(), @@ -60,10 +61,10 @@ describe('DatabaseHandler', () => { responsible: bazaarProject.responsible, }); - const res = await dbHandler.getMetadata('ref1'); + const res = await dbHandler.getMetadataByRef('ref1'); expect(res).toHaveLength(1); - expect(res[0].announcement).toEqual('a'); + expect(res[0].description).toEqual('a'); expect(res[0].community).toEqual(''); expect(res[0].status).toEqual('proposed'); expect(res[0].start_date).toEqual('2021-11-07T13:27:00.000Z'); diff --git a/plugins/bazaar-backend/src/service/DatabaseHandler.ts b/plugins/bazaar-backend/src/service/DatabaseHandler.ts index 60f579af2d..7da06a54b8 100644 --- a/plugins/bazaar-backend/src/service/DatabaseHandler.ts +++ b/plugins/bazaar-backend/src/service/DatabaseHandler.ts @@ -44,10 +44,10 @@ export class DatabaseHandler { } private columns = [ - 'members.entity_ref', + 'metadata.id', 'metadata.entity_ref', 'metadata.name', - 'metadata.announcement', + 'metadata.description', 'metadata.status', 'metadata.updated_at', 'metadata.community', @@ -57,40 +57,52 @@ export class DatabaseHandler { 'metadata.responsible', ]; - async getMembers(entityRef: string) { + async getMembers(id: string) { return await this.database .select('*') .from('members') - .where({ entity_ref: entityRef }); + .where({ item_id: id }); } - async addMember(userId: string, entityRef: string, picture?: string) { + async addMember(id: number, userId: string, picture?: string) { await this.database .insert({ - entity_ref: entityRef, + item_id: id, user_id: userId, picture: picture, }) .into('members'); } - async deleteMember(userId: string, entityRef: string) { + async deleteMember(id: number, userId: string) { return await this.database('members') - .where({ entity_ref: decodeURIComponent(entityRef) }) + .where({ item_id: id }) .andWhere('user_id', userId) .del(); } - async getMetadata(entityRef: string) { + async getMetadataById(id: number) { const coalesce = this.database.raw( - 'coalesce(count(members.entity_ref), 0) as members_count', + 'coalesce(count(members.item_id), 0) as members_count', + ); + + return await this.database('metadata') + .select([...this.columns, coalesce]) + .where({ 'metadata.id': id }) + .groupBy(this.columns) + .leftJoin('members', 'metadata.id', '=', 'members.item_id'); + } + + async getMetadataByRef(entityRef: string) { + const coalesce = this.database.raw( + 'coalesce(count(members.item_id), 0) as members_count', ); return await this.database('metadata') .select([...this.columns, coalesce]) .where({ 'metadata.entity_ref': entityRef }) .groupBy(this.columns) - .leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref'); + .leftJoin('members', 'metadata.id', '=', 'members.item_id'); } async insertMetadata(bazaarProject: any) { @@ -98,7 +110,7 @@ export class DatabaseHandler { name, entityRef, community, - announcement, + description, status, size, startDate, @@ -108,11 +120,11 @@ export class DatabaseHandler { await this.database .insert({ - name: name, + name, entity_ref: entityRef, - community: community, - announcement: announcement, - status: status, + community, + description, + status, updated_at: new Date().toISOString(), size, start_date: startDate, @@ -124,9 +136,11 @@ export class DatabaseHandler { async updateMetadata(bazaarProject: any) { const { + name, + id, entityRef, community, - announcement, + description, status, size, startDate, @@ -134,34 +148,32 @@ export class DatabaseHandler { responsible, } = bazaarProject; - return await this.database('metadata') - .where({ entity_ref: entityRef }) - .update({ - announcement: announcement, - community: community, - status: status, - updated_at: new Date().toISOString(), - size, - start_date: startDate, - end_date: endDate, - responsible, - }); + return await this.database('metadata').where({ id: id }).update({ + name, + entity_ref: entityRef, + description, + community, + status, + updated_at: new Date().toISOString(), + size, + start_date: startDate, + end_date: endDate, + responsible, + }); } - async deleteMetadata(entityRef: string) { - return await this.database('metadata') - .where({ entity_ref: entityRef }) - .del(); + async deleteMetadata(id: number) { + return await this.database('metadata').where({ id: id }).del(); } - async getEntities() { + async getProjects() { const coalesce = this.database.raw( - 'coalesce(count(members.entity_ref), 0) as members_count', + 'coalesce(count(members.item_id), 0) as members_count', ); return await this.database('metadata') .select([...this.columns, coalesce]) .groupBy(this.columns) - .leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref'); + .leftJoin('members', 'metadata.id', '=', 'members.item_id'); } } diff --git a/plugins/bazaar-backend/src/service/router.ts b/plugins/bazaar-backend/src/service/router.ts index f0bc02c65b..730d958fcd 100644 --- a/plugins/bazaar-backend/src/service/router.ts +++ b/plugins/bazaar-backend/src/service/router.ts @@ -40,28 +40,27 @@ export async function createRouter( const router = Router(); router.use(express.json()); - router.get('/members/:ref', async (request, response) => { - const entity_ref = decodeURIComponent(request.params.ref); - const data = await dbHandler.getMembers(entity_ref); + router.get('/projects/:id/members', async (request, response) => { + const members = await dbHandler.getMembers(request.params.id); - if (data?.length) { - response.json({ status: 'ok', data: data }); + if (members?.length) { + response.json({ status: 'ok', data: members }); } else { response.json({ status: 'ok', data: [] }); } }); - router.put('/member', async (request, response) => { - const { user_id, entity_ref, picture } = request.body; - await dbHandler.addMember(user_id, entity_ref, picture); + router.put('/projects/:id/member/:userId', async (request, response) => { + const { id, userId } = request.params; + await dbHandler.addMember(parseInt(id, 10), userId, request.body?.picture); response.json({ status: 'ok' }); }); - router.delete('/member/:ref/:id', async (request, response) => { - const { ref, id } = request.params; + router.delete('/projects/:id/member/:userId', async (request, response) => { + const { id, userId } = request.params; - const count = await dbHandler.deleteMember(id, ref); + const count = await dbHandler.deleteMember(parseInt(id, 10), userId); if (count) { response.json({ status: 'ok' }); @@ -70,36 +69,48 @@ export async function createRouter( } }); - router.get('/metadata/:ref', async (request, response) => { + router.get('/projects/id/:id', async (request, response) => { + const id = decodeURIComponent(request.params.id); + + const data = await dbHandler.getMetadataById(parseInt(id, 10)); + + response.json({ status: 'ok', data: data }); + }); + + router.get('/projects/ref/:ref', async (request, response) => { const ref = decodeURIComponent(request.params.ref); - const data = await dbHandler.getMetadata(ref); + const data = await dbHandler.getMetadataByRef(ref); response.json({ status: 'ok', data: data }); }); - router.get('/entities', async (_, response) => { - const data = await dbHandler.getEntities(); + router.get('/projects', async (_, response) => { + const data = await dbHandler.getProjects(); response.json({ status: 'ok', data: data }); }); - router.put('/metadata', async (request, response) => { + router.put('/projects', async (request, response) => { const bazaarProject = request.body; const count = await dbHandler.updateMetadata(bazaarProject); if (count) { response.json({ status: 'ok' }); - } else { - await dbHandler.insertMetadata(bazaarProject); - response.json({ status: 'ok' }); } }); - router.delete('/metadata/:ref', async (request, response) => { - const ref = decodeURIComponent(request.params.ref); + router.post('/projects', async (request, response) => { + const bazaarProject = request.body; - const count = await dbHandler.deleteMetadata(ref); + await dbHandler.insertMetadata(bazaarProject); + response.json({ status: 'ok' }); + }); + + router.delete('/projects/:id', async (request, response) => { + const id = decodeURIComponent(request.params.id); + + const count = await dbHandler.deleteMetadata(parseInt(id, 10)); if (count) { response.json({ status: 'ok' }); diff --git a/plugins/bazaar/README.md b/plugins/bazaar/README.md index f07e751984..dea25727b0 100644 --- a/plugins/bazaar/README.md +++ b/plugins/bazaar/README.md @@ -69,61 +69,42 @@ const overviewContent = ( ### Layout -The latest modified Bazaar projects are displayed in the Bazaar landing page, located at the Bazaar icon in the sidebar. Each project is represented as a card containing its most relevant data to give an overview of the project. The list of project is paginated. +The latest modified Bazaar projects are displayed in the Bazaar landing page, located at the Bazaar icon in the sidebar. Each project is represented as a card containing its most relevant data to give an overview of the project. It is also possible to sort in alphabetical order or on the number of members. Here you can also search or add project to the Bazaar. -![home](media/bazaar_pr_fullscreen.png) +![home](media/layout.png) ### Workflow -To add a project to the Bazaar, you need to create a project with one of the templates in Backstage. Click the add project-button, choose the project and fill in the form. +To add a project to the bazaar, simply click on the `add-project` button and fill in the form. The following fields are mandatory: -- announcement - present your idea and what skills you are looking for +- name - name of the project on URL safe format +- description - present your idea and what skills you are looking for - status - whether or not the project has started - size - small, medium or large - responsible - main contact person of the project The other fields are: +- project - link Bazaar project to existing entity in the catalog +- community link - link to where the project members can communicate, e.g. Teams or Discord link - start date - end date -- community link - link where the project members can chat, e.g. Teams or Discord link -When the project is added, you will see the Bazaar information in the Bazaar card on the entity page. There you can join a project, edit or delete it. +When clicking on a Bazaar project a card containing the Bazaar information will show up. If the Bazaar project is linked to an entity, the card is also visible on that entity's EntityPage. From that card it is possible to either link or unlink an entity to a project, edit or delete the project and join the project if it seems interesting to you. Once you have joined a project, you will get access to the community link if it exists. -### Database - -The metadata related to the Bazaar is stored in a database. Right now there are two tables, one for storing the metadata and one for storing the members of a Bazaar project. - -**metadata**: - -- name - name of the entity -- entity_ref - namespace/kind/name of the entity -- announcement - announcement of the project and its current need of skills/team member -- status - status of the project, 'proposed' or 'ongoing' -- updated_at - date when the Bazaar information was last modified (ISO 8601 format) -- size - small, medium or large -- start_date - date when the project is estimated to start (ISO 8601 format) -- end_date - date when the project is estimated to end (ISO 8601 format) -- responsible - main contact person of the project - -**members**: - -- entity_ref - namespace/kind/name of the entity -- user_name -- join_date - date when the user joined the project (ISO 8601 format) +![home](media/demo.gif) ## Future work and ideas - Workflow - - Make it possible to add a Bazaar project without linking it to a Backstage entity, this would make it easier to just add an idea to the Bazaar. + - Make it possible for multiple Bazaar project to link to the same catalog entity - Bazaar landing page - Add a tab 'My page', where your personal data is displayed. For example: your projects and its latest activities etc. - - Make it possible to sort the project based on e.g. the number of members - Bazaar tab on the EntityPage @@ -133,6 +114,3 @@ The metadata related to the Bazaar is stored in a database. Right now there are - Dialogues - Extend the dialogue for adding a project with more fields, e.g. the possibility to add images - -- Testing - - Add tests to all the components diff --git a/plugins/bazaar/media/bazaar_pr_fullscreen.png b/plugins/bazaar/media/bazaar_pr_fullscreen.png deleted file mode 100644 index 6c5cf1277b..0000000000 Binary files a/plugins/bazaar/media/bazaar_pr_fullscreen.png and /dev/null differ diff --git a/plugins/bazaar/media/demo.gif b/plugins/bazaar/media/demo.gif new file mode 100644 index 0000000000..6369ce7551 Binary files /dev/null and b/plugins/bazaar/media/demo.gif differ diff --git a/plugins/bazaar/media/layout.png b/plugins/bazaar/media/layout.png new file mode 100644 index 0000000000..cc02a374a3 Binary files /dev/null and b/plugins/bazaar/media/layout.png differ diff --git a/plugins/bazaar/package.json b/plugins/bazaar/package.json index 88da003778..6ccda73f93 100644 --- a/plugins/bazaar/package.json +++ b/plugins/bazaar/package.json @@ -34,6 +34,7 @@ "@material-ui/pickers": "^3.3.10", "@testing-library/jest-dom": "^5.10.1", "luxon": "^2.0.2", + "material-ui-search-bar": "^1.0.0", "react-hook-form": "^7.13.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" diff --git a/plugins/bazaar/plugin-bazaar.api.md b/plugins/bazaar/plugin-bazaar.api.md new file mode 100644 index 0000000000..33a674ffc4 --- /dev/null +++ b/plugins/bazaar/plugin-bazaar.api.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/plugin-bazaar" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import * as _backstage_core_plugin_api from '@backstage/core-plugin-api'; + +// @public (undocumented) +export const BazaarPage: () => JSX.Element; + +// @public (undocumented) +export const bazaarPlugin: _backstage_core_plugin_api.BackstagePlugin< + { + root: _backstage_core_plugin_api.RouteRef; + }, + {} +>; + +// @public (undocumented) +export const EntityBazaarInfoCard: () => JSX.Element; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/bazaar/src/api.ts b/plugins/bazaar/src/api.ts index db20e5e0cb..9f5010ec43 100644 --- a/plugins/bazaar/src/api.ts +++ b/plugins/bazaar/src/api.ts @@ -14,33 +14,34 @@ * limitations under the License. */ -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { createApiRef, DiscoveryApi, IdentityApi, } from '@backstage/core-plugin-api'; -import { BazaarProject } from './types'; export const bazaarApiRef = createApiRef({ id: 'bazaar', - description: 'Used to make requests towards the bazaar backend', }); export interface BazaarApi { - updateMetadata(bazaarProject: BazaarProject): Promise; + updateProject(bazaarProject: any): Promise; - getMetadata(entity: Entity): Promise; + addProject(bazaarProject: any): Promise; - getMembers(entity: Entity): Promise; + getProjectById(id: number): Promise; - deleteMember(entity: Entity): Promise; + getProjectByRef(entityRef: string): Promise; - addMember(entity: Entity): Promise; + getMembers(id: number): Promise; - getEntities(): Promise; + deleteMember(id: number, userId: string): Promise; - deleteEntity(bazaarProject: BazaarProject): Promise; + addMember(id: number, userId: string): Promise; + + getProjects(): Promise; + + deleteProject(id: number): Promise; } export class BazaarClient implements BazaarApi { @@ -55,10 +56,10 @@ export class BazaarClient implements BazaarApi { this.discoveryApi = options.discoveryApi; } - async updateMetadata(bazaarProject: BazaarProject): Promise { + async updateProject(bazaarProject: any): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - return await fetch(`${baseUrl}/metadata`, { + return await fetch(`${baseUrl}/projects`, { method: 'PUT', headers: { Accept: 'application/json', @@ -68,11 +69,34 @@ export class BazaarClient implements BazaarApi { }).then(resp => resp.json()); } - async getMetadata(entity: Entity): Promise { + async addProject(bazaarProject: any): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); + + return await fetch(`${baseUrl}/projects`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(bazaarProject), + }).then(resp => resp.json()); + } + + async getProjectById(id: number): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); + + const response = await fetch(`${baseUrl}/projects/id/${id}`, { + method: 'GET', + }); + + return response.ok ? response : null; + } + + async getProjectByRef(entityRef: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); const response = await fetch( - `${baseUrl}/metadata/${encodeURIComponent(stringifyEntityRef(entity))}`, + `${baseUrl}/projects/ref/${encodeURIComponent(entityRef)}`, { method: 'GET', }, @@ -81,60 +105,49 @@ export class BazaarClient implements BazaarApi { return response.ok ? response : null; } - async getMembers(entity: Entity): Promise { + async getMembers(id: number): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - return await fetch( - `${baseUrl}/members/${encodeURIComponent(stringifyEntityRef(entity))}`, - { - method: 'GET', - }, - ).then(resp => resp.json()); + return await fetch(`${baseUrl}/projects/${id}/members`, { + method: 'GET', + }).then(resp => resp.json()); } - async addMember(entity: Entity): Promise { + async addMember(id: number, userId: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - await fetch(`${baseUrl}/member`, { + await fetch(`${baseUrl}/projects/${id}/member/${userId}`, { method: 'PUT', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ - entity_ref: stringifyEntityRef(entity), - user_id: this.identityApi.getUserId(), picture: this.identityApi.getProfile()?.picture, }), }); } - async deleteMember(entity: Entity): Promise { + async deleteMember(id: number, userId: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - await fetch( - `${baseUrl}/member/${encodeURIComponent( - stringifyEntityRef(entity), - )}/${this.identityApi.getUserId()}`, - { - method: 'DELETE', - }, - ); + await fetch(`${baseUrl}/projects/${id}/member/${userId}`, { + method: 'DELETE', + }); } - async getEntities(): Promise { + async getProjects(): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - return await fetch(`${baseUrl}/entities`, { + return await fetch(`${baseUrl}/projects`, { method: 'GET', }).then(resp => resp.json()); } - async deleteEntity(bazaarProject: BazaarProject): Promise { + async deleteProject(id: number): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('bazaar'); - const entityRef = bazaarProject.entityRef as string; - await fetch(`${baseUrl}/metadata/${encodeURIComponent(entityRef)}`, { + await fetch(`${baseUrl}/projects/${id}`, { method: 'DELETE', }); } diff --git a/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx b/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx index 310ffe051d..4333ff11fd 100644 --- a/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx +++ b/plugins/bazaar/src/components/AddProjectDialog/AddProjectDialog.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { UseFormReset, UseFormGetValues } from 'react-hook-form'; import { useApi } from '@backstage/core-plugin-api'; @@ -39,18 +39,13 @@ export const AddProjectDialog = ({ fetchCatalogEntities, }: Props) => { const bazaarApi = useApi(bazaarApiRef); - const [selectedEntity, setSelectedEntity] = useState( - catalogEntities ? catalogEntities[0] : null, - ); - - useEffect(() => { - setSelectedEntity(catalogEntities ? catalogEntities[0] : null); - }, [catalogEntities]); + const [selectedEntity, setSelectedEntity] = useState(null); const defaultValues = { + name: '', title: 'Add project', community: '', - announcement: '', + description: '', status: 'proposed' as Status, size: 'medium' as Size, responsible: '', @@ -58,59 +53,48 @@ export const AddProjectDialog = ({ endDate: null, }; - const handleListItemClick = (entity: Entity) => { + const handleEntityClick = (entity: Entity) => { setSelectedEntity(entity); }; - const handleCloseDialog = () => { - setSelectedEntity(catalogEntities ? catalogEntities[0] : null); - handleClose(); - }; - - const handleSave: any = async ( + const handleSubmit: any = async ( getValues: UseFormGetValues, reset: UseFormReset, ) => { const formValues = getValues(); + const response = await bazaarApi.addProject({ + ...formValues, + entityRef: selectedEntity ? stringifyEntityRef(selectedEntity) : null, + startDate: formValues.startDate ?? null, + endDate: formValues.endDate ?? null, + } as BazaarProject); - if (selectedEntity) { - await bazaarApi.updateMetadata({ - name: selectedEntity.metadata.name, - entityRef: stringifyEntityRef(selectedEntity), - announcement: formValues.announcement, - status: formValues.status, - community: formValues.community, - membersCount: 0, - size: formValues.size, - startDate: formValues.startDate ?? null, - endDate: formValues.endDate ?? null, - responsible: formValues.responsible, - } as BazaarProject); - + if (response.status === 'ok') { fetchBazaarProjects(); fetchCatalogEntities(); - - handleClose(); - reset(defaultValues); } + + handleClose(); + reset(defaultValues); }; return ( } - handleClose={handleCloseDialog} + handleClose={handleClose} /> ); }; diff --git a/plugins/bazaar/src/components/AlertBanner/AlertBanner.tsx b/plugins/bazaar/src/components/AlertBanner/AlertBanner.tsx deleted file mode 100644 index 1b6c1331c3..0000000000 --- a/plugins/bazaar/src/components/AlertBanner/AlertBanner.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Snackbar, IconButton } from '@material-ui/core'; -import CloseIcon from '@material-ui/icons/Close'; -import { Alert } from '@material-ui/lab'; - -type Props = { - open: boolean; - message: JSX.Element; - handleClose: () => void; -}; - -export const AlertBanner = ({ open, message, handleClose }: Props) => { - return ( - - - - - } - > - {message} - - - ); -}; diff --git a/plugins/bazaar/src/components/CardContentFields/CardContentFields.tsx b/plugins/bazaar/src/components/CardContentFields/CardContentFields.tsx new file mode 100644 index 0000000000..5145dc54b8 --- /dev/null +++ b/plugins/bazaar/src/components/CardContentFields/CardContentFields.tsx @@ -0,0 +1,168 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Grid, + makeStyles, + Card, + CardContent, + Typography, + Link, + GridSize, +} from '@material-ui/core'; +import { Avatar } from '@backstage/core-components'; +import { AboutField } from '@backstage/plugin-catalog'; +import { StatusTag } from '../StatusTag'; +import { Member, BazaarProject } from '../../types'; + +const useStyles = makeStyles({ + break: { + wordBreak: 'break-word', + }, +}); + +type Props = { + bazaarProject: BazaarProject; + members: Member[]; + descriptionSize: GridSize; + membersSize: GridSize; +}; + +export const CardContentFields = ({ + bazaarProject, + members, + descriptionSize, + membersSize, +}: Props) => { + const classes = useStyles(); + + return ( +
+ + + + + + {bazaarProject.description + .split('\n') + .map((str: string, i: number) => ( + + {str} + + ))} + + + + + + {members.length ? ( + members.slice(0, 7).map((member: Member) => { + return ( +
+ + + {member?.userId} + +
+ ); + }) + ) : ( +
+ )} + + + + + + + + + + + + {bazaarProject.size} + + + + + + + {bazaarProject.startDate?.substring(0, 10) || ''} + + + + + + + + {bazaarProject.endDate?.substring(0, 10) || ''} + + + + + + + + {bazaarProject.responsible || ''} + + + + + + +
+ ); +}; diff --git a/plugins/bazaar/src/components/DeleteProjectDialog/index.ts b/plugins/bazaar/src/components/CardContentFields/index.ts similarity index 90% rename from plugins/bazaar/src/components/DeleteProjectDialog/index.ts rename to plugins/bazaar/src/components/CardContentFields/index.ts index fdd488aeb9..e6f990322b 100644 --- a/plugins/bazaar/src/components/DeleteProjectDialog/index.ts +++ b/plugins/bazaar/src/components/CardContentFields/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { DeleteProjectDialog } from './DeleteProjectDialog'; +export { CardContentFields } from './CardContentFields'; diff --git a/plugins/bazaar/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/plugins/bazaar/src/components/ConfirmationDialog/ConfirmationDialog.tsx new file mode 100644 index 0000000000..1e00ac5e30 --- /dev/null +++ b/plugins/bazaar/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import { + CustomDialogTitle, + DialogActions, + DialogContent, +} from '../CustomDialogTitle'; + +type Props = { + open: boolean; + handleClose: () => void; + message: (string | JSX.Element)[]; + type: 'delete' | 'unlink'; + handleSubmit: () => void; +}; + +export const ConfirmationDialog = ({ + open, + handleClose, + message, + type, + handleSubmit, +}: Props) => { + return ( + + + {type.charAt(0).toLocaleUpperCase('en-US') + type.slice(1)} project + + + {message} + + + + + + ); +}; diff --git a/plugins/bazaar/src/components/ConfirmationDialog/index.ts b/plugins/bazaar/src/components/ConfirmationDialog/index.ts new file mode 100644 index 0000000000..a3252eb6a9 --- /dev/null +++ b/plugins/bazaar/src/components/ConfirmationDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ConfirmationDialog } from './ConfirmationDialog'; diff --git a/plugins/bazaar/src/components/CustomDialogTitle/CustomDialogTitle.tsx b/plugins/bazaar/src/components/CustomDialogTitle/CustomDialogTitle.tsx new file mode 100644 index 0000000000..2cf8b13874 --- /dev/null +++ b/plugins/bazaar/src/components/CustomDialogTitle/CustomDialogTitle.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import IconButton from '@material-ui/core/IconButton'; +import CloseIcon from '@material-ui/icons/Close'; +import Typography from '@material-ui/core/Typography'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; +import { + Theme, + WithStyles, + withStyles, + createStyles, +} from '@material-ui/core/styles'; +import MuiDialogContent from '@material-ui/core/DialogContent'; +import MuiDialogActions from '@material-ui/core/DialogActions'; + +/* + DialogTitleProps, DialogTitle, DialogContent and DialogActions + are copied from the git-release plugin +*/ + +export interface DialogTitleProps extends WithStyles { + id: string; + children: React.ReactNode; + onClose: () => void; +} + +const styles = (theme: Theme) => + createStyles({ + root: { + margin: 0, + padding: theme.spacing(2), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + }); + +export const DialogContent = withStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiDialogContent); + +export const DialogActions = withStyles((theme: Theme) => ({ + root: { + margin: 0, + padding: theme.spacing(1), + }, +}))(MuiDialogActions); + +export const CustomDialogTitle = withStyles(styles)( + (props: DialogTitleProps) => { + const { children, classes, onClose, ...other } = props; + return ( + + {children} + {onClose ? ( + + + + ) : null} + + ); + }, +); diff --git a/plugins/bazaar/src/components/CustomDialogTitle/index.ts b/plugins/bazaar/src/components/CustomDialogTitle/index.ts new file mode 100644 index 0000000000..8b615ac211 --- /dev/null +++ b/plugins/bazaar/src/components/CustomDialogTitle/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + CustomDialogTitle, + DialogContent, + DialogActions, +} from './CustomDialogTitle'; diff --git a/plugins/bazaar/src/components/DateSelector/DateSelector.tsx b/plugins/bazaar/src/components/DateSelector/DateSelector.tsx index db564a7b68..600247a53f 100644 --- a/plugins/bazaar/src/components/DateSelector/DateSelector.tsx +++ b/plugins/bazaar/src/components/DateSelector/DateSelector.tsx @@ -45,7 +45,6 @@ export const DateSelector = ({ name, control, setValue }: Props) => { - createStyles({ - root: { - margin: 0, - padding: theme.spacing(2), - }, - closeButton: { - position: 'absolute', - right: theme.spacing(1), - top: theme.spacing(1), - color: theme.palette.grey[500], - }, - }); - -/* - DialogTitleProps, DialogTitle, DialogContent and DialogActions - are copied from the git-release plugin -*/ -export interface DialogTitleProps extends WithStyles { - id: string; - children: React.ReactNode; - onClose: () => void; -} - -const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { - const { children, classes, onClose, ...other } = props; - return ( - - {children} - {onClose ? ( - - - - ) : null} - - ); -}); - -const DialogContent = withStyles((theme: Theme) => ({ - root: { - padding: theme.spacing(2), - }, -}))(MuiDialogContent); - -const DialogActions = withStyles((theme: Theme) => ({ - root: { - margin: 0, - padding: theme.spacing(1), - }, -}))(MuiDialogActions); - -type Props = { - bazaarProject: BazaarProject; - openDelete: boolean; - handleClose: () => void; - setIsBazaar: Dispatch>; -}; - -export const DeleteProjectDialog = ({ - bazaarProject, - openDelete, - handleClose, - setIsBazaar, -}: Props) => { - const handleCloseAndClear = () => { - handleClose(); - }; - - const bazaarApi = useApi(bazaarApiRef); - - const handleSubmit = async () => { - await bazaarApi.deleteEntity(bazaarProject); - setIsBazaar(false); - handleCloseAndClear(); - }; - - return ( - - - Delete project - - - - Are you sure you want to delete this project from the Bazaar? - - - - - - - ); -}; diff --git a/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx b/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx index b37e734453..eb72ee67dc 100644 --- a/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx +++ b/plugins/bazaar/src/components/DoubleDateSelector/DoubleDateSelector.tsx @@ -17,49 +17,48 @@ import React from 'react'; import { Control, UseFormSetValue } from 'react-hook-form'; import { FormValues } from '../../types'; -import { Typography } from '@material-ui/core'; import { DateSelector } from '../DateSelector/DateSelector'; +import { Typography, makeStyles } from '@material-ui/core'; type Props = { control: Control; setValue: UseFormSetValue; }; +const useStyles = makeStyles({ + container: { + marginTop: '0.25rem', + textAlign: 'center', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + startDate: { + float: 'left', + }, + endDate: { + float: 'right', + }, + dash: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '1.5rem', + margin: '0 1rem', + }, +}); + export const DoubleDateSelector = ({ control, setValue }: Props) => { + const classes = useStyles(); + return ( -
-
+
+
- - - - -
+ - +
diff --git a/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx b/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx index 41c9e474d4..0b2a8ef391 100644 --- a/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx +++ b/plugins/bazaar/src/components/EditProjectDialog/EditProjectDialog.tsx @@ -15,81 +15,119 @@ */ import React, { useState, useEffect } from 'react'; -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { ProjectDialog } from '../ProjectDialog'; -import { BazaarProject, FormValues, Size, Status } from '../../types'; +import { BazaarProject, FormValues } from '../../types'; import { bazaarApiRef } from '../../api'; import { UseFormGetValues } from 'react-hook-form'; +import { ConfirmationDialog } from '../ConfirmationDialog'; +import { Button, makeStyles } from '@material-ui/core'; type Props = { - entity: Entity; bazaarProject: BazaarProject; + openEdit: boolean; + handleEditClose: () => void; + handleCardClose?: () => void; fetchBazaarProject: () => Promise; - open: boolean; - handleClose: () => void; - isAddForm: boolean; }; +const useStyles = makeStyles({ + button: { + marginLeft: '0', + marginRight: 'auto', + }, +}); + export const EditProjectDialog = ({ - entity, bazaarProject, + openEdit, + handleEditClose, + handleCardClose, fetchBazaarProject, - open, - handleClose, }: Props) => { + const classes = useStyles(); + const bazaarApi = useApi(bazaarApiRef); + const [openDelete, setOpenDelete] = useState(false); const [defaultValues, setDefaultValues] = useState({ - announcement: bazaarProject.announcement, - community: bazaarProject.community, - status: bazaarProject.status, - size: bazaarProject.size, - startDate: bazaarProject?.startDate ?? null, - endDate: bazaarProject?.endDate ?? null, - responsible: bazaarProject.responsible, + ...bazaarProject, + startDate: bazaarProject.startDate ?? null, + endDate: bazaarProject.endDate ?? null, }); - const bazaarApi = useApi(bazaarApiRef); + const handleDeleteClose = () => { + setOpenDelete(false); + handleEditClose(); + + if (handleCardClose) handleCardClose(); + }; + + const handleDeleteSubmit = async () => { + await bazaarApi.deleteProject(bazaarProject.id); + + handleDeleteClose(); + fetchBazaarProject(); + }; useEffect(() => { setDefaultValues({ - announcement: bazaarProject.announcement, - community: bazaarProject.community, - status: bazaarProject.status, - size: bazaarProject.size, - startDate: bazaarProject?.startDate ?? null, - endDate: bazaarProject?.endDate ?? null, - responsible: bazaarProject.responsible, + ...bazaarProject, + startDate: bazaarProject.startDate ?? null, + endDate: bazaarProject.endDate ?? null, }); }, [bazaarProject]); - const handleSave: any = async (getValues: UseFormGetValues) => { + const handleEditSubmit: any = async ( + getValues: UseFormGetValues, + ) => { const formValues = getValues(); - const updateResponse = await bazaarApi.updateMetadata({ - name: entity.metadata.name, - entityRef: stringifyEntityRef(entity), - announcement: formValues.announcement, - status: formValues.status as Status, - community: formValues.community, + const updateResponse = await bazaarApi.updateProject({ + ...formValues, + id: bazaarProject.id, + entityRef: bazaarProject.entityRef, membersCount: bazaarProject.membersCount, - size: formValues.size as Size, startDate: formValues?.startDate ?? null, endDate: formValues?.endDate ?? null, - responsible: formValues.responsible, }); if (updateResponse.status === 'ok') fetchBazaarProject(); - handleClose(); + handleEditClose(); }; return ( - +
+ {bazaarProject.name}, + ' from the Bazaar?', + ]} + type="delete" + handleSubmit={handleDeleteSubmit} + /> + + { + setOpenDelete(true); + }} + > + Delete project + + } + /> +
); }; diff --git a/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx b/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx index acae8f5342..685d400d3e 100644 --- a/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx +++ b/plugins/bazaar/src/components/EntityBazaarInfoCard/EntityBazaarInfoCard.tsx @@ -15,347 +15,48 @@ */ import React, { useState, useEffect } from 'react'; -import { - Grid, - makeStyles, - Card, - CardContent, - CardHeader, - Typography, - Divider, - IconButton, - Popover, - MenuList, - MenuItem, - ListItemText, - Link, -} from '@material-ui/core'; -import { - Progress, - HeaderIconLinkRow, - IconLinkVerticalProps, - Avatar, -} from '@backstage/core-components'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { AboutField } from '@backstage/plugin-catalog'; -import { StatusTag } from '../StatusTag'; -import EditIcon from '@material-ui/icons/Edit'; -import ChatIcon from '@material-ui/icons/Chat'; -import PersonAddIcon from '@material-ui/icons/PersonAdd'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; -import DeleteIcon from '@material-ui/icons/Delete'; -import { EditProjectDialog } from '../EditProjectDialog'; -import { DeleteProjectDialog } from '../DeleteProjectDialog'; -import ExitToAppIcon from '@material-ui/icons/ExitToApp'; -import { useApi, identityApiRef } from '@backstage/core-plugin-api'; -import { Member, BazaarProject } from '../../types'; -import { bazaarApiRef } from '../../api'; -import { Alert } from '@material-ui/lab'; import { useAsyncFn } from 'react-use'; - -const useStyles = makeStyles({ - description: { - wordBreak: 'break-word', - }, - icon: { - marginRight: '1.75rem', - }, - link: { - color: '#9cc9ff', - '&:hover': { - textDecoration: 'underline', - }, - }, - memberLink: { - display: 'block', - marginBottom: '0.3rem', - }, -}); - -const sortMembers = (m1: Member, m2: Member) => { - return new Date(m2.joinDate!).getTime() - new Date(m1.joinDate!).getTime(); -}; +import { useApi } from '@backstage/core-plugin-api'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { bazaarApiRef } from '../../api'; +import { EntityBazaarInfoContent } from '../EntityBazaarInfoContent'; +import { Card } from '@material-ui/core'; +import { parseBazaarResponse } from '../../util/parseMethods'; export const EntityBazaarInfoCard = () => { const { entity } = useEntity(); - const classes = useStyles(); const bazaarApi = useApi(bazaarApiRef); - const identity = useApi(identityApiRef); - const [anchorEl, setAnchorEl] = useState(); - const [open, setOpen] = useState(false); - const [popoverOpen, setPopoverOpen] = useState(false); - const [openDelete, setOpenDelete] = useState(false); - const [isMember, setIsMember] = useState(false); - const [isBazaar, setIsBazaar] = useState(false); - const [members, fetchMembers] = useAsyncFn(async () => { - const response = await bazaarApi.getMembers(entity); - - const dbMembers = response.data.map((obj: any) => { - const member: Member = { - userId: obj.user_id, - entityRef: obj.entity_ref, - joinDate: obj.join_date, - picture: obj.picture, - }; - - return member; - }); - - dbMembers.sort(sortMembers); - - return dbMembers; - }); const [bazaarProject, fetchBazaarProject] = useAsyncFn(async () => { - const response = await bazaarApi.getMetadata(entity); + const response = await bazaarApi.getProjectByRef( + stringifyEntityRef(entity), + ); - if (response) { - const metadata = await response.json().then((resp: any) => resp.data[0]); - - if (metadata) { - return { - entityRef: metadata.entity_ref, - name: metadata.name, - community: metadata.community, - announcement: metadata.announcement, - status: metadata.status, - updatedAt: metadata.updated_at, - membersCount: metadata.members_count, - size: metadata.size, - startDate: metadata.start_date, - endDate: metadata.end_date, - responsible: metadata.responsible, - } as BazaarProject; - } - } - return null; + return await parseBazaarResponse(response); }); + const [isBazaar, setIsBazaar] = useState(bazaarProject.value ?? false); + useEffect(() => { - fetchMembers(); fetchBazaarProject(); - }, [fetchMembers, fetchBazaarProject]); + }, [fetchBazaarProject]); useEffect(() => { - const isBazaarMember = - members?.value - ?.map((member: Member) => member.userId) - .indexOf(identity.getUserId()) >= 0; - const isBazaarProject = bazaarProject.value !== null; + const isBazaarProject = bazaarProject.value !== undefined; - setIsMember(isBazaarMember); setIsBazaar(isBazaarProject); - }, [bazaarProject, members, identity]); + }, [bazaarProject.value]); - const onOpen = (event: React.SyntheticEvent) => { - setAnchorEl(event.currentTarget); - setPopoverOpen(true); - }; - - const closeEdit = () => { - setOpen(false); - }; - - const closeDelete = () => { - setOpenDelete(false); - }; - - const popoverCloseHandler = () => { - setPopoverOpen(false); - }; - - const handleMembersClick = async () => { - if (!isMember) { - await bazaarApi.addMember(entity); - } else { - await bazaarApi.deleteMember(entity); - } - - fetchMembers(); - fetchBazaarProject(); - }; - - const links: IconLinkVerticalProps[] = [ - { - label: isMember ? 'Leave' : 'Join', - icon: isMember ? : , - href: '', - onClick: async () => { - handleMembersClick(); - }, - }, - { - label: 'Community', - icon: , - href: bazaarProject?.value?.community, - disabled: !bazaarProject?.value?.community || !isMember, - }, - ]; - - if (!isBazaar) { - return null; - } else if (bazaarProject.loading || members.loading) { - return ; - } else if (bazaarProject.error) { - return {bazaarProject?.error?.message}; - } else if (members.error) { - return {members?.error?.message}; - } - return ( - - {bazaarProject?.value && ( - + - )} - - {bazaarProject?.value && ( - - )} - - - - } - subheader={} - /> - - - - - { - setOpen(true); - setPopoverOpen(false); - }} - > - - - - - { - setOpenDelete(true); - setPopoverOpen(false); - }} - > - - - - - - - - - {bazaarProject?.value?.announcement - ? bazaarProject?.value?.announcement - .split('\n') - .map((str: string, i: number) => ( - - {str} - - )) - : 'No announcement'} - - - - - {' '} - - {members?.value?.length ? ( - members.value.slice(0, 7).map((member: Member) => { - return ( -
- - - {member?.userId} - -
- ); - }) - ) : ( -
- )} - - - - - - - - - - - - - {bazaarProject?.value?.size} - - - - - - - - {bazaarProject?.value?.startDate?.substring(0, 10) || ''} - - - - - - - - {bazaarProject?.value?.endDate?.substring(0, 10) || ''} - - - - - - - - {bazaarProject?.value?.responsible || ''} - - - - - - - ); + + ); + } + return null; }; diff --git a/plugins/bazaar/src/components/EntityBazaarInfoContent/EntityBazaarInfoContent.tsx b/plugins/bazaar/src/components/EntityBazaarInfoContent/EntityBazaarInfoContent.tsx new file mode 100644 index 0000000000..f2c005abd1 --- /dev/null +++ b/plugins/bazaar/src/components/EntityBazaarInfoContent/EntityBazaarInfoContent.tsx @@ -0,0 +1,200 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { CardHeader, Divider, IconButton } from '@material-ui/core'; +import { + HeaderIconLinkRow, + IconLinkVerticalProps, +} from '@backstage/core-components'; +import EditIcon from '@material-ui/icons/Edit'; +import ChatIcon from '@material-ui/icons/Chat'; +import PersonAddIcon from '@material-ui/icons/PersonAdd'; +import DashboardIcon from '@material-ui/icons/Dashboard'; +import LinkOffIcon from '@material-ui/icons/LinkOff'; +import { EditProjectDialog } from '../EditProjectDialog'; +import { useApi, identityApiRef } from '@backstage/core-plugin-api'; +import { BazaarProject, Member } from '../../types'; +import { bazaarApiRef } from '../../api'; +import { Alert } from '@material-ui/lab'; +import { useAsyncFn } from 'react-use'; +import ExitToAppIcon from '@material-ui/icons/ExitToApp'; +import { parseEntityRef } from '@backstage/catalog-model'; +import { ConfirmationDialog } from '../ConfirmationDialog'; +import { CardContentFields } from '../CardContentFields'; +import { fetchProjectMembers } from '../../util/fetchMethods'; + +type Props = { + bazaarProject: BazaarProject | null | undefined; + fetchBazaarProject: () => Promise; +}; + +export const EntityBazaarInfoContent = ({ + bazaarProject, + fetchBazaarProject, +}: Props) => { + const bazaarApi = useApi(bazaarApiRef); + const identity = useApi(identityApiRef); + const [openEdit, setOpenEdit] = useState(false); + const [isMember, setIsMember] = useState(false); + const [openUnlink, setOpenUnlink] = useState(false); + const [members, fetchMembers] = useAsyncFn(async () => { + return bazaarProject + ? await fetchProjectMembers(bazaarApi, bazaarProject) + : []; + }); + + const [userId, fetchUserId] = useAsyncFn(async () => { + return await ( + await identity.getProfileInfo() + ).displayName; + }); + + useEffect(() => { + fetchMembers(); + fetchUserId(); + }, [fetchMembers, fetchUserId]); + + useEffect(() => { + if (members.value && userId.value) { + setIsMember( + members.value + ?.map((member: Member) => member.userId) + .indexOf(userId.value) >= 0, + ); + } + }, [bazaarProject, members, identity, userId.value]); + + const handleMembersClick = async () => { + if (userId.value) { + if (!isMember) { + await bazaarApi.addMember(bazaarProject?.id!, userId.value); + } else { + await bazaarApi.deleteMember(bazaarProject!.id, userId.value); + } + setIsMember(!isMember); + fetchMembers(); + } + }; + + const links: IconLinkVerticalProps[] = [ + { + label: 'Entity page', + icon: , + disabled: true, + }, + { + label: 'Unlink project', + icon: , + disabled: false, + onClick: () => { + setOpenUnlink(true); + }, + }, + { + label: isMember ? 'Leave' : 'Join', + icon: isMember ? : , + href: '', + onClick: async () => { + handleMembersClick(); + }, + }, + { + label: 'Community', + icon: , + href: bazaarProject?.community, + disabled: bazaarProject?.community === '' || !isMember, + }, + ]; + + const handleEditClose = () => { + setOpenEdit(false); + }; + + const handleUnlinkClose = () => { + setOpenUnlink(false); + }; + + const handleUnlinkSubmit = async () => { + const updateResponse = await bazaarApi.updateProject({ + ...bazaarProject, + entityRef: null, + }); + + if (updateResponse.status === 'ok') { + handleUnlinkClose(); + fetchBazaarProject(); + } + }; + + if (members.error) { + return {members?.error?.message}; + } + + if (bazaarProject) { + return ( +
+ + + {openUnlink && ( + {parseEntityRef(bazaarProject.entityRef!).name}, + ' from ', + {bazaarProject.name}, + ' ?', + ]} + type="unlink" + handleSubmit={handleUnlinkSubmit} + /> + )} + + + { + setOpenEdit(true); + }} + > + + +
+ } + subheader={} + /> + + + +
+ ); + } + return null; +}; diff --git a/plugins/bazaar/src/components/EntityBazaarInfoContent/index.ts b/plugins/bazaar/src/components/EntityBazaarInfoContent/index.ts new file mode 100644 index 0000000000..6be5ad7dbf --- /dev/null +++ b/plugins/bazaar/src/components/EntityBazaarInfoContent/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EntityBazaarInfoContent } from './EntityBazaarInfoContent'; diff --git a/plugins/bazaar/src/components/HomePage/HomePage.tsx b/plugins/bazaar/src/components/HomePage/HomePage.tsx index 32a16291fc..b34b7ac949 100644 --- a/plugins/bazaar/src/components/HomePage/HomePage.tsx +++ b/plugins/bazaar/src/components/HomePage/HomePage.tsx @@ -36,11 +36,7 @@ export const HomePage = () => { return (
-
+
); diff --git a/plugins/bazaar/src/components/HomePageBazaarInfoCard/HomePageBazaarInfoCard.tsx b/plugins/bazaar/src/components/HomePageBazaarInfoCard/HomePageBazaarInfoCard.tsx new file mode 100644 index 0000000000..daca0aec1d --- /dev/null +++ b/plugins/bazaar/src/components/HomePageBazaarInfoCard/HomePageBazaarInfoCard.tsx @@ -0,0 +1,269 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { Card, CardHeader, Divider, IconButton } from '@material-ui/core'; +import { + HeaderIconLinkRow, + IconLinkVerticalProps, +} from '@backstage/core-components'; +import EditIcon from '@material-ui/icons/Edit'; +import ChatIcon from '@material-ui/icons/Chat'; +import PersonAddIcon from '@material-ui/icons/PersonAdd'; +import InsertLinkIcon from '@material-ui/icons/InsertLink'; +import DashboardIcon from '@material-ui/icons/Dashboard'; +import CloseIcon from '@material-ui/icons/Close'; +import LinkOffIcon from '@material-ui/icons/LinkOff'; +import { EditProjectDialog } from '../EditProjectDialog'; +import ExitToAppIcon from '@material-ui/icons/ExitToApp'; +import { + useApi, + identityApiRef, + useRouteRef, +} from '@backstage/core-plugin-api'; +import { Member, BazaarProject } from '../../types'; +import { bazaarApiRef } from '../../api'; +import { Alert } from '@material-ui/lab'; +import { useAsyncFn } from 'react-use'; +import { + catalogApiRef, + catalogRouteRef, +} from '@backstage/plugin-catalog-react'; + +import { + parseEntityName, + stringifyEntityRef, + Entity, + parseEntityRef, +} from '@backstage/catalog-model'; + +import { ConfirmationDialog } from '../ConfirmationDialog/ConfirmationDialog'; +import { CardContentFields } from '../CardContentFields/CardContentFields'; +import { LinkProjectDialog } from '../LinkProjectDialog'; +import { + fetchCatalogItems, + fetchProjectMembers, +} from '../../util/fetchMethods'; +import { parseBazaarResponse } from '../../util/parseMethods'; + +type Props = { + initProject: BazaarProject; + handleClose: () => void; + initEntity: Entity; +}; + +export const HomePageBazaarInfoCard = ({ + initProject, + handleClose, + initEntity, +}: Props) => { + const catalogLink = useRouteRef(catalogRouteRef); + const bazaarApi = useApi(bazaarApiRef); + const identity = useApi(identityApiRef); + const catalogApi = useApi(catalogApiRef); + const [openEdit, setOpenEdit] = useState(false); + const [openProjectSelector, setOpenProjectSelector] = useState(false); + const [openUnlink, setOpenUnlink] = useState(false); + const [isMember, setIsMember] = useState(false); + + const [catalogEntities, fetchCatalogEntities] = useAsyncFn(async () => { + const entities = await fetchCatalogItems(catalogApi); + const bazaarProjects = await bazaarApi.getProjects(); + const bazaarLinkedRefs: string[] = bazaarProjects.data + .filter((entity: any) => entity.entity_ref !== null) + .map((entity: any) => entity.entity_ref); + + return entities.filter( + (entity: Entity) => + !bazaarLinkedRefs.includes(stringifyEntityRef(entity)), + ); + }); + + const [bazaarProject, fetchBazaarProject] = useAsyncFn(async () => { + const response = await bazaarApi.getProjectById(initProject.id); + return await parseBazaarResponse(response); + }); + + const [members, fetchMembers] = useAsyncFn(async () => { + return fetchProjectMembers(bazaarApi, bazaarProject.value ?? initProject); + }); + + const [userId, fetchUserId] = useAsyncFn(async () => { + return await ( + await identity.getProfileInfo() + ).displayName; + }); + + useEffect(() => { + fetchMembers(); + fetchBazaarProject(); + fetchCatalogEntities(); + fetchUserId(); + }, [fetchMembers, fetchBazaarProject, fetchCatalogEntities, fetchUserId]); + + useEffect(() => { + if (members.value && userId.value) { + setIsMember( + members.value + ?.map((member: Member) => member.userId) + .indexOf(userId.value) >= 0, + ); + } + }, [bazaarProject.value, members, identity, userId.value]); + + const handleMembersClick = async () => { + if (userId.value) { + if (!isMember) { + await bazaarApi.addMember(bazaarProject.value!.id, userId.value); + } else { + await bazaarApi.deleteMember(bazaarProject.value!.id, userId.value); + } + setIsMember(!isMember); + fetchMembers(); + } + }; + + const getEntityPageLink = () => { + if (bazaarProject?.value?.entityRef) { + const { name, kind, namespace } = parseEntityName( + bazaarProject.value.entityRef, + ); + return `${catalogLink()}/${namespace}/${kind}/${name}`; + } + return ''; + }; + + const handleLink = () => { + if (bazaarProject.value?.entityRef) { + setOpenUnlink(true); + } else { + fetchCatalogEntities(); + setOpenProjectSelector(true); + } + }; + + const links: IconLinkVerticalProps[] = [ + { + label: 'Entity page', + icon: , + href: bazaarProject.value?.entityRef ? getEntityPageLink() : '', + disabled: bazaarProject.value?.entityRef === null, + }, + { + label: bazaarProject.value?.entityRef ? 'Unlink project' : 'Link project', + icon: bazaarProject.value?.entityRef ? ( + + ) : ( + + ), + onClick: handleLink, + }, + { + label: isMember ? 'Leave' : 'Join', + icon: isMember ? : , + href: '', + onClick: async () => { + handleMembersClick(); + }, + }, + { + label: 'Community', + icon: , + href: bazaarProject.value?.community, + disabled: !bazaarProject.value?.community || !isMember, + }, + ]; + + const handleUnlinkSubmit = async () => { + const updateResponse = await bazaarApi.updateProject({ + ...bazaarProject.value, + entityRef: null, + }); + + if (updateResponse.status === 'ok') { + setOpenUnlink(false); + fetchBazaarProject(); + } + }; + + if (bazaarProject.error) { + return {bazaarProject?.error?.message}; + } else if (members.error) { + return {members?.error?.message}; + } + + return ( +
+ setOpenProjectSelector(false)} + catalogEntities={catalogEntities.value || []} + bazaarProject={bazaarProject.value || initProject} + fetchBazaarProject={fetchBazaarProject} + initEntity={initEntity} + /> + + {openUnlink && ( + setOpenUnlink(false)} + message={[ + 'Are you sure you want to unlink ', + {parseEntityRef(bazaarProject.value?.entityRef!).name}, + ' from ', + {bazaarProject.value?.name}, + ' ?', + ]} + type="unlink" + handleSubmit={handleUnlinkSubmit} + /> + )} + + + setOpenEdit(false)} + handleCardClose={handleClose} + fetchBazaarProject={fetchBazaarProject} + /> + + + setOpenEdit(true)}> + + + + + +
+ } + subheader={} + /> + + + +
+
+ ); +}; diff --git a/plugins/bazaar/src/components/HomePageBazaarInfoCard/index.ts b/plugins/bazaar/src/components/HomePageBazaarInfoCard/index.ts new file mode 100644 index 0000000000..0edfba0652 --- /dev/null +++ b/plugins/bazaar/src/components/HomePageBazaarInfoCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { HomePageBazaarInfoCard } from './HomePageBazaarInfoCard'; diff --git a/plugins/bazaar/src/components/InputField/InputField.tsx b/plugins/bazaar/src/components/InputField/InputField.tsx index 9932e5b1fc..76eb28da6c 100644 --- a/plugins/bazaar/src/components/InputField/InputField.tsx +++ b/plugins/bazaar/src/components/InputField/InputField.tsx @@ -25,7 +25,7 @@ type Rules = { }; type Props = { - inputType: 'announcement' | 'community' | 'responsible'; + inputType: 'description' | 'community' | 'responsible' | 'name'; error?: FieldError | undefined; control: Control; helperText?: string; diff --git a/plugins/bazaar/src/components/InputSelector/InputSelector.tsx b/plugins/bazaar/src/components/InputSelector/InputSelector.tsx index 25ed459963..b7e9ecd96a 100644 --- a/plugins/bazaar/src/components/InputSelector/InputSelector.tsx +++ b/plugins/bazaar/src/components/InputSelector/InputSelector.tsx @@ -60,12 +60,7 @@ export const InputSelector = ({ name, options, control, error }: Props) => { > {options.map(option => { return ( - + {option} ); diff --git a/plugins/bazaar/src/components/LinkProjectDialog/LinkProjectDialog.tsx b/plugins/bazaar/src/components/LinkProjectDialog/LinkProjectDialog.tsx new file mode 100644 index 0000000000..92a7344dac --- /dev/null +++ b/plugins/bazaar/src/components/LinkProjectDialog/LinkProjectDialog.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { + Dialog, + DialogActions, + Button, + DialogContent, + makeStyles, +} from '@material-ui/core'; +import { ProjectSelector } from '../ProjectSelector'; +import { CustomDialogTitle } from '../CustomDialogTitle'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; + +import { bazaarApiRef } from '../../api'; +import { useApi } from '@backstage/core-plugin-api'; + +import { BazaarProject } from '../../types'; + +type Props = { + openProjectSelector: boolean; + handleProjectSelectorClose: () => void; + catalogEntities: Entity[]; + bazaarProject: BazaarProject; + fetchBazaarProject: () => Promise; + initEntity: Entity; +}; + +const useStyles = makeStyles({ + content: { padding: '0 1rem' }, +}); + +export const LinkProjectDialog = ({ + openProjectSelector, + handleProjectSelectorClose, + catalogEntities, + bazaarProject, + fetchBazaarProject, + initEntity, +}: Props) => { + const classes = useStyles(); + const bazaarApi = useApi(bazaarApiRef); + const [selectedEntity, setSelectedEntity] = useState(initEntity); + const handleEntityClick = (entity: Entity) => { + setSelectedEntity(entity); + }; + + const handleSubmit = async () => { + handleProjectSelectorClose(); + + const updateResponse = await bazaarApi.updateProject({ + ...bazaarProject, + entityRef: stringifyEntityRef(selectedEntity!), + }); + if (updateResponse.status === 'ok') fetchBazaarProject(); + }; + + return ( + + + Select entity + + + + + + + + + + ); +}; diff --git a/plugins/bazaar/src/components/LinkProjectDialog/index.ts b/plugins/bazaar/src/components/LinkProjectDialog/index.ts new file mode 100644 index 0000000000..68d8e7c89e --- /dev/null +++ b/plugins/bazaar/src/components/LinkProjectDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { LinkProjectDialog } from './LinkProjectDialog'; diff --git a/plugins/bazaar/src/components/ProjectCard/ProjectCard.tsx b/plugins/bazaar/src/components/ProjectCard/ProjectCard.tsx index b3c8b3e2e7..a558d347a5 100644 --- a/plugins/bazaar/src/components/ProjectCard/ProjectCard.tsx +++ b/plugins/bazaar/src/components/ProjectCard/ProjectCard.tsx @@ -14,85 +14,94 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { ItemCardHeader } from '@backstage/core-components'; import { Card, CardActionArea, CardContent, + Dialog, makeStyles, Typography, } from '@material-ui/core'; import { StatusTag } from '../StatusTag/StatusTag'; -import { Link as RouterLink } from 'react-router-dom'; -import { catalogRouteRef } from '@backstage/plugin-catalog-react'; -import { useRouteRef } from '@backstage/core-plugin-api'; import { BazaarProject } from '../../types'; -import { parseEntityName } from '@backstage/catalog-model'; import { DateTime } from 'luxon'; +import { HomePageBazaarInfoCard } from '../HomePageBazaarInfoCard'; +import { Entity } from '@backstage/catalog-model'; const useStyles = makeStyles({ statusTag: { display: 'inline-block', whiteSpace: 'nowrap', - marginBottom: '0.5rem', - }, - announcement: { - display: '-webkit-box', - WebkitLineClamp: 5, - WebkitBoxOrient: 'vertical', marginBottom: '0.8rem', + }, + description: { + display: '-webkit-box', + WebkitLineClamp: 7, + WebkitBoxOrient: 'vertical', overflow: 'hidden', + backgroundColor: '', }, memberCount: { float: 'right', }, + content: { height: '13rem', marginBottom: '-0.5rem' }, }); type Props = { - bazaarProject: BazaarProject; + project: BazaarProject; + fetchBazaarProjects: () => Promise; + catalogEntities: Entity[]; }; -export const ProjectCard = ({ bazaarProject }: Props) => { +export const ProjectCard = ({ + project, + fetchBazaarProjects, + catalogEntities, +}: Props) => { const classes = useStyles(); - const { entityRef, name, status, updatedAt, announcement, membersCount } = - bazaarProject; - const catalogLink = useRouteRef(catalogRouteRef); - const { namespace, kind } = parseEntityName(entityRef); + const [openCard, setOpenCard] = useState(false); + const { id, name, status, updatedAt, description, membersCount } = project; + + const handleClose = () => { + setOpenCard(false); + fetchBazaarProjects(); + }; return ( - - - + + - - - - {membersCount === 1 - ? `${membersCount} member` - : `${membersCount} members`} - -
- - {announcement} +
+ + + setOpenCard(true)}> + + + + + {Number(membersCount) === Number(1) + ? `${membersCount} member` + : `${membersCount} members`} -
- - - + + {description} + + + + +
); }; diff --git a/plugins/bazaar/src/components/ProjectDialog/ProjectDialog.tsx b/plugins/bazaar/src/components/ProjectDialog/ProjectDialog.tsx index a65122b26e..08c43d1c2e 100644 --- a/plugins/bazaar/src/components/ProjectDialog/ProjectDialog.tsx +++ b/plugins/bazaar/src/components/ProjectDialog/ProjectDialog.tsx @@ -15,17 +15,7 @@ */ import React from 'react'; -import { - createStyles, - Theme, - withStyles, - WithStyles, -} from '@material-ui/core/styles'; -import MuiDialogTitle from '@material-ui/core/DialogTitle'; -import MuiDialogContent from '@material-ui/core/DialogContent'; -import MuiDialogActions from '@material-ui/core/DialogActions'; -import CloseIcon from '@material-ui/icons/Close'; -import { Button, Dialog, Typography, IconButton } from '@material-ui/core'; +import { Button, Dialog } from '@material-ui/core'; import { useForm, SubmitHandler, @@ -36,61 +26,11 @@ import { InputField } from '../InputField/InputField'; import { InputSelector } from '../InputSelector/InputSelector'; import { FormValues } from '../../types'; import { DoubleDateSelector } from '../DoubleDateSelector/DoubleDateSelector'; - -const styles = (theme: Theme) => - createStyles({ - root: { - margin: 0, - padding: theme.spacing(2), - }, - closeButton: { - position: 'absolute', - right: theme.spacing(1), - top: theme.spacing(1), - color: theme.palette.grey[500], - }, - }); - -/* - DialogTitleProps, DialogTitle, DialogContent and DialogActions - are copied from the git-release plugin -*/ -export interface DialogTitleProps extends WithStyles { - id: string; - children: React.ReactNode; - onClose: () => void; -} - -const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { - const { children, classes, onClose, ...other } = props; - return ( - - {children} - {onClose ? ( - - - - ) : null} - - ); -}); - -const DialogContent = withStyles((theme: Theme) => ({ - root: { - padding: theme.spacing(2), - }, -}))(MuiDialogContent); - -const DialogActions = withStyles((theme: Theme) => ({ - root: { - margin: 0, - padding: theme.spacing(1), - }, -}))(MuiDialogActions); +import { + CustomDialogTitle, + DialogActions, + DialogContent, +} from '../CustomDialogTitle'; type Props = { handleSave: ( @@ -102,6 +42,7 @@ type Props = { defaultValues: FormValues; open: boolean; projectSelector?: JSX.Element; + deleteButton?: JSX.Element; handleClose: () => void; }; @@ -112,6 +53,7 @@ export const ProjectDialog = ({ defaultValues, open, projectSelector, + deleteButton, handleClose, }: Props) => { const { @@ -123,16 +65,16 @@ export const ProjectDialog = ({ setValue, } = useForm({ mode: 'onChange', - defaultValues: defaultValues, + defaultValues, }); - const handleCloseAndClear = () => { - handleClose(); - reset(defaultValues); + const handleSaveForm = () => { + handleSave(getValues, reset); }; - const handleSaveProject = () => { - handleSave(getValues, reset); + const handleCloseDialog = () => { + handleClose(); + reset(defaultValues); }; return ( @@ -140,25 +82,36 @@ export const ProjectDialog = ({ - + {title} - - - {isAddForm && projectSelector} + + + - + {isAddForm && projectSelector} + + + {!isAddForm && deleteButton}
- } - handleClose={handleCloseNoProjects} - /> - +
+ + + { + setSearchValue(newSortMethod); + }} + onCancelSearch={() => { + setSearchValue(''); + }} + /> { setOpenAdd(false); }} @@ -176,10 +191,11 @@ export const SortView = () => { fetchCatalogEntities={fetchCatalogEntities} /> - +
diff --git a/plugins/bazaar/src/types.ts b/plugins/bazaar/src/types.ts index 7d810c4eab..30c07286ac 100644 --- a/plugins/bazaar/src/types.ts +++ b/plugins/bazaar/src/types.ts @@ -17,7 +17,7 @@ import { EntityRef } from '@backstage/catalog-model'; export type Member = { - entityRef: EntityRef; + itemId: number; userId: string; joinDate?: string; picture?: string; @@ -29,10 +29,11 @@ export type Size = 'small' | 'medium' | 'large'; export type BazaarProject = { name: string; - entityRef: EntityRef; + id: number; + entityRef?: EntityRef; community: string; status: Status; - announcement: string; + description: string; updatedAt?: string; membersCount: number; size: Size; @@ -42,7 +43,8 @@ export type BazaarProject = { }; export type FormValues = { - announcement: string; + name: string; + description: string; community: string; status: string; size: Size; diff --git a/plugins/bazaar/src/util/fetchMethods.ts b/plugins/bazaar/src/util/fetchMethods.ts new file mode 100644 index 0000000000..015abae210 --- /dev/null +++ b/plugins/bazaar/src/util/fetchMethods.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { sortMembers } from './sortMethods'; +import { parseMember } from './parseMethods'; +import { BazaarProject, Member } from '../types'; +import { BazaarApi } from '../api'; +import { Entity } from '@backstage/catalog-model'; + +export const fetchProjectMembers = async ( + bazaarApi: BazaarApi, + project: BazaarProject, +): Promise => { + const response = await bazaarApi.getMembers(project.id); + + if (response.data.length > 0) { + const dbMembers = response.data.map((member: any) => { + return parseMember(member); + }); + + dbMembers.sort(sortMembers); + return dbMembers; + } + return []; +}; + +export const fetchCatalogItems = async (catalogApi: any): Promise => { + const entities = await catalogApi.getEntities({ + filter: { + kind: ['Component', 'Resource'], + }, + fields: ['kind', 'metadata.name', 'metadata.namespace'], + }); + + return entities.items; +}; diff --git a/plugins/bazaar/src/util/parseMethods.ts b/plugins/bazaar/src/util/parseMethods.ts new file mode 100644 index 0000000000..1b4ebfd308 --- /dev/null +++ b/plugins/bazaar/src/util/parseMethods.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BazaarProject, Member } from '../types'; + +export const parseBazaarProject = (metadata: any): BazaarProject => { + return { + id: metadata.id, + entityRef: metadata.entity_ref, + name: metadata.name, + community: metadata.community, + description: metadata.description, + status: metadata.status, + updatedAt: metadata.updated_at, + membersCount: metadata.members_count, + size: metadata.size, + startDate: metadata.start_date, + endDate: metadata.end_date, + responsible: metadata.responsible, + } as BazaarProject; +}; + +export const parseMember = (member: any): Member => { + return { + itemId: member.item_id, + userId: member.user_id, + joinDate: member.join_date, + picture: member.picture, + } as Member; +}; + +export const parseBazaarResponse = async (response: any) => { + if (response) { + const metadata = await response.json().then((resp: any) => resp.data[0]); + + if (metadata) { + return parseBazaarProject(metadata); + } + } + return null; +}; diff --git a/plugins/bazaar/src/util/sortMethods.ts b/plugins/bazaar/src/util/sortMethods.ts new file mode 100644 index 0000000000..d75639edca --- /dev/null +++ b/plugins/bazaar/src/util/sortMethods.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BazaarProject, Member } from '../types'; + +export const sortMembers = (m1: Member, m2: Member) => { + return new Date(m2.joinDate!).getTime() - new Date(m1.joinDate!).getTime(); +}; + +export const sortByDate = (a: BazaarProject, b: BazaarProject): number => { + const dateA = new Date(a.updatedAt!).getTime(); + const dateB = new Date(b.updatedAt!).getTime(); + return dateB - dateA; +}; + +export const sortByName = (a: BazaarProject, b: BazaarProject) => { + if (a.name < b.name) { + return -1; + } else if (a.name > b.name) { + return 1; + } + return 0; +}; + +export const sortByMembers = (a: BazaarProject, b: BazaarProject) => { + return b.membersCount - a.membersCount; +}; diff --git a/plugins/bitrise/src/plugin.ts b/plugins/bitrise/src/plugin.ts index a6f0706999..92beda287c 100644 --- a/plugins/bitrise/src/plugin.ts +++ b/plugins/bitrise/src/plugin.ts @@ -26,8 +26,6 @@ import { export const bitriseApiRef = createApiRef({ id: 'plugin.bitrise.service', - description: - 'Used by the BitriseCI plugin to retrieve information about builds.', }); export const bitrisePlugin = createPlugin({ diff --git a/plugins/catalog-common/.eslintrc.js b/plugins/catalog-common/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/catalog-common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/catalog-common/README.md b/plugins/catalog-common/README.md new file mode 100644 index 0000000000..2566a737d8 --- /dev/null +++ b/plugins/catalog-common/README.md @@ -0,0 +1,9 @@ +# Catalog Common + +Shared isomorphic code for the catalog plugin. + +## 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-common/api-report.md b/plugins/catalog-common/api-report.md new file mode 100644 index 0000000000..8e03fc16b5 --- /dev/null +++ b/plugins/catalog-common/api-report.md @@ -0,0 +1,31 @@ +## API Report File for "@backstage/plugin-catalog-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Permission } from '@backstage/plugin-permission-common'; + +// @public +export const catalogEntityDeletePermission: Permission; + +// @public +export const catalogEntityReadPermission: Permission; + +// @public +export const catalogEntityRefreshPermission: Permission; + +// @public +export const catalogLocationCreatePermission: Permission; + +// @public +export const catalogLocationDeletePermission: Permission; + +// @public +export const catalogLocationReadPermission: Permission; + +// @public (undocumented) +export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity'; + +// @public (undocumented) +export const RESOURCE_TYPE_CATALOG_LOCATION = 'catalog-location'; +``` diff --git a/plugins/catalog-common/package.json b/plugins/catalog-common/package.json new file mode 100644 index 0000000000..439a76fe58 --- /dev/null +++ b/plugins/catalog-common/package.json @@ -0,0 +1,41 @@ +{ + "name": "@backstage/plugin-catalog-common", + "description": "Common functionalities for the catalog plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/catalog-common" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test --passWithNoTests", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/plugin-permission-common": "^0.2.0" + }, + "devDependencies": { + "@backstage/cli": "^0.10.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/catalog-common/src/index.ts b/plugins/catalog-common/src/index.ts new file mode 100644 index 0000000000..a38a5ed93b --- /dev/null +++ b/plugins/catalog-common/src/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Provides shared objects useful for interacting with the catalog and its + * entities, such as catalog permissions. + * + * @packageDocumentation + */ + +export { + RESOURCE_TYPE_CATALOG_ENTITY, + RESOURCE_TYPE_CATALOG_LOCATION, + catalogEntityReadPermission, + catalogEntityDeletePermission, + catalogEntityRefreshPermission, + catalogLocationReadPermission, + catalogLocationCreatePermission, + catalogLocationDeletePermission, +} from './permissions'; diff --git a/plugins/catalog-common/src/permissions.ts b/plugins/catalog-common/src/permissions.ts new file mode 100644 index 0000000000..eb7b01c31e --- /dev/null +++ b/plugins/catalog-common/src/permissions.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Permission } from '@backstage/plugin-permission-common'; + +/** + * {@link https://backstage.io/docs/features/software-catalog/software-catalog-overview} + * @public + */ +export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity'; + +/** + * {@link https://backstage.io/docs/features/software-catalog/descriptor-format#kind-location} + * @public + */ +export const RESOURCE_TYPE_CATALOG_LOCATION = 'catalog-location'; + +/** + * This permission is used to authorize actions that involve reading one or more + * entities from the catalog. + * + * If this permission is not authorized, it will appear that the entity does not + * exist in the catalog — both in the frontend and in API responses. + * @public + */ +export const catalogEntityReadPermission: Permission = { + name: 'catalog.entity.read', + attributes: { + action: 'read', + }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}; + +/** + * This permission is used to designate actions that involve removing one or + * more entities from the catalog. + * @public + */ +export const catalogEntityDeletePermission: Permission = { + name: 'catalog.entity.delete', + attributes: { + action: 'delete', + }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}; + +/** + * This permission is used to designate refreshing one or more entities from the + * catalog. + * @public + */ +export const catalogEntityRefreshPermission: Permission = { + name: 'catalog.entity.refresh', + attributes: { + action: 'update', + }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}; + +/** + * This permission is used to designate actions that involve reading one or more + * locations from the catalog. + * + * If this permission is not authorized, it will appear that the location does + * not exist in the catalog — both in the frontend and in API responses. + * @public + */ +export const catalogLocationReadPermission: Permission = { + name: 'catalog.location.read', + attributes: { + action: 'read', + }, + resourceType: RESOURCE_TYPE_CATALOG_LOCATION, +}; + +/** + * This permission is used to designate actions that involve creating catalog + * locations. + * @public + */ +export const catalogLocationCreatePermission: Permission = { + name: 'catalog.location.create', + attributes: { + action: 'create', + }, + resourceType: RESOURCE_TYPE_CATALOG_LOCATION, +}; + +/** + * This permission is used to designate actions that involve deleting locations + * from the catalog. + * @public + */ +export const catalogLocationDeletePermission: Permission = { + name: 'catalog.location.delete', + attributes: { + action: 'delete', + }, + resourceType: RESOURCE_TYPE_CATALOG_LOCATION, +}; diff --git a/plugins/bazaar/src/components/AlertBanner/index.ts b/plugins/catalog-common/src/setupTests.ts similarity index 92% rename from plugins/bazaar/src/components/AlertBanner/index.ts rename to plugins/catalog-common/src/setupTests.ts index 1728cab9fc..fb7d1a181a 100644 --- a/plugins/bazaar/src/components/AlertBanner/index.ts +++ b/plugins/catalog-common/src/setupTests.ts @@ -13,5 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -export { AlertBanner } from './AlertBanner'; +export {}; diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 89c27f1875..5fae076479 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -20,7 +20,6 @@ import { PartialEntity } from '../types'; export const catalogImportApiRef = createApiRef({ id: 'plugin.catalog-import.service', - description: 'Used by the catalog import plugin to make requests', }); // result of the analyze state diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index 2a0eacf074..e8b00d8513 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -19,13 +19,13 @@ import { useEntity, useEntityFromUrl, } from '@backstage/plugin-catalog-react'; -import { Link, Typography } from '@material-ui/core'; +import { Typography } from '@material-ui/core'; import React, { ComponentType, ReactNode } from 'react'; import { Navigate, Route, Routes, useParams } from 'react-router'; import { CatalogPage } from './CatalogPage'; import { EntityNotFound } from './EntityNotFound'; import { EntityPageLayout } from './EntityPageLayout'; -import { Content } from '@backstage/core-components'; +import { Content, Link } from '@backstage/core-components'; const DefaultEntityPage = () => ( @@ -38,9 +38,7 @@ const DefaultEntityPage = () => ( To override this component with your custom implementation, read docs on{' '} - - backstage.io/docs - + backstage.io/docs } diff --git a/plugins/circleci/src/api/CircleCIApi.ts b/plugins/circleci/src/api/CircleCIApi.ts index 498cbb588a..78b8b378d6 100644 --- a/plugins/circleci/src/api/CircleCIApi.ts +++ b/plugins/circleci/src/api/CircleCIApi.ts @@ -33,7 +33,6 @@ export type { BuildWithSteps, BuildStepAction, BuildSummary }; export const circleCIApiRef = createApiRef({ id: 'plugin.circleci.service', - description: 'Used by the CircleCI plugin to make requests', }); const DEFAULT_PROXY_PATH = '/circleci/api'; diff --git a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx index 53cbc754cf..adc8a569bb 100644 --- a/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/circleci/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -17,7 +17,6 @@ import React from 'react'; import { Avatar, - Link, Typography, Box, IconButton, @@ -37,6 +36,7 @@ import { StatusRunning, Table, TableColumn, + Link, } from '@backstage/core-components'; import { useRouteRef } from '@backstage/core-plugin-api'; @@ -121,9 +121,7 @@ const SourceInfo = ({ build }: { build: CITableBuildInfo }) => { {source?.branchName} {source?.commit?.url !== undefined ? ( - - {source?.commit.shortHash} - + {source?.commit.shortHash} ) : ( source?.commit.shortHash )} @@ -169,7 +167,7 @@ const generatedColumns: TableColumn[] = [ field: 'buildName', highlight: true, render: (row: Partial) => ( - + diff --git a/plugins/cloudbuild/src/api/CloudbuildApi.ts b/plugins/cloudbuild/src/api/CloudbuildApi.ts index 2b09d24e64..92ea8511fe 100644 --- a/plugins/cloudbuild/src/api/CloudbuildApi.ts +++ b/plugins/cloudbuild/src/api/CloudbuildApi.ts @@ -22,7 +22,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const cloudbuildApiRef = createApiRef({ id: 'plugin.cloudbuild.service', - description: 'Used by the Cloudbuild plugin to make requests', }); export type CloudbuildApi = { diff --git a/plugins/cloudbuild/src/components/Cards/Cards.tsx b/plugins/cloudbuild/src/components/Cards/Cards.tsx index a1c9b3c076..dad1f7d38f 100644 --- a/plugins/cloudbuild/src/components/Cards/Cards.tsx +++ b/plugins/cloudbuild/src/components/Cards/Cards.tsx @@ -19,12 +19,13 @@ import { WorkflowRunsTable } from '../WorkflowRunsTable'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; -import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core'; +import { Theme, makeStyles, LinearProgress } from '@material-ui/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; import { CLOUDBUILD_ANNOTATION } from '../useProjectName'; import { InfoCard, + Link, StructuredMetadataTable, WarningPanel, } from '@backstage/core-components'; @@ -62,7 +63,7 @@ const WidgetContent = ({ ), message: lastRun.message, url: ( - + See more on Google{' '} diff --git a/plugins/code-coverage/src/api.ts b/plugins/code-coverage/src/api.ts index a4a7bba37c..61f920106b 100644 --- a/plugins/code-coverage/src/api.ts +++ b/plugins/code-coverage/src/api.ts @@ -34,7 +34,6 @@ export type CodeCoverageApi = { export const codeCoverageApiRef = createApiRef({ id: 'plugin.code-coverage.service', - description: 'Used by the code coverage plugin to make requests', }); export class CodeCoverageRestApi implements CodeCoverageApi { diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 40882ebd95..58c8592273 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -148,5 +148,4 @@ export type CostInsightsApi = { export const costInsightsApiRef = createApiRef({ id: 'plugin.costinsights.service', - description: 'Provides cost data and alerts for the cost-insights plugin', }); diff --git a/plugins/explore-react/src/tools/api.ts b/plugins/explore-react/src/tools/api.ts index 33b4b7b66c..8a91b84785 100644 --- a/plugins/explore-react/src/tools/api.ts +++ b/plugins/explore-react/src/tools/api.ts @@ -18,7 +18,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const exploreToolsConfigRef = createApiRef({ id: 'plugin.explore.toolsconfig', - description: 'Used to configure tools displayed in the explore plugin', }); export type ExploreTool = { diff --git a/plugins/firehydrant/src/api/index.ts b/plugins/firehydrant/src/api/index.ts index b78e81a024..7101b5dc62 100644 --- a/plugins/firehydrant/src/api/index.ts +++ b/plugins/firehydrant/src/api/index.ts @@ -39,7 +39,6 @@ export interface FireHydrantAPI { export const fireHydrantApiRef = createApiRef({ id: 'plugin.firehydrant.service', - description: 'Used by FireHydrant plugin for requests', }); export type Options = { diff --git a/plugins/fossa/src/api/FossaApi.ts b/plugins/fossa/src/api/FossaApi.ts index 918eab62a4..9a3932fea0 100644 --- a/plugins/fossa/src/api/FossaApi.ts +++ b/plugins/fossa/src/api/FossaApi.ts @@ -26,7 +26,6 @@ export interface FindingSummary { export const fossaApiRef = createApiRef({ id: 'plugin.fossa.service', - description: 'Used by the Fossa plugin to make requests', }); export type FossaApi = { diff --git a/plugins/gcp-projects/src/api/GcpApi.ts b/plugins/gcp-projects/src/api/GcpApi.ts index 5626796a05..9167d2983f 100644 --- a/plugins/gcp-projects/src/api/GcpApi.ts +++ b/plugins/gcp-projects/src/api/GcpApi.ts @@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const gcpApiRef = createApiRef({ id: 'plugin.gcpprojects.service', - description: 'Used by the GCP Projects plugin to make requests', }); export type GcpApi = { diff --git a/plugins/git-release-manager/src/api/serviceApiRef.test.ts b/plugins/git-release-manager/src/api/serviceApiRef.test.ts index 03a6bbae02..b27738671d 100644 --- a/plugins/git-release-manager/src/api/serviceApiRef.test.ts +++ b/plugins/git-release-manager/src/api/serviceApiRef.test.ts @@ -23,7 +23,6 @@ describe('gitReleaseManagerApiRef', () => { expect(result).toMatchInlineSnapshot(` ApiRefImpl { "config": Object { - "description": "Used by the Git Release Manager plugin to make requests", "id": "plugin.git-release-manager.service", }, } diff --git a/plugins/git-release-manager/src/api/serviceApiRef.ts b/plugins/git-release-manager/src/api/serviceApiRef.ts index 75f0203e47..f2c695f455 100644 --- a/plugins/git-release-manager/src/api/serviceApiRef.ts +++ b/plugins/git-release-manager/src/api/serviceApiRef.ts @@ -20,5 +20,4 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const gitReleaseManagerApiRef = createApiRef({ id: 'plugin.git-release-manager.service', - description: 'Used by the Git Release Manager plugin to make requests', }); diff --git a/plugins/github-actions/src/api/GithubActionsApi.ts b/plugins/github-actions/src/api/GithubActionsApi.ts index e30145c4b3..fe516a0211 100644 --- a/plugins/github-actions/src/api/GithubActionsApi.ts +++ b/plugins/github-actions/src/api/GithubActionsApi.ts @@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const githubActionsApiRef = createApiRef({ id: 'plugin.githubactions.service', - description: 'Used by the GitHub Actions plugin to make requests', }); export type GithubActionsApi = { diff --git a/plugins/github-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index 6f6531044e..cb4b2190fc 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -18,7 +18,6 @@ import { readGitHubIntegrationConfigs } from '@backstage/integration'; import { useEntity } from '@backstage/plugin-catalog-react'; import { LinearProgress, - Link, makeStyles, Theme, Typography, @@ -34,6 +33,7 @@ import { configApiRef, errorApiRef, useApi } from '@backstage/core-plugin-api'; import { InfoCard, InfoCardVariants, + Link, StructuredMetadataTable, } from '@backstage/core-components'; @@ -71,7 +71,7 @@ const WidgetContent = ({ ), message: lastRun.message, url: ( - + See more on GitHub{' '} diff --git a/plugins/github-deployments/src/api/index.ts b/plugins/github-deployments/src/api/index.ts index bec0167f63..b64d914612 100644 --- a/plugins/github-deployments/src/api/index.ts +++ b/plugins/github-deployments/src/api/index.ts @@ -91,7 +91,6 @@ export interface GithubDeploymentsApi { export const githubDeploymentsApiRef = createApiRef({ id: 'plugin.github-deployments.service', - description: 'Used by the GitHub Deployments plugin to make requests', }); export type Options = { diff --git a/plugins/gitops-profiles/src/api.ts b/plugins/gitops-profiles/src/api.ts index 86cfd805c9..ca141140d8 100644 --- a/plugins/gitops-profiles/src/api.ts +++ b/plugins/gitops-profiles/src/api.ts @@ -113,7 +113,6 @@ export type GitOpsApi = { export const gitOpsApiRef = createApiRef({ id: 'plugin.gitops.service', - description: 'Used by the GitOps profiles plugin to make requests', }); export class GitOpsRestApi implements GitOpsApi { diff --git a/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx b/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx index 28c4bb65f3..9ac93a6d6c 100644 --- a/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx +++ b/plugins/gitops-profiles/src/components/ClusterTable/ClusterTable.tsx @@ -14,10 +14,9 @@ * limitations under the License. */ import React from 'react'; -import { Link } from '@material-ui/core'; import { ClusterStatus } from '../../api'; import { transformStatus } from '../ProfileCatalog/ProfileCatalog'; -import { Table, TableColumn } from '@backstage/core-components'; +import { Link, Table, TableColumn } from '@backstage/core-components'; const columns: TableColumn[] = [ { @@ -25,7 +24,7 @@ const columns: TableColumn[] = [ field: 'name', highlight: true, render: (componentData: any) => ( - + {componentData.name} ), diff --git a/plugins/ilert/src/api/client.ts b/plugins/ilert/src/api/client.ts index f05e58878f..13de8c1f2c 100644 --- a/plugins/ilert/src/api/client.ts +++ b/plugins/ilert/src/api/client.ts @@ -41,7 +41,6 @@ import { export const ilertApiRef = createApiRef({ id: 'plugin.ilert.service', - description: 'Used to make requests towards iLert API', }); const DEFAULT_PROXY_PATH = '/ilert'; diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index c909c54806..b8d171f9c1 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -52,6 +52,8 @@ export interface JenkinsInfo { // (undocumented) baseUrl: string; // (undocumented) + crumbIssuer?: boolean; + // (undocumented) headers?: Record; // (undocumented) jobFullName: string; @@ -77,6 +79,8 @@ export interface JenkinsInstanceConfig { // (undocumented) baseUrl: string; // (undocumented) + crumbIssuer?: boolean; + // (undocumented) name: string; // (undocumented) username: string; diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index 5f74259eef..a410d8ad92 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -411,4 +411,17 @@ describe('JenkinsApi', () => { }); expect(mockedJenkinsClient.job.build).toBeCalledWith(jobFullName); }); + + it('buildProject with crumbIssuer option', async () => { + const info: JenkinsInfo = { ...jenkinsInfo, crumbIssuer: true }; + await jenkinsApi.buildProject(info, jobFullName); + + expect(mockedJenkins).toHaveBeenCalledWith({ + baseUrl: jenkinsInfo.baseUrl, + headers: jenkinsInfo.headers, + promisify: true, + crumbIssuer: true, + }); + expect(mockedJenkinsClient.job.build).toBeCalledWith(jobFullName); + }); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 13705ce049..f156259f04 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -146,6 +146,7 @@ export class JenkinsApiImpl { baseUrl: jenkinsInfo.baseUrl, headers: jenkinsInfo.headers, promisify: true, + crumbIssuer: jenkinsInfo.crumbIssuer, }) as any; } diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index 08439297a7..21a2022bb3 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -210,6 +210,7 @@ describe('DefaultJenkinsInfoProvider', () => { expect(mockCatalog.getEntityByName).toBeCalledWith(entityRef); expect(info).toStrictEqual({ baseUrl: 'https://jenkins.example.com', + crumbIssuer: undefined, headers: { Authorization: 'Basic YmFja3N0YWdlIC0gYm90OjEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNlZGYwMTI=', diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 1a992e73f1..f5cb52b696 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -38,6 +38,7 @@ export interface JenkinsInfo { baseUrl: string; headers?: Record; jobFullName: string; // TODO: make this an array + crumbIssuer?: boolean; } export interface JenkinsInstanceConfig { @@ -45,6 +46,7 @@ export interface JenkinsInstanceConfig { baseUrl: string; username: string; apiKey: string; + crumbIssuer?: boolean; } /** @@ -70,6 +72,7 @@ export class JenkinsConfig { baseUrl: c.getString('baseUrl'), username: c.getString('username'), apiKey: c.getString('apiKey'), + crumbIssuer: c.getOptionalBoolean('crumbIssuer'), })) || []; // load unnamed default config @@ -81,6 +84,7 @@ export class JenkinsConfig { const baseUrl = jenkinsConfig.getOptionalString('baseUrl'); const username = jenkinsConfig.getOptionalString('username'); const apiKey = jenkinsConfig.getOptionalString('apiKey'); + const crumbIssuer = jenkinsConfig.getOptionalBoolean('crumbIssuer'); if (hasNamedDefault && (baseUrl || username || apiKey)) { throw new Error( @@ -98,12 +102,13 @@ export class JenkinsConfig { if (unnamedAllPresent) { const unnamedInstanceConfig = [ - { name: DEFAULT_JENKINS_NAME, baseUrl, username, apiKey }, + { name: DEFAULT_JENKINS_NAME, baseUrl, username, apiKey, crumbIssuer }, ] as { name: string; baseUrl: string; username: string; apiKey: string; + crumbIssuer: boolean; }[]; return new JenkinsConfig([ @@ -227,6 +232,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { Authorization: `Basic ${creds}`, }, jobFullName, + crumbIssuer: instanceConfig.crumbIssuer, }; } diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md index f33e565d89..a6926c1639 100644 --- a/plugins/jenkins/api-report.md +++ b/plugins/jenkins/api-report.md @@ -9,8 +9,8 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityName } from '@backstage/catalog-model'; -import { EntityRef } from '@backstage/catalog-model'; +import type { EntityName } from '@backstage/catalog-model'; +import type { EntityRef } from '@backstage/catalog-model'; import { IdentityApi } from '@backstage/core-plugin-api'; import { InfoCardVariants } from '@backstage/core-components'; import { RouteRef } from '@backstage/core-plugin-api'; diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 5822a262b5..862c2cdfb3 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -35,6 +35,7 @@ "@backstage/catalog-model": "^0.9.7", "@backstage/core-components": "^0.8.1", "@backstage/core-plugin-api": "^0.3.1", + "@backstage/errors": "^0.1.5", "@backstage/plugin-catalog-react": "^0.6.5", "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 4b23fb4292..492e7f5198 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -19,11 +19,11 @@ import { DiscoveryApi, IdentityApi, } from '@backstage/core-plugin-api'; -import { EntityName, EntityRef } from '@backstage/catalog-model'; +import type { EntityName, EntityRef } from '@backstage/catalog-model'; +import { ResponseError } from '@backstage/errors'; export const jenkinsApiRef = createApiRef({ id: 'plugin.jenkins.service2', - description: 'Used by the Jenkins plugin to make requests', }); export interface Build { @@ -140,7 +140,7 @@ export class JenkinsClient implements JenkinsApi { url.searchParams.append('branch', filter.branch); } - const idToken = await this.identityApi.getIdToken(); + const idToken = await this.getToken(); const response = await fetch(url.href, { method: 'GET', headers: { @@ -151,8 +151,8 @@ export class JenkinsClient implements JenkinsApi { return ( (await response.json()).projects?.map((p: Project) => ({ ...p, - onRestartClick: async () => { - await this.retry({ + onRestartClick: () => { + return this.retry({ entity, jobFullName: p.fullName, buildNumber: String(p.lastBuild.number), @@ -179,7 +179,7 @@ export class JenkinsClient implements JenkinsApi { jobFullName, )}/${encodeURIComponent(buildNumber)}`; - const idToken = await this.identityApi.getIdToken(); + const idToken = await this.getToken(); const response = await fetch(url, { method: 'GET', headers: { @@ -207,12 +207,21 @@ export class JenkinsClient implements JenkinsApi { jobFullName, )}/${encodeURIComponent(buildNumber)}:rebuild`; - const idToken = await this.identityApi.getIdToken(); - await fetch(url, { + const idToken = await this.getToken(); + const response = await fetch(url, { method: 'POST', headers: { ...(idToken && { Authorization: `Bearer ${idToken}` }), }, }); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + } + + private async getToken() { + const { token } = await this.identityApi.getCredentials(); + return token; } } diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index b1eaf2c24c..2c464491c2 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -13,17 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { Box, IconButton, Link, Typography, Tooltip } from '@material-ui/core'; +import { Link, Progress, Table, TableColumn } from '@backstage/core-components'; +import { alertApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; -import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; -import { Link as RouterLink } from 'react-router-dom'; -import { JenkinsRunStatus } from '../Status'; -import { useBuilds } from '../../../useBuilds'; -import { buildRouteRef } from '../../../../plugin'; -import { Table, TableColumn } from '@backstage/core-components'; +import { default as React, useState } from 'react'; import { Project } from '../../../../api/JenkinsApi'; -import { useRouteRef } from '@backstage/core-plugin-api'; +import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; +import { buildRouteRef } from '../../../../plugin'; +import { useBuilds } from '../../../useBuilds'; +import { JenkinsRunStatus } from '../Status'; const FailCount = ({ count }: { count: number }): JSX.Element | null => { if (count !== 0) { @@ -107,7 +106,6 @@ const generatedColumns: TableColumn[] = [ return ( ) => ( <>

- + {row.lastBuild?.source?.branchName}

@@ -154,7 +152,7 @@ const generatedColumns: TableColumn[] = [ <>

{row.lastBuild?.tests && ( - + {row.lastBuild?.tests.passed} / {row.lastBuild?.tests.total}{' '} passed ) => ( - - - - - - ), + render: (row: Partial) => { + const ActionWrapper = () => { + const [isLoadingRebuild, setIsLoadingRebuild] = useState(false); + const alertApi = useApi(alertApiRef); + + const onRebuild = async () => { + if (row.onRestartClick) { + setIsLoadingRebuild(true); + try { + await row.onRestartClick(); + alertApi.post({ + message: 'Jenkins re-build has successfully executed', + severity: 'success', + }); + } catch (e) { + alertApi.post({ + message: `Jenkins re-build has failed. Error: ${e.message}`, + severity: 'error', + }); + } finally { + setIsLoadingRebuild(false); + } + } + }; + + return ( + + <> + {isLoadingRebuild && } + {!isLoadingRebuild && ( + + + + )} + + + ); + }; + return ; + }, width: '10%', }, ]; diff --git a/plugins/jenkins/src/components/Cards/Cards.test.tsx b/plugins/jenkins/src/components/Cards/Cards.test.tsx index 0af91da179..97b920e666 100644 --- a/plugins/jenkins/src/components/Cards/Cards.test.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.test.tsx @@ -35,7 +35,7 @@ describe('', () => { const jenkinsApi: Partial = { getProjects: () => Promise.resolve([ - { lastBuild: { timestamp: 0, status: 'success' } }, + { lastBuild: { timestamp: 0, status: 'success', url: 'foo' } }, ] as Project[]), }; diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index f0b1ff2bca..cb84964124 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { LinearProgress, Link, makeStyles, Theme } from '@material-ui/core'; +import { LinearProgress, makeStyles, Theme } from '@material-ui/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; import { DateTime, Duration } from 'luxon'; import React from 'react'; @@ -22,6 +22,7 @@ import { ErrorType, useBuilds } from '../useBuilds'; import { InfoCard, InfoCardVariants, + Link, StructuredMetadataTable, WarningPanel, } from '@backstage/core-components'; @@ -66,7 +67,7 @@ const WidgetContent = ({ 'latest run': displayDate, duration: displayDuration, link: ( - + See more on Jenkins{' '} diff --git a/plugins/kafka/src/api/types.ts b/plugins/kafka/src/api/types.ts index 80a0866290..7804574761 100644 --- a/plugins/kafka/src/api/types.ts +++ b/plugins/kafka/src/api/types.ts @@ -18,8 +18,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const kafkaApiRef = createApiRef({ id: 'plugin.kafka.service', - description: - 'Used by the Kafka plugin to make requests to accompanying backend', }); export type ConsumerGroupOffsetsResponse = { diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 5132c8794d..8b95dd820c 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -22,8 +22,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const kubernetesApiRef = createApiRef({ id: 'plugin.kubernetes.service', - description: - 'Used by the Kubernetes plugin to make requests to accompanying backend', }); export interface KubernetesApi { diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index 3c5564e7cc..ffa0fd5961 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -26,8 +26,6 @@ export interface KubernetesAuthProvider { export const kubernetesAuthProvidersApiRef = createApiRef({ id: 'plugin.kubernetes-auth-providers.service', - description: - 'Used by the Kubernetes plugin to fetch KubernetesAuthProviders', }); export interface KubernetesAuthProvidersApi { diff --git a/plugins/lighthouse/src/api.ts b/plugins/lighthouse/src/api.ts index 7dbd48b960..b40b70356f 100644 --- a/plugins/lighthouse/src/api.ts +++ b/plugins/lighthouse/src/api.ts @@ -109,7 +109,6 @@ export type LighthouseApi = { export const lighthouseApiRef = createApiRef({ id: 'plugin.lighthouse.service', - description: 'Used by the Lighthouse plugin to make requests', }); export class LighthouseRestApi implements LighthouseApi { diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index 10f29f3fd5..541bc0e22d 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -22,10 +22,15 @@ import { CATEGORY_LABELS, buildSparklinesDataForItem, } from '../../utils'; -import { Link, generatePath } from 'react-router-dom'; +import { generatePath } from 'react-router-dom'; import AuditStatusIcon from '../AuditStatusIcon'; -import { Table, TableColumn, TrendLine } from '@backstage/core-components'; +import { + Link, + Table, + TableColumn, + TrendLine, +} from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; const columns: TableColumn[] = [ diff --git a/plugins/newrelic/src/api/index.ts b/plugins/newrelic/src/api/index.ts index 980a690d40..2734dd2b4f 100644 --- a/plugins/newrelic/src/api/index.ts +++ b/plugins/newrelic/src/api/index.ts @@ -55,7 +55,6 @@ export type NewRelicApplications = { export const newRelicApiRef = createApiRef({ id: 'plugin.newrelic.service', - description: 'Used by the NewRelic plugin to make requests', }); const DEFAULT_PROXY_PATH_BASE = '/newrelic'; diff --git a/plugins/org/package.json b/plugins/org/package.json index 8733c1c322..def4bac002 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -29,6 +29,7 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "pluralize": "^8.0.0", "qs": "^6.10.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index 1e2d514359..e8d7c44816 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -40,6 +40,7 @@ import { } from '@material-ui/core'; import qs from 'qs'; import React from 'react'; +import pluralize from 'pluralize'; import { useAsync } from 'react-use'; type EntityTypeProps = { @@ -96,7 +97,7 @@ const EntityCountTile = ({ {counter} - {name} + {pluralize(name, counter)} diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index 30709fe68b..298819ed09 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -22,7 +22,6 @@ import { import { Box, Grid, - Link, List, ListItem, ListItemIcon, @@ -34,7 +33,12 @@ import GroupIcon from '@material-ui/icons/Group'; import PersonIcon from '@material-ui/icons/Person'; import Alert from '@material-ui/lab/Alert'; import React from 'react'; -import { Avatar, InfoCard, InfoCardVariants } from '@backstage/core-components'; +import { + Avatar, + InfoCard, + InfoCardVariants, + Link, +} from '@backstage/core-components'; const CardTitle = ({ title }: { title?: string }) => title ? ( @@ -83,7 +87,7 @@ export const UserProfileCard = ({ - {profile.email} + {profile.email} )} diff --git a/plugins/pagerduty/src/api/client.ts b/plugins/pagerduty/src/api/client.ts index 65ff83e7ba..81d3792ecf 100644 --- a/plugins/pagerduty/src/api/client.ts +++ b/plugins/pagerduty/src/api/client.ts @@ -35,7 +35,6 @@ export class UnauthorizedError extends Error {} export const pagerDutyApiRef = createApiRef({ id: 'plugin.pagerduty.api', - description: 'Used to fetch data from PagerDuty API', }); export class PagerDutyClient implements PagerDutyApi { diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 96c1002e5e..6fbecdbc3d 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -34,7 +34,7 @@ "@backstage/backend-common": "^0.9.12", "@backstage/config": "^0.1.10", "@types/express": "^4.17.6", - "axios": "^0.21.1", + "axios": "^0.24.0", "camelcase-keys": "^6.2.2", "compression": "^1.7.4", "cors": "^2.8.5", diff --git a/plugins/rollbar/src/api/RollbarApi.ts b/plugins/rollbar/src/api/RollbarApi.ts index 4c28630db6..3da52338ec 100644 --- a/plugins/rollbar/src/api/RollbarApi.ts +++ b/plugins/rollbar/src/api/RollbarApi.ts @@ -23,8 +23,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const rollbarApiRef = createApiRef({ id: 'plugin.rollbar.service', - description: - 'Used by the Rollbar plugin to make requests to accompanying backend', }); export interface RollbarApi { diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts index 00a5690c0a..56c45fa015 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/index.ts @@ -96,6 +96,16 @@ export function createFetchRailsAction(options: { description: "Don't run Webpack install", type: 'boolean', }, + skipTest: { + title: 'skipTest', + description: 'Skip test files', + type: 'boolean', + }, + force: { + title: 'force', + description: 'Overwrite files that already exist', + type: 'boolean', + }, api: { title: 'api', description: 'Preconfigure smaller stack for API only apps', diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts index 99a113f4f7..cced9f7ac2 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.test.ts @@ -27,6 +27,8 @@ describe('railsArgumentResolver', () => { [{ api: true }, ['--api']], [{ skipBundle: true }, ['--skip-bundle']], [{ skipWebpackInstall: true }, ['--skip-webpack-install']], + [{ skipTest: true }, ['--skip-test']], + [{ force: true }, ['--force']], [{ webpacker: 'vue' }, ['--webpack', 'vue']], [{ database: 'postgresql' }, ['--database', 'postgresql']], [{ railsVersion: 'dev' }, ['--dev']], diff --git a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts index 60251a702d..4006ead630 100644 --- a/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts +++ b/plugins/scaffolder-backend-module-rails/src/actions/fetch/rails/railsArgumentResolver.ts @@ -51,6 +51,8 @@ export type RailsRunOptions = { railsVersion?: RailsVersion; skipBundle?: boolean; skipWebpackInstall?: boolean; + skipTest?: boolean; + force?: boolean; }; export const railsArgumentResolver = ( @@ -76,6 +78,14 @@ export const railsArgumentResolver = ( argumentsToRun.push('--skip-webpack-install'); } + if (options?.skipTest) { + argumentsToRun.push('--skip-test'); + } + + if (options?.force) { + argumentsToRun.push('--force'); + } + if ( options?.webpacker && Object.values(Webpacker).includes(options?.webpacker as Webpacker) diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 34e16708ea..d7696fadde 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -30,7 +30,6 @@ import { ListActionsResponse, ScaffolderTask, Status } from './types'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', - description: 'Used to make requests towards the scaffolder backend', }); type TemplateParameterSchema = { diff --git a/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx b/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx index 9b66a0a9b6..2ac0def547 100644 --- a/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx +++ b/plugins/scaffolder/src/components/TemplateList/TemplateList.tsx @@ -24,11 +24,12 @@ import { Content, ContentHeader, ItemCardGrid, + Link, Progress, WarningPanel, } from '@backstage/core-components'; import { useEntityListProvider } from '@backstage/plugin-catalog-react'; -import { Link, Typography } from '@material-ui/core'; +import { Typography } from '@material-ui/core'; import { TemplateCard } from '../TemplateCard'; export type TemplateListProps = { @@ -73,7 +74,7 @@ export const TemplateList = ({ {!error && !loading && !entities.length && ( No templates found that match your filter. Learn more about{' '} - + adding templates . diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index 4b3cb30264..77a1be1eb4 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -18,6 +18,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { IndexBuilder, LunrSearchEngine, + SearchEngine, } from '@backstage/plugin-search-backend-node'; import express from 'express'; import request from 'supertest'; @@ -26,11 +27,13 @@ import { createRouter } from './router'; describe('createRouter', () => { let app: express.Express; + let mockSearchEngine: jest.Mocked; beforeAll(async () => { const logger = getVoidLogger(); const searchEngine = new LunrSearchEngine({ logger }); const indexBuilder = new IndexBuilder({ logger, searchEngine }); + const router = await createRouter({ engine: indexBuilder.getSearchEngine(), logger, @@ -49,5 +52,63 @@ describe('createRouter', () => { expect(response.status).toEqual(200); expect(response.body).toMatchObject({ results: [] }); }); + + describe('search result filtering', () => { + beforeAll(async () => { + const logger = getVoidLogger(); + mockSearchEngine = { + index: jest.fn(), + setTranslator: jest.fn(), + query: jest.fn(), + }; + const indexBuilder = new IndexBuilder({ + logger, + searchEngine: mockSearchEngine, + }); + + const router = await createRouter({ + engine: indexBuilder.getSearchEngine(), + logger, + }); + app = express().use(router); + }); + + describe('where the search result set includes unsafe results', () => { + const safeResult = { + type: 'software-catalog', + document: { + text: 'safe', + title: 'safe-location', + // eslint-disable-next-line no-script-url + location: '/catalog/default/component/safe', + }, + }; + beforeEach(() => { + mockSearchEngine.query.mockResolvedValue({ + results: [ + { + type: 'software-catalog', + document: { + text: 'unsafe', + title: 'unsafe-location', + // eslint-disable-next-line no-script-url + location: 'javascript:alert("unsafe")', + }, + }, + safeResult, + ], + nextPageCursor: '', + previousPageCursor: '', + }); + }); + + it('removes the unsafe results', async () => { + const response = await request(app).get('/query'); + + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ results: [safeResult] }); + }); + }); + }); }); }); diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 5bd99988a7..aae1914fc1 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -25,10 +25,28 @@ export type RouterOptions = { logger: Logger; }; +const allowedLocationProtocols = ['http:', 'https:']; + export async function createRouter( options: RouterOptions, ): Promise { const { engine, logger } = options; + + const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({ + ...resultSet, + results: results.filter(result => { + const protocol = new URL(result.document.location, 'https://example.com') + .protocol; + const isAllowed = allowedLocationProtocols.includes(protocol); + if (!isAllowed) { + logger.info( + `Rejected search result for "${result.document.title}" as location protocol "${protocol}" is unsafe`, + ); + } + return isAllowed; + }), + }); + const router = Router(); router.get( '/query', @@ -46,8 +64,8 @@ export async function createRouter( ); try { - const results = await engine?.query(req.query); - res.send(results); + const resultSet = await engine?.query(req.query); + res.send(filterResultSet(resultSet)); } catch (err) { throw new Error( `There was a problem performing the search query. ${err}`, diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 3db26579e3..13a9b15ea1 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -8,6 +8,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { AsyncState } from 'react-use/lib/useAsync'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { IconComponent } from '@backstage/core-plugin-api'; import { IndexableDocument } from '@backstage/search-common'; import { JsonObject } from '@backstage/types'; import { default as React_2 } from 'react'; @@ -213,12 +214,28 @@ export const SearchType: ({ // Warning: (ae-missing-release-tag) "SidebarSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SidebarSearch: () => JSX.Element; +export const SidebarSearch: (props: SidebarSearchProps) => JSX.Element; // Warning: (ae-missing-release-tag) "SidebarSearchModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SidebarSearchModal: () => JSX.Element; +export const SidebarSearchModal: ( + props: SidebarSearchModalProps, +) => JSX.Element; + +// Warning: (ae-missing-release-tag) "SidebarSearchModalProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SidebarSearchModalProps = { + icon?: IconComponent; +}; + +// Warning: (ae-missing-release-tag) "SidebarSearchProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type SidebarSearchProps = { + icon?: IconComponent; +}; // Warning: (ae-forgotten-export) The symbol "SearchContextValue" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "useSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index fa6d9ce908..4ce638fcca 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -25,7 +25,6 @@ import qs from 'qs'; export const searchApiRef = createApiRef({ id: 'plugin.search.queryservice', - description: 'Used to make requests against the search API', }); export interface SearchApi { diff --git a/plugins/search/src/components/SearchResult/SearchResult.stories.tsx b/plugins/search/src/components/SearchResult/SearchResult.stories.tsx index bf83b8b1ad..aebaecb8b4 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.stories.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.stories.tsx @@ -15,9 +15,10 @@ */ import React from 'react'; -import { List, Link, ListItem } from '@material-ui/core'; +import { List, ListItem } from '@material-ui/core'; import { SearchResult, SearchContext, DefaultResultListItem } from '../index'; import { MemoryRouter } from 'react-router'; +import { Link } from '@backstage/core-components'; export default { title: 'Plugins/Search/SearchResult', @@ -79,7 +80,7 @@ export const Default = () => { default: return ( - + {document.title} - {document.text} diff --git a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx index 883829d44e..9f6be2333f 100644 --- a/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx +++ b/plugins/search/src/components/SidebarSearch/SidebarSearch.tsx @@ -19,9 +19,13 @@ import { useNavigate } from 'react-router-dom'; import { rootRouteRef } from '../../plugin'; import { SidebarSearchField } from '@backstage/core-components'; -import { useRouteRef } from '@backstage/core-plugin-api'; +import { useRouteRef, IconComponent } from '@backstage/core-plugin-api'; -export const SidebarSearch = () => { +export type SidebarSearchProps = { + icon?: IconComponent; +}; + +export const SidebarSearch = (props: SidebarSearchProps) => { const searchRoute = useRouteRef(rootRouteRef); const navigate = useNavigate(); const handleSearch = useCallback( @@ -33,5 +37,11 @@ export const SidebarSearch = () => { [navigate, searchRoute], ); - return ; + return ( + + ); }; diff --git a/plugins/search/src/components/SidebarSearch/index.ts b/plugins/search/src/components/SidebarSearch/index.ts index 437234c5b4..1340bfaa9a 100644 --- a/plugins/search/src/components/SidebarSearch/index.ts +++ b/plugins/search/src/components/SidebarSearch/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { SidebarSearch } from './SidebarSearch'; +export type { SidebarSearchProps } from './SidebarSearch'; diff --git a/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx b/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx index ae38526c9d..b8f20de67d 100644 --- a/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx +++ b/plugins/search/src/components/SidebarSearchModal/SidebarSearchModal.tsx @@ -16,17 +16,23 @@ import React from 'react'; import SearchIcon from '@material-ui/icons/Search'; import { SidebarItem } from '@backstage/core-components'; +import { IconComponent } from '@backstage/core-plugin-api'; import { SearchModal } from '../SearchModal'; import { useSearch } from '../SearchContext'; -export const SidebarSearchModal = () => { +export type SidebarSearchModalProps = { + icon?: IconComponent; +}; + +export const SidebarSearchModal = (props: SidebarSearchModalProps) => { const { open, toggleModal } = useSearch(); + const Icon = props.icon ? props.icon : SearchIcon; return ( <> diff --git a/plugins/search/src/components/SidebarSearchModal/index.ts b/plugins/search/src/components/SidebarSearchModal/index.ts index 849180878d..f667f20da2 100644 --- a/plugins/search/src/components/SidebarSearchModal/index.ts +++ b/plugins/search/src/components/SidebarSearchModal/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { SidebarSearchModal } from './SidebarSearchModal'; +export type { SidebarSearchModalProps } from './SidebarSearchModal'; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index f5c58450fc..bfc9c52b74 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -36,7 +36,11 @@ export { SidebarSearch, useSearch, } from './components'; -export type { SearchModalProps } from './components'; +export type { + SearchModalProps, + SidebarSearchModalProps, + SidebarSearchProps, +} from './components'; export type { FiltersState } from './components'; export { DefaultResultListItem, diff --git a/plugins/sentry/src/api/sentry-api.ts b/plugins/sentry/src/api/sentry-api.ts index d2177cde2a..0c5b709508 100644 --- a/plugins/sentry/src/api/sentry-api.ts +++ b/plugins/sentry/src/api/sentry-api.ts @@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const sentryApiRef = createApiRef({ id: 'plugin.sentry.service', - description: 'Used by the Sentry plugin to make requests', }); export interface SentryApi { diff --git a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx index d2da63f01f..015cf96ed1 100644 --- a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx +++ b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx @@ -16,9 +16,10 @@ import React from 'react'; import { SentryIssue } from '../../api'; -import { Link, Typography } from '@material-ui/core'; +import { Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; +import { Link } from '@backstage/core-components'; function stripText(text: string, maxLength: number) { return text.length > maxLength ? `${text.substr(0, maxLength)}...` : text; @@ -53,7 +54,7 @@ export const ErrorCell = ({ sentryIssue }: { sentryIssue: SentryIssue }) => { return (

- + {stripText(issueType, 28)} diff --git a/plugins/shortcuts/src/api/ShortcutApi.ts b/plugins/shortcuts/src/api/ShortcutApi.ts index dcecc9945b..893a2fd74b 100644 --- a/plugins/shortcuts/src/api/ShortcutApi.ts +++ b/plugins/shortcuts/src/api/ShortcutApi.ts @@ -20,7 +20,6 @@ import { Observable } from '@backstage/types'; export const shortcutsApiRef = createApiRef({ id: 'plugin.shortcuts.api', - description: 'API to handle shortcuts in a Backstage Sidebar', }); export interface ShortcutApi { diff --git a/plugins/sonarqube/src/api/SonarQubeApi.ts b/plugins/sonarqube/src/api/SonarQubeApi.ts index 5bafd87ca8..3b39b30eb0 100644 --- a/plugins/sonarqube/src/api/SonarQubeApi.ts +++ b/plugins/sonarqube/src/api/SonarQubeApi.ts @@ -35,7 +35,6 @@ export interface FindingSummary { export const sonarQubeApiRef = createApiRef({ id: 'plugin.sonarqube.service', - description: 'Used by the SonarQube plugin to make requests', }); export type SonarQubeApi = { diff --git a/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx index e5bcfe4f86..547c3008c0 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/RatingCard.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ -import { Grid, Typography, Link } from '@material-ui/core'; +import { Link } from '@backstage/core-components'; +import { Grid, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import React, { ReactNode } from 'react'; @@ -62,7 +63,7 @@ export const RatingCard = ({ const classes = useStyles(); return ( - + diff --git a/plugins/splunk-on-call/src/api/client.ts b/plugins/splunk-on-call/src/api/client.ts index a0256fcf07..437c6365ed 100644 --- a/plugins/splunk-on-call/src/api/client.ts +++ b/plugins/splunk-on-call/src/api/client.ts @@ -41,7 +41,6 @@ export class UnauthorizedError extends Error {} export const splunkOnCallApiRef = createApiRef({ id: 'plugin.splunk-on-call.api', - description: 'Used to fetch data from Splunk On-Call API', }); export class SplunkOnCallClient implements SplunkOnCallApi { diff --git a/plugins/tech-insights/src/api/TechInsightsApi.ts b/plugins/tech-insights/src/api/TechInsightsApi.ts index 2c55930f0a..d3ff9ac2ff 100644 --- a/plugins/tech-insights/src/api/TechInsightsApi.ts +++ b/plugins/tech-insights/src/api/TechInsightsApi.ts @@ -22,7 +22,6 @@ import { EntityName } from '@backstage/catalog-model'; export const techInsightsApiRef = createApiRef({ id: 'plugin.techinsights.service', - description: 'Used by the tech insights plugin to make requests', }); export interface TechInsightsApi { diff --git a/plugins/tech-radar/README.md b/plugins/tech-radar/README.md index 469a12e8e4..beaf2a0724 100644 --- a/plugins/tech-radar/README.md +++ b/plugins/tech-radar/README.md @@ -64,6 +64,10 @@ export interface TechRadarPageProps { } ``` +### Radar properties + +When defining the radar entries you can see the available properties on the file [api](./src/api.ts) + ## Frequently Asked Questions ### Who created the Tech Radar? diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index db1be29f26..241f3dce85 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -21,7 +21,6 @@ import { ApiRef, createApiRef } from '@backstage/core-plugin-api'; export const techRadarApiRef: ApiRef = createApiRef( { id: 'plugin.techradar.service', - description: 'Used to populate data in the TechRadar plugin', }, ); diff --git a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx index 320e85f8bf..302e77c1ac 100644 --- a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx +++ b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx @@ -19,7 +19,8 @@ import Dialog from '@material-ui/core/Dialog'; import DialogTitle from '@material-ui/core/DialogTitle'; import { Button, DialogActions, DialogContent } from '@material-ui/core'; import LinkIcon from '@material-ui/icons/Link'; -import { MarkdownContent } from '@backstage/core-components'; +import { Link, MarkdownContent } from '@backstage/core-components'; +import { isValidUrl } from '../../utils/components'; export type Props = { open: boolean; @@ -32,13 +33,6 @@ export type Props = { const RadarDescription = (props: Props): JSX.Element => { const { open, onClose, title, description, url } = props; - const handleClick = () => { - onClose(); - if (url) { - window.location.href = url; - } - }; - return ( @@ -47,13 +41,14 @@ const RadarDescription = (props: Props): JSX.Element => { - {url && ( + {isValidUrl(url) && ( diff --git a/plugins/tech-radar/src/sample.ts b/plugins/tech-radar/src/sample.ts index 2b674f35c8..fee5f8cdbf 100644 --- a/plugins/tech-radar/src/sample.ts +++ b/plugins/tech-radar/src/sample.ts @@ -161,9 +161,10 @@ entries.push({ { ringId: 'use', date: new Date('2020-08-06'), + description: 'long description', }, ], - url: '#', + url: 'https://github.com', key: 'github-actions', id: 'github-actions', title: 'GitHub Actions', diff --git a/plugins/tech-radar/src/utils/components.tsx b/plugins/tech-radar/src/utils/components.tsx index bbfe046b69..53d64065f6 100644 --- a/plugins/tech-radar/src/utils/components.tsx +++ b/plugins/tech-radar/src/utils/components.tsx @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { Link } from '@backstage/core-components'; import React from 'react'; type WithLinkProps = { @@ -21,15 +23,19 @@ type WithLinkProps = { children: React.ReactNode; }; +export function isValidUrl(url: string | undefined): url is string { + return Boolean(url && url !== '#' && url.length > 0); +} + export const WithLink = ({ url, className, children, }: WithLinkProps): JSX.Element => - url ? ( - + isValidUrl(url) ? ( + {children} - + ) : ( <>{children} ); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index a23d9fc7f0..cd130f083d 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -20,12 +20,10 @@ import { createApiRef } from '@backstage/core-plugin-api'; export const techdocsStorageApiRef = createApiRef({ id: 'plugin.techdocs.storageservice', - description: 'Used to make requests towards the techdocs storage', }); export const techdocsApiRef = createApiRef({ id: 'plugin.techdocs.service', - description: 'Used to make requests towards techdocs API', }); export type SyncResult = 'cached' | 'updated'; diff --git a/plugins/todo/src/api/types.ts b/plugins/todo/src/api/types.ts index f1f647f6e3..51b846a30f 100644 --- a/plugins/todo/src/api/types.ts +++ b/plugins/todo/src/api/types.ts @@ -107,5 +107,4 @@ export interface TodoApi { */ export const todoApiRef = createApiRef({ id: 'plugin.todo.api', - description: 'Lists TODOs', }); diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index a801f2e807..0d8f132f37 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -119,5 +119,6 @@ export const UserSettingsThemeToggle: () => JSX.Element; export const useUserProfile: () => { profile: ProfileInfo; displayName: string; + loading: boolean; }; ``` diff --git a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx index 83d9e23f83..09b27cb559 100644 --- a/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsThemeToggle.test.tsx @@ -21,6 +21,8 @@ import { wrapInTestApp, } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core/styles'; +import CssBaseline from '@material-ui/core/CssBaseline'; import { fireEvent } from '@testing-library/react'; import React from 'react'; import { UserSettingsThemeToggle } from './UserSettingsThemeToggle'; @@ -30,7 +32,11 @@ const mockTheme: AppTheme = { id: 'light-theme', title: 'Mock Theme', variant: 'light', - theme: lightTheme, + Provider: ({ children }) => ( + + {children} + + ), }; const apiRegistry = TestApiRegistry.from([ diff --git a/plugins/user-settings/src/components/useUserProfileInfo.ts b/plugins/user-settings/src/components/useUserProfileInfo.ts index 336ea34ee4..6f9b5c154e 100644 --- a/plugins/user-settings/src/components/useUserProfileInfo.ts +++ b/plugins/user-settings/src/components/useUserProfileInfo.ts @@ -14,13 +14,46 @@ * limitations under the License. */ -import { useApi, identityApiRef } from '@backstage/core-plugin-api'; +import { + alertApiRef, + identityApiRef, + ProfileInfo, + useApi, +} from '@backstage/core-plugin-api'; +import { useEffect } from 'react'; +import { useAsync } from 'react-use'; export const useUserProfile = () => { const identityApi = useApi(identityApiRef); - const userId = identityApi.getUserId(); - const profile = identityApi.getProfile(); - const displayName = profile.displayName ?? userId; + const alertApi = useApi(alertApiRef); - return { profile, displayName }; + const { value, loading, error } = useAsync(async () => { + return { + profile: await identityApi.getProfileInfo(), + identity: await identityApi.getBackstageIdentity(), + }; + }, []); + + useEffect(() => { + if (error) { + alertApi.post({ + message: `Failed to load user identity: ${error}`, + severity: 'error', + }); + } + }, [error, alertApi]); + + if (loading || error) { + return { + profile: {} as ProfileInfo, + displayName: '', + loading, + }; + } + + return { + profile: value!.profile, + displayName: value!.profile.displayName ?? value!.identity.userEntityRef, + loading, + }; }; diff --git a/plugins/xcmetrics/src/api/types.ts b/plugins/xcmetrics/src/api/types.ts index 6c7d133259..b4656e9478 100644 --- a/plugins/xcmetrics/src/api/types.ts +++ b/plugins/xcmetrics/src/api/types.ts @@ -193,5 +193,4 @@ export interface XcmetricsApi { export const xcmetricsApiRef = createApiRef({ id: 'plugin.xcmetrics.api', - description: 'Used by the XCMetrics plugin to make requests', }); diff --git a/yarn.lock b/yarn.lock index cca08863df..422531d3a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9996,6 +9996,13 @@ axios@^0.21.1, axios@^0.21.4: dependencies: follow-redirects "^1.14.0" +axios@^0.24.0: + version "0.24.0" + resolved "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6" + integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA== + dependencies: + follow-redirects "^1.14.4" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -15139,9 +15146,9 @@ find-my-way@^2.2.2: semver-store "^0.3.0" find-process@^1.4.5: - version "1.4.5" - resolved "https://registry.npmjs.org/find-process/-/find-process-1.4.5.tgz#6a0e4c87a32ca927c05cbed7b9078d62ffaac1a4" - integrity sha512-v11rJYYISUWn+s8qZzgGnBvlzRKf3bOtlGFM8H0kw56lGQtOmLuLCzuclA5kehA2j7S5sioOWdI4woT3jDavAw== + version "1.4.7" + resolved "https://registry.npmjs.org/find-process/-/find-process-1.4.7.tgz#8c76962259216c381ef1099371465b5b439ea121" + integrity sha512-/U4CYp1214Xrp3u3Fqr9yNynUrr5Le4y0SsJh2lMDDSbpwYSz3M2SMWQC+wqcx79cN8PQtHQIL8KnuY9M66fdg== dependencies: chalk "^4.0.0" commander "^5.1.0" @@ -15241,15 +15248,10 @@ fn.name@1.x.x: resolved "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.0.0: - version "1.13.0" - resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" - integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== - -follow-redirects@^1.14.0: - version "1.14.4" - resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379" - integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g== +follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.14.4: + version "1.14.6" + resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd" + integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A== for-in@^0.1.3: version "0.1.8" @@ -15877,19 +15879,7 @@ glob@^6.0.1: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7: - version "7.1.7" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" - integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^7.2.0: +glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7, glob@^7.2.0: version "7.2.0" resolved "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== @@ -20251,6 +20241,14 @@ marked@^2.1.1: resolved "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz#bd017cef6431724fd4b27e0657f5ceb14bff3753" integrity sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA== +material-ui-search-bar@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/material-ui-search-bar/-/material-ui-search-bar-1.0.0.tgz#2652dd5bdc4cb043cffb7144d9c296c120702e62" + integrity sha512-lCNuzMLPBVukVAkcnYKLXHneozsuKZREZNOcc8z9S9scXHqxJzhC9hOS3OC3/YJ+NJEB5lZB9zg1gryBaXEu8w== + dependencies: + classnames "^2.2.5" + prop-types "^15.5.8" + math-expression-evaluator@^1.2.14: version "1.2.22" resolved "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz#c14dcb3d8b4d150e5dcea9c68c8dad80309b0d5e" @@ -27229,9 +27227,9 @@ subscriptions-transport-ws@^0.9.18, subscriptions-transport-ws@^0.9.19: ws "^5.2.0 || ^6.0.0 || ^7.0.0" sucrase@^3.18.0, sucrase@^3.20.2: - version "3.20.2" - resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.20.2.tgz#28a28dc58a55be0d6916d5c9b2440d203e9ffe62" - integrity sha512-EdJ5M6VEvToIZwIWiZ71cxe4CklDRG8PdSjUSst+BZCUGlaEhnrdQo/LOXsuq3MjWRbfepg1XTffClK0Tmo0HQ== + version "3.20.3" + resolved "https://registry.npmjs.org/sucrase/-/sucrase-3.20.3.tgz#424f1e75b77f955724b06060f1ae708f5f0935cf" + integrity sha512-azqwq0/Bs6RzLAdb4dXxsCgMtAaD2hzmUr4UhSfsxO46JFPAwMnnb441B/qsudZiS6Ylea3JXZe3Q497lsgXzQ== dependencies: commander "^4.0.0" glob "7.1.6"