diff --git a/.changeset/blue-donkeys-exercise.md b/.changeset/blue-donkeys-exercise.md new file mode 100644 index 0000000000..4f714e3c97 --- /dev/null +++ b/.changeset/blue-donkeys-exercise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Add client side paging for catalog table diff --git a/.changeset/cost-insights-sparkle.md b/.changeset/cost-insights-sparkle.md new file mode 100644 index 0000000000..c332502476 --- /dev/null +++ b/.changeset/cost-insights-sparkle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +Added getLastCompleteBillingDate to the CostInsightsApi to reason about completeness of billing data diff --git a/.changeset/dull-icons-share.md b/.changeset/dull-icons-share.md new file mode 100644 index 0000000000..aa4c01e422 --- /dev/null +++ b/.changeset/dull-icons-share.md @@ -0,0 +1,21 @@ +--- +'@backstage/backend-common': minor +'@backstage/cli': minor +'@backstage/config-loader': minor +'example-backend': patch +'@backstage/create-app': patch +--- + +**BREAKING CHANGE** + +The existing loading of additional config files like `app-config.development.yaml` using APP_ENV or NODE_ENV has been removed. +Instead, the CLI and backend process now accept one or more `--config` flags to load config files. + +Without passing any flags, `app-config.yaml` and, if it exists, `app-config.local.yaml` will be loaded. +If passing any `--config ` flags, only those files will be loaded, **NOT** the default `app-config.yaml` one. + +The old behaviour of for example `APP_ENV=development` can be replicated using the following flags: + +```bash +--config ../../app-config.yaml --config ../../app-config.development.yaml +``` diff --git a/.changeset/eighty-stingrays-type.md b/.changeset/eighty-stingrays-type.md new file mode 100644 index 0000000000..731c401b77 --- /dev/null +++ b/.changeset/eighty-stingrays-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Add forwardRef to the SidebarItem diff --git a/.changeset/flat-yaks-march.md b/.changeset/flat-yaks-march.md new file mode 100644 index 0000000000..6b26206eb8 --- /dev/null +++ b/.changeset/flat-yaks-march.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +fix the accordion details design when job stage fail diff --git a/.changeset/great-apples-flash.md b/.changeset/great-apples-flash.md new file mode 100644 index 0000000000..78ad89dee0 --- /dev/null +++ b/.changeset/great-apples-flash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fix CodeOwnersProcessor to handle non team users diff --git a/.changeset/hungry-parrots-work.md b/.changeset/hungry-parrots-work.md new file mode 100644 index 0000000000..a18218b542 --- /dev/null +++ b/.changeset/hungry-parrots-work.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +rename stories folder top Chip diff --git a/.changeset/little-wasps-pull.md b/.changeset/little-wasps-pull.md new file mode 100644 index 0000000000..732c7506b0 --- /dev/null +++ b/.changeset/little-wasps-pull.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Remove "in default" in component name diff --git a/.changeset/pink-spies-rule.md b/.changeset/pink-spies-rule.md new file mode 100644 index 0000000000..71653a1841 --- /dev/null +++ b/.changeset/pink-spies-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Replacing the hard coded `baseApiUrl` by reading the value from configuration to enable private GitHub setup for TechDocs. diff --git a/.changeset/polite-moose-cough.md b/.changeset/polite-moose-cough.md new file mode 100644 index 0000000000..b66227bb24 --- /dev/null +++ b/.changeset/polite-moose-cough.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +update ItemCard component and it's story diff --git a/.changeset/selfish-mugs-itch.md b/.changeset/selfish-mugs-itch.md new file mode 100644 index 0000000000..39ed236352 --- /dev/null +++ b/.changeset/selfish-mugs-itch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +Remove product filters from query parameters diff --git a/.changeset/spicy-moles-yell.md b/.changeset/spicy-moles-yell.md new file mode 100644 index 0000000000..72b648b30b --- /dev/null +++ b/.changeset/spicy-moles-yell.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +make ErrorPage responsive + fix the test case diff --git a/.changeset/tough-weeks-pull.md b/.changeset/tough-weeks-pull.md new file mode 100644 index 0000000000..e79355de90 --- /dev/null +++ b/.changeset/tough-weeks-pull.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +fix the responsive of page story diff --git a/.changeset/weak-ducks-tan.md b/.changeset/weak-ducks-tan.md new file mode 100644 index 0000000000..64b1c3f117 --- /dev/null +++ b/.changeset/weak-ducks-tan.md @@ -0,0 +1,8 @@ +--- +'@backstage/backend-common': minor +'example-backend': patch +'@backstage/cli': patch +'@backstage/create-app': patch +--- + +Change loadBackendConfig to return the config directly diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3df1031034..d161a8e1a9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,3 +14,4 @@ /packages/techdocs-container @spotify/techdocs-core /.github/workflows/techdocs.yml @spotify/techdocs-core /.github/workflows/techdocs-pypi.yml @spotify/techdocs-core +/.changeset/cost-insights-* @spotify/silver-lining diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bcce2966f..f445c189f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ If you encounter issues while upgrading to a newer version, don't hesitate to re ## Next Release +### @backstage/cli + +- Configuration files are no longer selected through `APP_ENV` or `NODE_ENV`. The default configuration files are `app-config.yaml` and, fix it exists, `app-config.local.yaml` in the repo root. To load a different set of files, use `--config ` arguments. + +### @backstage/backend-common + +- Configuration files are no longer selected through `APP_ENV` or `NODE_ENV`. The default configuration files are `app-config.yaml` and, fix it exists, `app-config.local.yaml` in the repo root. To load a different set of files, use `--config ` arguments. + +## v0.1.1-alpha.25 + > Collect changes for the next release below ### @backstage/cli diff --git a/app-config.development.yaml b/app-config.development.yaml deleted file mode 100644 index 817847c6d6..0000000000 --- a/app-config.development.yaml +++ /dev/null @@ -1,13 +0,0 @@ -app: - baseUrl: http://localhost:3000 - -backend: - baseUrl: http://localhost:7000 - listen: - port: 7000 - cors: - origin: http://localhost:3000 - methods: [GET, POST, PUT, DELETE] - credentials: true - csp: - connect-src: ["'self'", 'http:', 'https:'] diff --git a/app-config.yaml b/app-config.yaml index 715d564feb..05bf39b5b3 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -1,6 +1,6 @@ app: title: Backstage Example App - baseUrl: http://localhost:7000 + baseUrl: http://localhost:3000 googleAnalyticsTrackingId: # UA-000000-0 backend: @@ -10,8 +10,12 @@ backend: database: client: sqlite3 connection: ':memory:' + cors: + origin: http://localhost:3000 + methods: [GET, POST, PUT, DELETE] + credentials: true csp: - connect-src: ["'self'", 'https:'] + connect-src: ["'self'", 'http:', 'https:'] # See README.md in the proxy-backend plugin for information on the configuration format proxy: @@ -146,6 +150,9 @@ catalog: # Backstage example templates - type: url target: https://github.com/spotify/backstage/blob/master/plugins/scaffolder-backend/sample-templates/all-templates.yaml + # Backstage example groups and users + - type: url + target: https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/acme-corp.yaml scaffolder: github: diff --git a/contrib/kubernetes/plain_single_backend_deplyoment/deployment.yaml b/contrib/kubernetes/plain_single_backend_deplyoment/deployment.yaml index 87dee94895..a20c47229d 100644 --- a/contrib/kubernetes/plain_single_backend_deplyoment/deployment.yaml +++ b/contrib/kubernetes/plain_single_backend_deplyoment/deployment.yaml @@ -28,6 +28,9 @@ spec: image: example-backend:latest imagePullPolicy: Never + command: [node, packages/backend] + args: [--config, app-config.yaml, --config, k8s-config.yaml] + env: # We set this to development to make the backend start with incomplete configuration. In a production # deployment you will want to make sure that you have a full configuration, and remove any plugins that @@ -35,10 +38,6 @@ spec: - name: NODE_ENV value: development - # This makes us load in `app-config.production.yaml` if there is one. - - name: APP_ENV - value: production - # This makes it possible for the app to reach the backend when serving through `kubectl proxy` # If you expose the service using for example an ingress controller, you should # switch this out or remove it. @@ -54,8 +53,8 @@ spec: volumeMounts: - name: config-volume - mountPath: /usr/src/app/app-config.local.yaml - subPath: app-config.local.yaml + mountPath: /usr/src/app/k8s-config.yaml + subPath: k8s-config.yaml resources: limits: @@ -77,7 +76,7 @@ spec: name: backstage-config items: - key: app-config - path: app-config.local.yaml + path: k8s-config.yaml --- apiVersion: v1 kind: ConfigMap diff --git a/docs/auth/auth-backend-classes.md b/docs/auth/auth-backend-classes.md index 9dc677b439..442553d4b8 100644 --- a/docs/auth/auth-backend-classes.md +++ b/docs/auth/auth-backend-classes.md @@ -20,7 +20,7 @@ Each of these methods is hosted at an endpoint `/auth/[provider]/method`, where ``` For more information on how these methods are used and for which purpose, refer -to the documentation [here](oauth.md). +to the [OAuth documentation](oauth.md). For details on the parameters, input and output conditions for each method, refer to the type documentation under @@ -38,7 +38,7 @@ Currently OAuth is assumed to be the de facto authentication mechanism for Backstage based applications. Backstage comes with a "batteries-included" set of supported commonly used OAuth -providers: Okta, Github, Google, Gitlab, and a generic OAuth2 provider. +providers: Okta, GitHub, Google, GitLab, and a generic OAuth2 provider. All of these use the authorization flow of OAuth2 to implement authentication. diff --git a/docs/conf/index.md b/docs/conf/index.md index e1dfc30582..2c3ee06ba6 100644 --- a/docs/conf/index.md +++ b/docs/conf/index.md @@ -15,11 +15,11 @@ allowing for customization. ## Supplying Configuration -Configuration is stored in `app-config.yaml` files, with support for suffixes -such as `app-config.production.yaml` to override values for specific -environments. The configuration files themselves contain plain YAML, but with -support for loading in secrets from various sources using for example `$env` and -`$file` keys. +Configuration is stored in YAML files where the defaults are `app-config.yaml` +and `app-config.local.yaml` for local overrides. Other sets of files can by +loaded by passing `--config ` flags. The configuration files themselves +contain plain YAML, but with support for loading in secrets from various sources +using for example `$env` and `$file` keys. It is also possible to supply configuration through environment variables, for example `APP_CONFIG_app_baseUrl=https://staging.example.com`. However these diff --git a/docs/conf/writing.md b/docs/conf/writing.md index bb4d0beb75..06d4b52f3f 100644 --- a/docs/conf/writing.md +++ b/docs/conf/writing.md @@ -55,20 +55,28 @@ picked up by the serve tasks of `@backstage/cli` for local development, and are injected by the entrypoint of the nginx container serving the frontend in a production build. -## File Resolution +## Configuration Files It is possible to have multiple configuration files, both to support different environments, but also to define configuration that is local to specific -packages. +packages. The configuration files to load are selected using a `--config ` +flag, and it is possible to load any number of files. Paths are relative to the +working directory of the executed process, for example `package/backend`. This +means that to select a config file in the repo root when running the backend, +you would use `--config ../../my-config.yaml`. -All `app-config.yaml` files inside the monorepo root and package root are -considered, as are files with additional `local` and environment affixes such as -`development`, for example `app-config.local.yaml`, -`app-config.production.yaml`, and `app-config.development.local.yaml`. Which -environment config files are loaded is determined by the `APP_ENV` environment -variable, or `NODE_ENV` if it is not set. Local configuration files are always -loaded, but are meant for local development overrides and should typically be -`.gitignore`'d. +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 +it a good place to add config overrides and secrets for local development. + +Note that if any config flags are provided, the default `app-config.yaml` files +are NOT loaded. To include them you need to explicitly include them with a flag, +for example: + +``` +yarn start --config ../../app-config.yaml --config ../../app-config.staging.yaml +``` All loaded configuration files are merged together using the following rules: @@ -84,10 +92,10 @@ order: - Configuration from the `APP_CONFIG_` environment variables has the highest priority, followed by files. -- Files inside package directories have higher priority than those in the root - directory. -- Files with environment affixes have higher priority than ones without. -- Files with the `local` affix have higher priority than ones without. +- Files loaded with config flags are ordered by priority, where the last flag + has the highest priority. +- If no config flags are provided, `app-config.local.yaml` has higher priority + than `app-config.yaml`. ## Secrets diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md index 4ba83146db..953540227c 100644 --- a/docs/features/software-catalog/index.md +++ b/docs/features/software-catalog/index.md @@ -19,7 +19,7 @@ are then harvested and visualized in Backstage. ## How it works -Backstage and the Backstage Service Catalog makes it easy for one team to manage +Backstage and the Backstage Service Catalog make it easy for one team to manage 10 services — and makes it possible for your company to manage thousands of them. diff --git a/docs/features/software-templates/adding-templates.md b/docs/features/software-templates/adding-templates.md index 5a4e0049ab..75a75003f7 100644 --- a/docs/features/software-templates/adding-templates.md +++ b/docs/features/software-templates/adding-templates.md @@ -5,8 +5,8 @@ description: Documentation on Adding your own Templates --- Templates are stored in the **Service Catalog** under a kind `Template`. The -minimum that the template skeleton needs is a `template.yaml` but it would be -good to also have some files in there that can be templated in. +minimum that is needed to define a template is a `template.yaml` file, but it +would be good to also have some files in there that can be templated in. A simple `template.yaml` definition might look something like this: @@ -61,7 +61,7 @@ support to load the location will also need to be added to the Catalog. You can add the template files to the catalog through [static location configuration](../software-catalog/configuration.md#static-location-configuration), -for example +for example: ```yaml catalog: @@ -100,11 +100,11 @@ curl \ --location \ --request POST 'localhost:7000/api/catalog/locations' \ --header 'Content-Type: application/json' \ - --data-raw "{\"type\": \"github\", \"target\": \"https://${YOUR GITHUB REPO}blob/master/${PATH TO FOLDER}/template.yaml\"}" + --data-raw "{\"type\": \"github\", \"target\": \"https://${GITHUB URL}/${YOUR GITHUB ORG/REPO}/blob/master/${PATH TO FOLDER}/template.yaml\"}" ``` -This should then have added the catalog, and also should now be listed under the -create page at http://localhost:3000/create. +This should then have been added the catalog, and be listed under the create +page at http://localhost:3000/create. The `type` field which is chosen in the request to add the `template.yaml` to the Service Catalog here, will become the `PreparerKey` which will be used to diff --git a/docs/features/software-templates/extending/create-your-own-preparer.md b/docs/features/software-templates/extending/create-your-own-preparer.md index 80287c6ce1..ef9d709bce 100644 --- a/docs/features/software-templates/extending/create-your-own-preparer.md +++ b/docs/features/software-templates/extending/create-your-own-preparer.md @@ -15,10 +15,10 @@ location protocols: - `github://` These two are added to the `PreparersBuilder` and then passed into the -`createRouter` function of the `@spotify/plugin-scaffolder-backend` +`createRouter` function of the `@spotify/plugin-scaffolder-backend`. -A full example backend can be found -[here](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), +A full example backend can be found in +[`scaffolder.ts`](https://github.com/spotify/backstage/blob/d91c10f654475a60829fa33a5c81018e517a319a/packages/backend/src/plugins/scaffolder.ts), but it looks something like the following ```ts @@ -56,7 +56,7 @@ when added to the service catalog. You can see more about this `PreparerKey` here in [Register your own template](../adding-templates.md) **note:** Currently the catalog supports loading definitions from GitHub + Local -Files, which translate into the two `PreparerKeys` `file` and `github`. To load +Files, which translate into the two `PreparerKeys`: `file` and `github`. To load from other places, not only will there need to be another preparer, but the support to load the location will also need to be added to the Catalog. diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index ae121f8029..3ebb426466 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -39,17 +39,17 @@ internally. ![Enter some variables](../../assets/software-templates/template-picked.png) After filling in these variables, you'll get some more fields to fill out which -are required for backstage usage: the owner, (which is a `user` in the backstage +are required for backstage usage: the owner (which is a `user` in the backstage system), the `storePath` (which right now must be a GitHub Organisation or -GitHub user), a non-existing github repository name in the format -`organisation/reponame`, and a GitHub team or user account which should be +GitHub user and a non-existing GitHub repository name in the format +`organisation/reponame`), and a GitHub team or user account which should be granted admin access to the repository. ![Enter backstage vars](../../assets/software-templates/template-picked-2.png) ### Run! -Once you've entered values and confirmed, you'll then get a modal with live +Once you've entered values and confirmed, you'll then get a popup box with live progress of what is currently happening with the creation of your template. ![Templating Running](../../assets/software-templates/running.png) @@ -70,6 +70,6 @@ you to the registered component in the catalog: ![Catalog](../../assets/software-templates/go-to-catalog.png) -And then you'll also be able to see it in the Catalog View table +And then you'll also be able to see it in the Catalog View table: ![Catalog](../../assets/software-templates/added-to-the-catalog-list.png) diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 838c5e2d11..842c59e289 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -211,22 +211,16 @@ docs on creating private GitHub access tokens is available Note that the need for private GitHub access tokens will be replaced with GitHub Apps integration further down the line. -#### Github +#### GitHub -The Github access token is retrieved from environment variables via the config. +The GitHub access token is retrieved from environment variables via the config. The config file needs to specify what environment variable the token is retrieved from. Your config should have the following objects. You can configure who can see the new repositories that the scaffolder creates by specifying `visibility` option. Valid options are `public`, `private` and -`internal`. `internal` options is for GitHub Enterprise clients, which means -public within the organization. - -#### Gitlab - -For Gitlab, we currently support the configuration of the GitLab publisher and -allows to configure the private access token and the base URL of a GitLab -instance: +`internal`. The `internal` option is for GitHub Enterprise clients, which means +public within the enterprise. ```yaml scaffolder: @@ -234,6 +228,16 @@ scaffolder: token: $env: GITHUB_TOKEN visibility: public # or 'internal' or 'private' +``` + +#### GitLab + +For GitLab, we currently support the configuration of the GitLab publisher and +allows to configure the private access token and the base URL of a GitLab +instance: + +```yaml +scaffolder: gitlab: api: baseUrl: https://gitlab.com diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index cec5b63cc7..e2b81b4a53 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -82,7 +82,9 @@ for companies to adopt. This involves (something like) the following work items. - “Solidify” work and “Mkdocs stabilization” work that has come out of our Q3 end-to-end work. - Improve/simplify the get up and running process. -- Introduce doc template Software Templates. +- Introduce new documentation templates. +- Extend the already existing docs-template to have options of different + documentation types. - Enable companies to choose their own storage (S3 for example). - Enable companies to choose their own source code hosting provider (GitHub, GitLab, and so). diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 4ca2f431a7..855c8b0634 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -23,8 +23,8 @@ Requests towards this repo. Backstage provides the `@backstage/create-app` package to scaffold standalone instances of Backstage. You will need to have -[NodeJS](https://nodejs.org/en/download/) Active LTS Release installed -(currently v12), [yarn](https://classic.yarnpkg.com/en/docs/install) and +[Node.js](https://nodejs.org/en/download/) Active LTS Release installed +(currently v12), [Yarn](https://classic.yarnpkg.com/en/docs/install) and [Python](https://www.python.org/downloads/) (although you likely have it already). You will also need to have [Docker](https://docs.docker.com/engine/install/) installed to use some features @@ -38,8 +38,8 @@ npx @backstage/create-app ``` You will be taken through a wizard to create your app, and the output should -look something like this. You can read more about this process -[here](https://backstage.io/docs/getting-started/create-an-app). +look something like this. You can read more about this process in +[Create an app](https://backstage.io/docs/getting-started/create-an-app). ### Contributing to Backstage diff --git a/docs/getting-started/running-backstage-locally.md b/docs/getting-started/running-backstage-locally.md index 6e6c8b2733..1a9097afd3 100644 --- a/docs/getting-started/running-backstage-locally.md +++ b/docs/getting-started/running-backstage-locally.md @@ -8,7 +8,7 @@ description: Documentation on How to run Backstage Locally - Node.js -First make sure you are using NodeJS with an Active LTS Release, currently v12. +First make sure you are using Node.js with an Active LTS Release, currently v12. This is made easy with a version manager such as [nvm](https://github.com/nvm-sh/nvm) which allows for version switching. @@ -23,10 +23,10 @@ node --version > v12.18.3 ``` -- yarn +- Yarn Please refer to the -[installation instructions for yarn](https://classic.yarnpkg.com/en/docs/install/). +[installation instructions for Yarn](https://classic.yarnpkg.com/en/docs/install/). - Docker diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index 8edf3e9e4c..504d6ea2c9 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -48,7 +48,7 @@ management. [[live demo](https://backstage-demo.roadie.io/)] ![UI with different components highlighted](../assets/architecture-overview/core-vs-plugin-components-highlighted.png) Each plugin typically makes itself available in the UI on a dedicated URL. For -example, the lighthouse plugin is registered with the UI on `/lighthouse`. +example, the Lighthouse plugin is registered with the UI on `/lighthouse`. [[live demo](https://backstage-demo.roadie.io/lighthouse)] ![The lighthouse plugin UI](../assets/architecture-overview/lighthouse-plugin.png) @@ -76,7 +76,7 @@ Plugins can be enabled, and passed configuration in `apis.ts`. For example, [here](https://github.com/spotify/backstage/blob/master/packages/app/src/apis.ts) is that file in the Backstage sample app. -This is how the lighthouse plugin would be enabled in a typical Backstage +This is how the Lighthouse plugin would be enabled in a typical Backstage application: ```tsx @@ -108,7 +108,7 @@ Architecturally, plugins can take three forms: #### Standalone plugins Standalone plugins run entirely in the browser. -[The tech radar plugin](https://backstage-demo.roadie.io/tech-radar), for +[The Tech Radar plugin](https://backstage-demo.roadie.io/tech-radar), for example, simply renders hard-coded information. It doesn't make any API requests to other services. @@ -124,9 +124,9 @@ simple. Service backed plugins make API requests to a service which is within the purview of the organisation running Backstage. -The lighthouse plugin, for example, makes requests to the +The Lighthouse plugin, for example, makes requests to the [lighthouse-audit-service](https://github.com/spotify/lighthouse-audit-service). -The lighthouse-audit-service is a microservice which runs a copy of Google's +The `lighthouse-audit-service` is a microservice which runs a copy of Google's [Lighthouse library](https://github.com/GoogleChrome/lighthouse/) and stores the results in a PostgreSQL database. @@ -144,11 +144,11 @@ Third-party backed plugins are similar to service backed plugins. The main difference is that the service which backs the plugin is hosted outside of the ecosystem of the company hosting Backstage. -The Circle CI plugin is an example of a third-party backed plugin. Circle CI is -a SaaS service which can be used without any knowledge of Backstage. It has an -API which a Backstage plugin consumes to display content. +The CircleCI plugin is an example of a third-party backed plugin. CircleCI is a +SaaS service which can be used without any knowledge of Backstage. It has an API +which a Backstage plugin consumes to display content. -Requests which go to Circle CI from the users browser are passed through a proxy +Requests which go to CircleCI from the users browser are passed through a proxy service that Backstage provides. Without this, the requests would be blocked by Cross Origin Resource Sharing policies which prevent a browser page served at [https://example.com](https://example.com) from serving resources hosted at @@ -180,7 +180,7 @@ separate docker images. 1. The frontend container 2. The backend container -3. The lighthouse audit service container +3. The Lighthouse audit service container ![Boxes around the architecture to indicate how it is containerised](../assets/architecture-overview/containerised.png) @@ -204,7 +204,7 @@ yarn run docker-build This will create a container called `example-backend`. The lighthouse-audit-service container is already publicly available in Docker -Hub and can be downloaded and ran with +Hub and can be downloaded and run with ```bash docker run spotify/lighthouse-audit-service:latest diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index ee0a27cde2..0f11896f7a 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -73,7 +73,7 @@ export const plugin = createPlugin({ ``` This is where the plugin is created and where it hooks into the app by declaring -what component should be shown on what url. See reference docs for +what component should be shown on what URL. See reference docs for [createPlugin](../reference/createPlugin.md) or [router](../reference/createPlugin-router.md). @@ -101,11 +101,11 @@ Backstage CLI. ## Talking to the outside world -If your plugin needs to communicate with services outside the backstage +If your plugin needs to communicate with services outside the Backstage environment you will probably face challenges like CORS policies and/or backend-side authorization. To smooth this process out you can use proxy - -either the one you already have (like nginx/haproxy/etc) or the proxy-backend -plugin that we provide for the backstage backend. +either the one you already have (like Nginx, HAProxy, etc.) or the proxy-backend +plugin that we provide for the Backstage backend. [Read more](https://github.com/spotify/backstage/blob/master/plugins/proxy-backend/README.md) [Back to Getting Started](../README.md) diff --git a/docs/support/project-structure.md b/docs/support/project-structure.md index 277ca3929f..fd8e76d58a 100644 --- a/docs/support/project-structure.md +++ b/docs/support/project-structure.md @@ -71,38 +71,38 @@ Let's look at them individually. ### `packages/` -These are all the packages that is we use within the project. -[Plugins](#plugins) are separated out into their own folder, see further down. +These are all the packages that we use within the project. [Plugins](#plugins) +are separated out into their own folder, see further down. - [`app/`](https://github.com/spotify/backstage/tree/master/packages/app) - This is our take on how an App could look like, bringing together a set of packages and plugins into a working Backstage App. This is not a published package, and - the main goals is to provide a demo of what an App could look like, and also - enabling local development. + the main goals are to provide a demo of what an App could look like and to + enable local development. - [`backend/`](https://github.com/spotify/backstage/tree/master/packages/backend) - - Every standalone backstage project will have both an `app` _and_ a `backend` + Every standalone Backstage project will have both an `app` _and_ a `backend` package. The `backend` uses plugins to construct a working backend that the frontend (`app`) can use. - [`backend-common/`](https://github.com/spotify/backstage/tree/master/packages/backend-common) - There are no "core" packages in the backend. Instead we have `backend-common` - which contains helper middleware´s and other utils. + which contains helper middleware and other utils. - [`catalog-model/`](https://github.com/spotify/backstage/tree/master/packages/catalog-model) - - You can considers this to be a library for working with the catalog of sorts. + You can consider this to be a library for working with the catalog of sorts. It contains the definition of an [Entity](https://backstage.io/docs/features/software-catalog/references#docsNav), - as well as validation an other logic related to it. This package can be used + as well as validation and other logic related to it. This package can be used in both the frontend and the backend. - [`cli/`](https://github.com/spotify/backstage/tree/master/packages/cli) - One of the biggest packages in our project, the `cli` is used to build, serve, diff, create-plugins and more. In the early days of this project, we started - out with calling tools directly - such as `eslint` - through package.json. But - as it was tricky to have a good development experience around that when we + out with calling tools directly - such as `eslint` - through `package.json`. + But as it was tricky to have a good development experience around that when we change named tooling, we opted for wrapping those in our own cli. That way - everything looks the same in package.json. Much like + everything looks the same in `package.json`. Much like [react-scripts](https://github.com/facebook/create-react-app/tree/master/packages/react-scripts). - [`cli-common/`](https://github.com/spotify/backstage/tree/master/packages/cli-common) - @@ -119,20 +119,21 @@ These are all the packages that is we use within the project. * [`config-loader/`](https://github.com/spotify/backstage/tree/master/packages/config-loader) - This package is used to read config objects. It does not know how to merge, - this only reads files and passes them on to the config. As this part os only + but only reads files and passes them on to the config. As this part is only used by the backend, we chose to separate `config` and `config-loader` into two different packages. - [`core/`](https://github.com/spotify/backstage/tree/master/packages/core) - This package contains our visual React components, some of which you can find - [here](https://backstage.io/storybook/?path=/story/plugins-examples--plugin-with-data). + in + [plugin examples](https://backstage.io/storybook/?path=/story/plugins-examples--plugin-with-data). Apart from that it re-exports everything from [`core-api`] so that users only need to rely on one package. * [`core-api/`](https://github.com/spotify/backstage/tree/master/packages/core-api) - - This package contains apis and definitions of such. It is it's own package + This package contains APIs and definitions of such. It is it's own package because we needed to split our `test-utils` package. It's an implementation - detail that we try to hide from our users, and no-one should have to depend on + detail that we try to hide from our users, and no one should have to depend on it directly. * [`test-utils/`](https://github.com/spotify/backstage/tree/master/packages/test-utils) - @@ -140,7 +141,7 @@ These are all the packages that is we use within the project. `core-api`. * [`test-utils-core/`](https://github.com/spotify/backstage/tree/master/packages/test-utils-core) - - This package contains more general purpose testing facilities for testing an + This package contains more general purpose testing facilities for testing a Backstage App. * [`create-app/`](https://github.com/spotify/backstage/tree/master/packages/create-app) - @@ -181,7 +182,7 @@ These are all the packages that is we use within the project. ### `plugins/` -Most of the functionality of an Backstage App comes from plugins. Even core +Most of the functionality of a Backstage App comes from plugins. Even core features can be plugins, take the [catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog) as an example. @@ -199,7 +200,7 @@ through the ## Packages outside of the monorepo -For convenience we include packages in our project that is not part of our +For convenience we include packages in our project that are not part of our monorepo setup. - [`microsite/`](https://github.com/spotify/backstage/blob/master/microsite) - @@ -220,20 +221,17 @@ future. that this folder use the public registry. - [`.vale.ini`](https://github.com/spotify/backstage/tree/master/.vale.ini) - - [Spell checker](https://github.com/errata-ai/vale) for markdown files + [Spell checker](https://github.com/errata-ai/vale) for Markdown files. - [`.yarnrc`](https://github.com/spotify/backstage/tree/master/.yarnrc) - - Enforces "our" version of yarn. + Enforces "our" version of Yarn. - [`app-config.yaml`](https://github.com/spotify/backstage/tree/master/app-config.yaml) - - Configuration for the app, both frontend and backend - -- [`app-config.development.yaml`](https://github.com/spotify/backstage/tree/master/app-config.development.yaml) - - Used for overriding configuration when developing locally. + Configuration for the app, both frontend and backend. - [`catalog-info.yaml`](https://github.com/spotify/backstage/tree/master/catalog-info.yaml) - Description of Backstage in the Backstage Entity format. - [`lerna.json`](https://github.com/spotify/backstage/tree/master/lerna.json) - - [lerna](https://github.com/lerna/lerna) monorepo config. We are using + [Lerna](https://github.com/lerna/lerna) monorepo config. We are using `yarn workspaces`, so this will only be used for executing scripts. diff --git a/docs/tutorials/journey.md b/docs/tutorials/journey.md index 908d7fc61b..059782de33 100644 --- a/docs/tutorials/journey.md +++ b/docs/tutorials/journey.md @@ -179,7 +179,7 @@ const onSave = async () => { ``` Now it's much simpler for users to change the theme tune, as they no longer need -to go look up a track ID and edit a yaml file. Instead, they can now stay inside +to go look up a track ID and edit a YAML file. Instead, they can now stay inside Backstage and search for the track and request the change from there. In addition, the requested change can be reviewed by the regular process of each organization. diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md index 8f6b35617b..7fc7e739f2 100644 --- a/docs/tutorials/quickstart-app-auth.md +++ b/docs/tutorials/quickstart-app-auth.md @@ -11,7 +11,7 @@ title: Monorepo App Setup With Authentication > own environment. It starts with a skeleton install and verifying of the > monorepo's functionality. Next, GitHub authentication is added and tested. > -> This document assumes you have NodeJS 12 active along with Yarn and Python. +> This document assumes you have Node.js 12 active along with Yarn and Python. > Please note, that at the time of this writing, the current version is > 0.1.1-alpha.21. This guide can still be used with future versions, just, > verify as you go. If you run into issues, you can compare your setup with mine diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 9f571b0c9a..daf76aa1cd 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -20,7 +20,7 @@ title: Adding Custom Plugin to Existing Monorepo App > functionality, extend the Sidebar to make our life easy. Finally, we add > custom code to display GitHub repository information. > -> This document assumes you have NodeJS 12 active along with Yarn and Python. +> This document assumes you have Node.js 12 active along with Yarn and Python. > Please note, that at the time of this writing, the current version is > 0.1.1-alpha.21. This guide can still be used with future versions, just, > verify as you go. If you run into issues, you can compare your setup with mine @@ -161,7 +161,7 @@ export default ExampleFetchComponent; # The Graph Model -GitHub has a graphql API available for interacting. Let's start by adding our +GitHub has a GraphQL API available for interacting. Let's start by adding our basic repository query 1. Add the query const statement outside ExampleFetchComponent diff --git a/microsite/blog/2020-10-22-cost-insights-plugin.md b/microsite/blog/2020-10-22-cost-insights-plugin.md new file mode 100644 index 0000000000..421ebacdd2 --- /dev/null +++ b/microsite/blog/2020-10-22-cost-insights-plugin.md @@ -0,0 +1,79 @@ +--- +title: New Cost Insights plugin: The engineer’s solution to taming cloud costs +author: Janisa Anandamohan +authorURL: https://twitter.com/janisa_a +--- + +How did Spotify save millions on cloud costs within a matter of months?? We made cost optimization just another part of the daily development process. Our newly open sourced [Cost Insights plugin](https://github.com/spotify/backstage/tree/master/plugins/cost-insights) makes a team’s cloud costs visible — and actionable — right inside Backstage. So engineers can see the impact of their cloud usage (down to a product and resource level) and make optimizations wherever and whenever it makes sense. By managing cloud costs from the ground up, you can make smarter decisions that let you continue to build and scale quickly, without wasting resources. + + + +Are we turning engineers into accountants? Nope, we’re just letting engineers do what they do best, in the place that feels natural to them: inside Backstage. + + + +## Why put a cost management tool in the hands of engineers? + +Engineers are closest to the metal in terms of knowing why a specific feature, product, or service is using cloud resources. So they’re in the best position to understand how costs impact ongoing development (and vice versa). + +If you manage costs top-down from a 10,000-foot view of your cloud infrastructure, you’re likely making decisions far removed from products, especially in larger organizations. Set a broad cost-cutting goal, and you could be creating unintended consequences — curtailing spending at the expense of growth or experimentation. + +## Ground-level intelligence, data-driven solutions + +Our hypothesis at Spotify was, if you bring spending data into an engineer’s everyday development workflow, they’ll naturally look for cost optimizations just like they look for any other optimization. And the cost optimizations will be more efficient and effective, because the decisions are informed at the ground level. + +The problem is that most cloud platforms don’t provide cost data at a granular enough level to make those decisions. And the bigger your organization (say, two-thousand-microservices and four-thousand-data-pipelines big, like Spotify), then the less you can attribute these large, fuzzy numbers to the right team, let alone a shipping product or internal service. + +That’s where Cost Insights comes in. Instead of making cost management and product development separate departments on the org chart, Backstage brings them together — with a level of detail and specificity engineers relate and respond to. + +## How to turn dollars into sense + +It’s not enough to make costs visible. To be useful, the numbers need to be relevant, relatable, and actionable. In other words, not just cost information, but insights. There are several ways the plugin puts data from your cloud provider in a more helpful context. + +### Use business metrics to evaluate costs + +Cost Insights will show you trends at a glance and also let you compare costs quarter over quarter. More importantly, you can also evaluate costs against business metrics that you care most about. In the example below, should the upward slope shown in the first screen be cause for worry? Perhaps not — if you switch views, you’ll see that cost per daily average user (DAU) is actually going down. Exactly what you hope to see as you scale. + +![Comparing costs to DAU](assets/20-10-22/cost-insights-1-dau.gif) +_(Note: Screens are examples; they do not show real data.)_ + +### Illustrate costs with relatable, real-world comparisons + +In addition to dollar amounts, Cost Insights allows teams to visualize and convert cost overages into more relatable terms. In the example below, we equate the growth in costs for virtual machine instances (100% increase) to developer time spent (about 1 engineer). We use this particular comparison in the plugin because we found it resonated with our own engineers — providing a useful perspective for spending increases. You can configure what the “cost of an engineer” means to your organization. Or engineers can build in their own comparisons — cups of coffee, carbon offset credits, electric luxury vehicles — whatever makes costs more tangible for them. + +![Cost growth as engineering time](assets/20-10-22/cost-insights-2-engineer.png) +_(Note: Screens are examples; they do not show real data.)_ + +### Tie spending to specific products and resources + +The more detailed the cost data, the more relevant, actionable, and helpful it is. Cost Insights allows you to attribute costs to products and resources in a way that makes sense to your engineers. For example, here we see a breakdown of data processing costs by individual pipelines. This allows your team to target optimizations more precisely. + +![Data Processing costs by pipeline](assets/20-10-22/cost-insights-3-data.png) +_(Note: Screens are examples; they do not show real data.)_ + +## Driving down costs without slowing down development + +When it comes to cutting costs, we actually want to guard against over-optimization. Growth and costs can go hand in hand. The trick is knowing when one is out of balance and needs addressing. Our product highlights when there’s been a large increase in spending, so that engineers are thinking about cost only when they must and aren’t distracted from their set goals and priorities. + +Engineers can then determine for themselves if the time invested in an optimization was valuable compared to the costs saved. Cost Insights puts the decision in our engineers’ hands for them to choose when to focus on growth efforts and when to focus on cost. Control, as ever, remains with our developers, where we think it belongs. + +## Getting started + +You can begin working with the Cost Insights plugin today on [GitHub](https://github.com/spotify/backstage/tree/master/plugins/cost-insights). We include an example client with static data in the expected format. The `CostInsightsApi` should talk with a cloud billing backend that aggregates billing data from your cloud provider. + +The current release of Cost Insights includes: + +- Daily cost graph by team or billing account +- Cost comparisons against configurable business metrics (including an option for Daily Active Users) +- Insights panels — configurable for the cloud products your company uses +- Cost alerts and recommendations +- Selectable time periods for month-over-month or quarter-over-quarter comparisons +- Conversion of cost growth into “cost of average engineer” to help optimization trade-off decisions + +Our hope is to help other companies translate their cloud cost in a relatable way for their engineers to better understand their impact and accurately identify their opportunities for optimizations. + +And if you’re interested in contributing to our outstanding issues, you can find them in the issues queue, filtered under the [‘cost-insights’ label](https://github.com/spotify/backstage/labels/cost-insights). + +## Ready for DevSecCostOpsPlus (and whatever’s next) + +There’s DevOps, there’s DevSecOps, and then there’s Backstage: one frontend for all your infrastructure. From building, testing, and deploying to monitoring and security — Backstage helps you manage your entire tech organization and provides a seamless developer experience for engineers, from end to end to end. And now that also extends to cost management for your cloud infrastructure and tooling. Happy building and [happy optimizing](https://github.com/spotify/backstage/tree/master/plugins/cost-insights). diff --git a/microsite/blog/assets/20-10-22/cost-insights-1-dau.gif b/microsite/blog/assets/20-10-22/cost-insights-1-dau.gif new file mode 100644 index 0000000000..df9396439d Binary files /dev/null and b/microsite/blog/assets/20-10-22/cost-insights-1-dau.gif differ diff --git a/microsite/blog/assets/20-10-22/cost-insights-2-engineer.png b/microsite/blog/assets/20-10-22/cost-insights-2-engineer.png new file mode 100644 index 0000000000..1e9293f5c1 Binary files /dev/null and b/microsite/blog/assets/20-10-22/cost-insights-2-engineer.png differ diff --git a/microsite/blog/assets/20-10-22/cost-insights-3-data.png b/microsite/blog/assets/20-10-22/cost-insights-3-data.png new file mode 100644 index 0000000000..941f0487da Binary files /dev/null and b/microsite/blog/assets/20-10-22/cost-insights-3-data.png differ diff --git a/microsite/data/plugins/security-insights.yaml b/microsite/data/plugins/security-insights.yaml new file mode 100644 index 0000000000..1cbcfbc3aa --- /dev/null +++ b/microsite/data/plugins/security-insights.yaml @@ -0,0 +1,9 @@ +--- +title: Security Insights +author: roadie.io +authorUrl: https://roadie.io/ +category: Security +description: View Security Insights for your components in Backstage. +documentation: https://roadie.io/backstage/plugins/security-insights +iconUrl: https://roadie.io/static/7f13bb8d861d8dedc5112fb939d215f9/351f2/GitHub-Mark-Light-120px-plus.png +npmPackageName: '@roadiehq/backstage-plugin-security-insights' diff --git a/microsite/pages/en/demos.js b/microsite/pages/en/demos.js index c6657c6a31..63c15ab698 100644 --- a/microsite/pages/en/demos.js +++ b/microsite/pages/en/demos.js @@ -78,7 +78,41 @@ const Background = props => { - + + + + Control cloud costs + + How do you control cloud costs while maintaining the speed and + independence of your development teams? With the{' '} + Cost Insights plugin{' '} + for Backstage, managing cloud costs becomes just another part of + an engineer’s daily development process. They get a clear view of + their spending — and can decide for themselves how they want to + optimize it. Learn more about the{' '} + + Cost Insights plugin + + . + + + Watch now + + + + + + + + + @@ -96,7 +130,7 @@ const Background = props => { . - + Watch now diff --git a/microsite/pages/en/plugins.js b/microsite/pages/en/plugins.js index 66f3a9776d..36957c2b4e 100644 --- a/microsite/pages/en/plugins.js +++ b/microsite/pages/en/plugins.js @@ -16,9 +16,8 @@ const { const pluginsDirectory = require('path').join(process.cwd(), 'data/plugins'); const pluginMetadata = fs .readdirSync(pluginsDirectory) - .map(file => - yaml.safeLoad(fs.readFileSync(`./data/plugins/${file}`, 'utf8')), - ); + .map(file => yaml.safeLoad(fs.readFileSync(`./data/plugins/${file}`, 'utf8'))) + .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); const truncate = text => text.length > 170 ? text.substr(0, 170) + '...' : text; diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts index a6ef15f3b9..799b9e1975 100644 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -23,6 +23,7 @@ import { Cost, CostInsightsApi, DateAggregation, + DEFAULT_DATE_FORMAT, Duration, exclusiveEndDateOf, Group, @@ -38,18 +39,32 @@ import { UnlabeledDataflowData, } from '@backstage/plugin-cost-insights'; -function durationOf(intervals: string): Duration { - const match = intervals.match(/\/(?P\d+[DM])\//); - const { duration } = match!.groups!; - return duration as Duration; +type IntervalFields = { + duration: Duration; + endDate: string; +}; + +function parseIntervals(intervals: string): IntervalFields { + const match = intervals.match( + /\/(?P\d+[DM])\/(?\d{4}-\d{2}-\d{2})/, + ); + if (Object.keys(match?.groups || {}).length !== 2) { + throw new Error(`Invalid intervals: ${intervals}`); + } + const { duration, date } = match!.groups!; + return { + duration: duration as Duration, + endDate: date, + }; } function aggregationFor( - duration: Duration, + intervals: string, baseline: number, ): DateAggregation[] { - const days = dayjs(exclusiveEndDateOf(duration)).diff( - inclusiveStartDateOf(duration), + const { duration, endDate } = parseIntervals(intervals); + const days = dayjs(exclusiveEndDateOf(duration, endDate)).diff( + inclusiveStartDateOf(duration, endDate), 'day', ); @@ -57,10 +72,10 @@ function aggregationFor( (values: DateAggregation[], i: number): DateAggregation[] => { const last = values.length ? values[values.length - 1].amount : baseline; values.push({ - date: dayjs(inclusiveStartDateOf(duration)) + date: dayjs(inclusiveStartDateOf(duration, endDate)) .add(i, 'day') - .format('YYYY-MM-DD'), - amount: last + (baseline / 20) * (Math.random() * 2 - 1), + .format(DEFAULT_DATE_FORMAT), + amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)), }); return values; }, @@ -99,6 +114,12 @@ export class ExampleCostInsightsClient implements CostInsightsApi { return new Promise(resolve => setTimeout(resolve, 0, res)); } + getLastCompleteBillingDate(): Promise { + return Promise.resolve( + dayjs().subtract(1, 'day').format(DEFAULT_DATE_FORMAT), + ); + } + async getUserGroups(userId: string): Promise { const groups: Group[] = await this.request({ userId }, [ { id: 'pied-piper' }, @@ -121,10 +142,10 @@ export class ExampleCostInsightsClient implements CostInsightsApi { metric: string, intervals: string, ): Promise { - const aggregation = aggregationFor( - durationOf(intervals), - 100_000, - ).map(entry => ({ ...entry, amount: Math.round(entry.amount) })); + const aggregation = aggregationFor(intervals, 100_000).map(entry => ({ + ...entry, + amount: Math.round(entry.amount), + })); const cost: MetricData = await this.request( { metric, intervals }, @@ -140,7 +161,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { } async getGroupDailyCost(group: string, intervals: string): Promise { - const aggregation = aggregationFor(durationOf(intervals), 8_000); + const aggregation = aggregationFor(intervals, 8_000); const groupDailyCost: Cost = await this.request( { group, intervals }, { @@ -154,7 +175,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { } async getProjectDailyCost(project: string, intervals: string): Promise { - const aggregation = aggregationFor(durationOf(intervals), 1_500); + const aggregation = aggregationFor(intervals, 1_500); const projectDailyCost: Cost = await this.request( { project, intervals }, { @@ -257,8 +278,8 @@ export class ExampleCostInsightsClient implements CostInsightsApi { async getAlerts(group: string): Promise { const projectGrowthData: ProjectGrowthData = { project: 'example-project', - periodStart: 'Q2 2020', - periodEnd: 'Q3 2020', + periodStart: '2020-Q2', + periodEnd: '2020-Q3', aggregation: [60_000, 120_000], change: { ratio: 1, diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 249fa9e72c..5a2357f74e 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -32,10 +32,12 @@ "@backstage/cli-common": "^0.1.1-alpha.25", "@backstage/config": "^0.1.1-alpha.25", "@backstage/config-loader": "^0.1.1-alpha.25", + "@backstage/test-utils": "^0.1.1-alpha.25", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "compression": "^1.7.4", "cors": "^2.8.5", + "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-prom-bundle": "^6.1.0", "express-promise-router": "^3.0.3", @@ -44,8 +46,8 @@ "knex": "^0.21.1", "lodash": "^4.17.15", "logform": "^2.1.1", + "minimist": "^1.2.5", "morgan": "^1.10.0", - "node-fetch": "^2.6.0", "prom-client": "^12.0.0", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", @@ -63,8 +65,8 @@ "@backstage/cli": "^0.1.1-alpha.25", "@types/compression": "^1.7.0", "@types/http-errors": "^1.6.3", + "@types/minimist": "^1.2.0", "@types/morgan": "^1.9.0", - "@types/node-fetch": "^2.5.7", "@types/stoppable": "^1.1.0", "@types/supertest": "^2.0.8", "@types/webpack-env": "^1.15.2", @@ -72,8 +74,7 @@ "get-port": "^5.1.1", "http-errors": "^1.7.3", "jest": "^26.0.1", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", + "msw": "^0.21.2", "supertest": "^4.0.2" }, "files": [ diff --git a/packages/backend-common/src/config.ts b/packages/backend-common/src/config.ts index 490070f80a..69e46bd5b4 100644 --- a/packages/backend-common/src/config.ts +++ b/packages/backend-common/src/config.ts @@ -14,19 +14,38 @@ * limitations under the License. */ +import { resolve as resolvePath } from 'path'; +import parseArgs from 'minimist'; +import { Logger } from 'winston'; import { findPaths } from '@backstage/cli-common'; +import { Config, ConfigReader } from '@backstage/config'; import { loadConfig } from '@backstage/config-loader'; +type Options = { + logger: Logger; + // process.argv or any other overrides + argv: string[]; +}; + /** * Load configuration for a Backend */ -export async function loadBackendConfig() { +export async function loadBackendConfig(options: Options): Promise { + const args = parseArgs(options.argv); + const configOpts: string[] = [args.config ?? []].flat(); + /* eslint-disable-next-line no-restricted-syntax */ const paths = findPaths(__dirname); const configs = await loadConfig({ env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development', - rootPaths: [paths.targetRoot, paths.targetDir], + configRoot: paths.targetRoot, + configPaths: configOpts.map(opt => resolvePath(opt)), shouldReadSecrets: true, }); - return configs; + + options.logger.info( + `Loaded config from ${configs.map(c => c.context).join(', ')}`, + ); + + return ConfigReader.fromConfigs(configs); } diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index c9e1fc5bc7..24b435006a 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -19,14 +19,13 @@ import { setupServer } from 'msw/node'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '../logging'; import { AzureUrlReader } from './AzureUrlReader'; +import { msw } from '@backstage/test-utils'; const logger = getVoidLogger(); describe('AzureUrlReader', () => { const worker = setupServer(); - - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterAll(() => worker.close()); + msw.setupDefaultHandlers(worker); beforeEach(() => { worker.use( @@ -41,7 +40,6 @@ describe('AzureUrlReader', () => { ), ); }); - afterEach(() => worker.resetHandlers()); const createConfig = (token?: string) => new ConfigReader( diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 28fbf25eea..8e6e6a75df 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import fetch, { RequestInit, HeadersInit, Response } from 'node-fetch'; +import fetch from 'cross-fetch'; import { Config } from '@backstage/config'; import { NotFoundError } from '../errors'; import { ReaderFactory, UrlReader } from './types'; @@ -76,7 +76,7 @@ export class AzureUrlReader implements UrlReader { // for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html if (response.ok && response.status !== 203) { - return response.buffer(); + return Buffer.from(await response.text()); } const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index c3e61fb821..bd7c43d887 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -19,14 +19,14 @@ import { setupServer } from 'msw/node'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '../logging'; import { BitbucketUrlReader } from './BitbucketUrlReader'; +import { msw } from '@backstage/test-utils'; const logger = getVoidLogger(); describe('BitbucketUrlReader', () => { const worker = setupServer(); - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterAll(() => worker.close()); + msw.setupDefaultHandlers(worker); beforeEach(() => { worker.use( @@ -41,7 +41,6 @@ describe('BitbucketUrlReader', () => { ), ); }); - afterEach(() => worker.resetHandlers()); const createConfig = (username?: string, appPassword?: string) => new ConfigReader( diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index e2576eed47..bf07dc18a6 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import fetch, { RequestInit, HeadersInit, Response } from 'node-fetch'; +import fetch from 'cross-fetch'; import { Config } from '@backstage/config'; import { ReaderFactory, UrlReader } from './types'; import { NotFoundError } from '../errors'; @@ -84,7 +84,7 @@ export class BitbucketUrlReader implements UrlReader { } if (response.ok) { - return response.buffer(); + return Buffer.from(await response.text()); } const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; diff --git a/packages/backend-common/src/reading/FetchUrlReader.ts b/packages/backend-common/src/reading/FetchUrlReader.ts index ea56f2182d..d2e5c45620 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import fetch, { Response } from 'node-fetch'; +import fetch from 'cross-fetch'; import { NotFoundError } from '../errors'; import { UrlReader } from './types'; @@ -31,7 +31,7 @@ export class FetchUrlReader implements UrlReader { } if (response.ok) { - return response.buffer(); + return Buffer.from(await response.text()); } const message = `could not read ${url}, ${response.status} ${response.statusText}`; diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index e5bed6dd26..d12344ec91 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -16,7 +16,7 @@ import { Config } from '@backstage/config'; import parseGitUri from 'git-url-parse'; -import fetch, { HeadersInit, RequestInit, Response } from 'node-fetch'; +import fetch from 'cross-fetch'; import { NotFoundError } from '../errors'; import { ReaderFactory, UrlReader } from './types'; @@ -219,7 +219,7 @@ export class GithubUrlReader implements UrlReader { } if (response.ok) { - return response.buffer(); + return Buffer.from(await response.text()); } const message = `${url} could not be read as ${ghUrl}, ${response.status} ${response.statusText}`; diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 09da9c4e0a..4b3aa471c3 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -19,14 +19,14 @@ import { setupServer } from 'msw/node'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; +import { msw } from '@backstage/test-utils'; const logger = getVoidLogger(); describe('GitlabUrlReader', () => { const worker = setupServer(); - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterAll(() => worker.close()); + msw.setupDefaultHandlers(worker); beforeEach(() => { worker.use( @@ -44,7 +44,6 @@ describe('GitlabUrlReader', () => { ), ); }); - afterEach(() => worker.resetHandlers()); const createConfig = (token?: string) => new ConfigReader( diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 0e430d650c..378e76fb61 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import fetch, { RequestInit, Response } from 'node-fetch'; +import fetch from 'cross-fetch'; import { Config } from '@backstage/config'; import { NotFoundError } from '../errors'; import { ReaderFactory, UrlReader } from './types'; @@ -77,7 +77,7 @@ export class GitlabUrlReader implements UrlReader { } if (response.ok) { - return response.buffer(); + return Buffer.from(await response.text()); } const message = `${url} could not be read as ${builtUrl}, ${response.status} ${response.statusText}`; diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 5ff7ed4271..710de6f1d7 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; +import { Config } from '@backstage/config'; import compression from 'compression'; import cors from 'cors'; import express, { Router } from 'express'; @@ -77,7 +77,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { this.module = moduleRef; } - loadConfig(config: ConfigReader): ServiceBuilder { + loadConfig(config: Config): ServiceBuilder { const backendConfig = config.getOptionalConfig('backend'); if (!backendConfig) { return this; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 18fd44f089..b74954ecc5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -33,7 +33,7 @@ import { SingleHostDiscovery, UrlReaders, } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; +import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; @@ -47,7 +47,7 @@ import graphql from './plugins/graphql'; import app from './plugins/app'; import { PluginEnvironment } from './types'; -function makeCreateEnv(config: ConfigReader) { +function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); @@ -64,9 +64,11 @@ function makeCreateEnv(config: ConfigReader) { } async function main() { - const configs = await loadBackendConfig(); - const configReader = ConfigReader.fromConfigs(configs); - const createEnv = makeCreateEnv(configReader); + const config = await loadBackendConfig({ + argv: process.argv, + logger: getRootLogger(), + }); + const createEnv = makeCreateEnv(config); const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); @@ -93,7 +95,7 @@ async function main() { apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) - .loadConfig(configReader) + .loadConfig(config) .addRouter('', await healthcheck(healthcheckEnv)) .addRouter('/api', apiRouter) .addRouter('', await app(appEnv)); diff --git a/packages/catalog-model/examples/acme-corp.yaml b/packages/catalog-model/examples/acme-corp.yaml new file mode 100644 index 0000000000..7e5c5ebc61 --- /dev/null +++ b/packages/catalog-model/examples/acme-corp.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: acme-corp + description: A collection of all Backstage example Groups +spec: + type: github + targets: + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/acme/org.yaml diff --git a/packages/catalog-model/examples/acme/backstage-group.yaml b/packages/catalog-model/examples/acme/backstage-group.yaml new file mode 100644 index 0000000000..f992d155bc --- /dev/null +++ b/packages/catalog-model/examples/acme/backstage-group.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: backstage + description: The backstage sub-department +spec: + type: sub-department + parent: infrastructure + ancestors: [infrastructure, acme-corp] + children: [team-a, team-b] + descendants: [team-a, team-b] diff --git a/packages/catalog-model/examples/acme/boxoffice-group.yaml b/packages/catalog-model/examples/acme/boxoffice-group.yaml new file mode 100644 index 0000000000..0be1fe58cb --- /dev/null +++ b/packages/catalog-model/examples/acme/boxoffice-group.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: boxoffice + description: The boxoffice sub-department +spec: + type: sub-department + parent: infrastructure + ancestors: [infrastructure, acme-corp] + children: [team-c, team-d] + descendants: [team-c, team-d] diff --git a/packages/catalog-model/examples/acme/infrastructure-group.yaml b/packages/catalog-model/examples/acme/infrastructure-group.yaml new file mode 100644 index 0000000000..2341782944 --- /dev/null +++ b/packages/catalog-model/examples/acme/infrastructure-group.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: infrastructure + description: The infra department +spec: + type: department + parent: acme-corp + ancestors: [acme-corp] + children: [backstage, boxoffice] + descendants: [backstage, boxoffice, team-a, team-b, team-c, team-d] diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml new file mode 100644 index 0000000000..2d6a6a2666 --- /dev/null +++ b/packages/catalog-model/examples/acme/org.yaml @@ -0,0 +1,27 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: acme-corp + description: The acme-corp organization +spec: + type: organization + ancestors: [] + children: [infrastructure] + descendants: + [infrastructure, backstage, boxoffice, team-a, team-b, team-c, team-d] +--- +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: example-groups + description: A collection of all Backstage example Groups +spec: + type: github + targets: + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/acme/infrastructure-group.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/acme/boxoffice-group.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/acme/backstage-group.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/acme/team-a-group.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/acme/team-b-group.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/acme/team-c-group.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/acme/team-d-group.yaml diff --git a/packages/catalog-model/examples/acme/team-a-group.yaml b/packages/catalog-model/examples/acme/team-a-group.yaml new file mode 100644 index 0000000000..dacc4c6c3c --- /dev/null +++ b/packages/catalog-model/examples/acme/team-a-group.yaml @@ -0,0 +1,44 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: team-a + description: Team A +spec: + type: team + parent: backstage + ancestors: [backstage, infrastructure, acme-corp] + children: [] + descendants: [] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: breanna.davison +spec: + profile: + displayName: Breanna Davison + email: breanna-davison@example.com + picture: https://example.com/staff/breanna.jpeg + memberOf: [team-a] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: janelle.dawe +spec: + profile: + displayName: Janelle Dawe + email: janelle-dawe@example.com + picture: https://example.com/staff/janelle.jpeg + memberOf: [team-a] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: nigel.manning +spec: + profile: + displayName: Nigel Manning + email: nigel-manning@example.com + picture: https://example.com/staff/nigel.jpeg + memberOf: [team-a] diff --git a/packages/catalog-model/examples/acme/team-b-group.yaml b/packages/catalog-model/examples/acme/team-b-group.yaml new file mode 100644 index 0000000000..00e9e41d80 --- /dev/null +++ b/packages/catalog-model/examples/acme/team-b-group.yaml @@ -0,0 +1,66 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: team-b + description: Team B +spec: + type: team + parent: backstage + ancestors: [backstage, infrastructure, acme-corp] + children: [] + descendants: [] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: amelia.park +spec: + profile: + displayName: Amelia Park + email: amelia-park@example.com + picture: https://example.com/staff/amelia.jpeg + memberOf: [team-b] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: colette.brock +spec: + profile: + displayName: Colette Brock + email: colette-brock@example.com + picture: https://example.com/staff/colette.jpeg + memberOf: [team-b] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: jenny.doe +spec: + profile: + displayName: Jenny Doe + email: jenny-doe@example.com + picture: https://example.com/staff/jenny.jpeg + memberOf: [team-b] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: jonathon.page +spec: + profile: + displayName: Jonathon Page + email: jonathon-page@example.com + picture: https://example.com/staff/jonathon.jpeg + memberOf: [team-b] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: justine.barrow +spec: + profile: + displayName: Justine Barrow + email: justine-barrow@example.com + picture: https://example.com/staff/justine.jpeg + memberOf: [team-b] diff --git a/packages/catalog-model/examples/acme/team-c-group.yaml b/packages/catalog-model/examples/acme/team-c-group.yaml new file mode 100644 index 0000000000..0f97f69cb3 --- /dev/null +++ b/packages/catalog-model/examples/acme/team-c-group.yaml @@ -0,0 +1,66 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: team-c + description: Team C +spec: + type: team + parent: boxoffice + ancestors: [boxoffice, infrastructure, acme-corp] + children: [] + descendants: [] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: calum.leavy +spec: + profile: + displayName: Calum Leavy + email: calum-leavy@example.com + picture: https://example.com/staff/calum.jpeg + memberOf: [team-c] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: frank.tiernan +spec: + profile: + displayName: Frank Tiernan + email: frank-tiernan@example.com + picture: https://example.com/staff/frank.jpeg + memberOf: [team-c] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: peadar.macmahon +spec: + profile: + displayName: Peadar MacMahon + email: peadar-macmahon@example.com + picture: https://example.com/staff/peadar.jpeg + memberOf: [team-c] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: sarah.gilroy +spec: + profile: + displayName: Sarah Gilroy + email: sarah-gilroy@example.com + picture: https://example.com/staff/sarah.jpeg + memberOf: [team-c] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: tara.macgovern +spec: + profile: + displayName: Tara MacGovern + email: tara-macgovern@example.com + picture: https://example.com/staff/tara.jpeg + memberOf: [team-c] diff --git a/packages/catalog-model/examples/acme/team-d-group.yaml b/packages/catalog-model/examples/acme/team-d-group.yaml new file mode 100644 index 0000000000..5a1a53a2e6 --- /dev/null +++ b/packages/catalog-model/examples/acme/team-d-group.yaml @@ -0,0 +1,33 @@ +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: team-d + description: Team D +spec: + type: team + parent: boxoffice + ancestors: [boxoffice, infrastructure, acme-corp] + children: [] + descendants: [] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: eva.macdowell +spec: + profile: + displayName: Eva MacDowell + email: eva-macdowell@example.com + picture: https://example.com/staff/eva.jpeg + memberOf: [team-d] +--- +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: lucy.sheehan +spec: + profile: + displayName: Lucy Sheehan + email: lucy-sheehan@example.com + picture: https://example.com/staff/lucy.jpeg + memberOf: [team-d] diff --git a/packages/catalog-model/examples/all-apis.yaml b/packages/catalog-model/examples/all-apis.yaml index 3c4f7804bf..64586fdddd 100644 --- a/packages/catalog-model/examples/all-apis.yaml +++ b/packages/catalog-model/examples/all-apis.yaml @@ -6,7 +6,8 @@ metadata: spec: type: github targets: - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/hello-world-api.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/streetlights-api.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/spotify-api.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/swapi-graphql.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/apis/hello-world-api.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/apis/petstore-api.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/apis/spotify-api.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/apis/streetlights-api.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/apis/swapi-graphql.yaml diff --git a/packages/catalog-model/examples/all-components.yaml b/packages/catalog-model/examples/all-components.yaml index cad4eee044..bef38df0b1 100644 --- a/packages/catalog-model/examples/all-components.yaml +++ b/packages/catalog-model/examples/all-components.yaml @@ -6,11 +6,12 @@ metadata: spec: type: github targets: - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/podcast-api-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/queue-proxy-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/searcher-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/playback-lib-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/www-artist-component.yaml - - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/shuffle-api-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/petstore-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml + - https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml diff --git a/packages/catalog-model/examples/hello-world-api.yaml b/packages/catalog-model/examples/apis/hello-world-api.yaml similarity index 100% rename from packages/catalog-model/examples/hello-world-api.yaml rename to packages/catalog-model/examples/apis/hello-world-api.yaml diff --git a/packages/catalog-model/examples/petstore-api.yaml b/packages/catalog-model/examples/apis/petstore-api.yaml similarity index 100% rename from packages/catalog-model/examples/petstore-api.yaml rename to packages/catalog-model/examples/apis/petstore-api.yaml diff --git a/packages/catalog-model/examples/spotify-api.yaml b/packages/catalog-model/examples/apis/spotify-api.yaml similarity index 100% rename from packages/catalog-model/examples/spotify-api.yaml rename to packages/catalog-model/examples/apis/spotify-api.yaml diff --git a/packages/catalog-model/examples/streetlights-api.yaml b/packages/catalog-model/examples/apis/streetlights-api.yaml similarity index 100% rename from packages/catalog-model/examples/streetlights-api.yaml rename to packages/catalog-model/examples/apis/streetlights-api.yaml diff --git a/packages/catalog-model/examples/swapi-graphql.yaml b/packages/catalog-model/examples/apis/swapi-graphql.yaml similarity index 100% rename from packages/catalog-model/examples/swapi-graphql.yaml rename to packages/catalog-model/examples/apis/swapi-graphql.yaml diff --git a/packages/catalog-model/examples/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml similarity index 100% rename from packages/catalog-model/examples/artist-lookup-component.yaml rename to packages/catalog-model/examples/components/artist-lookup-component.yaml diff --git a/packages/catalog-model/examples/petstore-component.yaml b/packages/catalog-model/examples/components/petstore-component.yaml similarity index 100% rename from packages/catalog-model/examples/petstore-component.yaml rename to packages/catalog-model/examples/components/petstore-component.yaml diff --git a/packages/catalog-model/examples/playback-lib-component.yaml b/packages/catalog-model/examples/components/playback-lib-component.yaml similarity index 100% rename from packages/catalog-model/examples/playback-lib-component.yaml rename to packages/catalog-model/examples/components/playback-lib-component.yaml diff --git a/packages/catalog-model/examples/playback-order-component.yaml b/packages/catalog-model/examples/components/playback-order-component.yaml similarity index 100% rename from packages/catalog-model/examples/playback-order-component.yaml rename to packages/catalog-model/examples/components/playback-order-component.yaml diff --git a/packages/catalog-model/examples/podcast-api-component.yaml b/packages/catalog-model/examples/components/podcast-api-component.yaml similarity index 100% rename from packages/catalog-model/examples/podcast-api-component.yaml rename to packages/catalog-model/examples/components/podcast-api-component.yaml diff --git a/packages/catalog-model/examples/queue-proxy-component.yaml b/packages/catalog-model/examples/components/queue-proxy-component.yaml similarity index 100% rename from packages/catalog-model/examples/queue-proxy-component.yaml rename to packages/catalog-model/examples/components/queue-proxy-component.yaml diff --git a/packages/catalog-model/examples/searcher-component.yaml b/packages/catalog-model/examples/components/searcher-component.yaml similarity index 100% rename from packages/catalog-model/examples/searcher-component.yaml rename to packages/catalog-model/examples/components/searcher-component.yaml diff --git a/packages/catalog-model/examples/shuffle-api-component.yaml b/packages/catalog-model/examples/components/shuffle-api-component.yaml similarity index 100% rename from packages/catalog-model/examples/shuffle-api-component.yaml rename to packages/catalog-model/examples/components/shuffle-api-component.yaml diff --git a/packages/catalog-model/examples/www-artist-component.yaml b/packages/catalog-model/examples/components/www-artist-component.yaml similarity index 100% rename from packages/catalog-model/examples/www-artist-component.yaml rename to packages/catalog-model/examples/components/www-artist-component.yaml diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index 9e0aec7de6..ac43137546 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -15,6 +15,7 @@ */ import { JsonObject } from '@backstage/config'; +import { EntityName } from '../types'; /** * The format envelope that's common to all versions/kinds of entity. @@ -42,6 +43,11 @@ export type Entity = { * The specification data describing the entity itself. */ spec?: JsonObject; + + /** + * The relations that this entity has with other entities. + */ + relations?: EntityRelation[]; }; /** @@ -120,3 +126,38 @@ export type EntityMeta = JsonObject & { */ tags?: string[]; }; + +/** + * A relation of a specific type to another entity in the catalog. + */ +export type EntityRelation = { + /** + * The type of the relation. + */ + type: string; + + /** + * The target entity of this relation. + */ + target: EntityName; +}; + +/** + * Holds the relation data for entities. + */ +export type EntityRelationSpec = { + /** + * The source entity of this relation. + */ + source: EntityName; + + /** + * The type of the relation. + */ + type: string; + + /** + * The target entity of this relation. + */ + target: EntityName; +}; diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index 759a94ca67..d450119f0a 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -18,7 +18,12 @@ export { ENTITY_DEFAULT_NAMESPACE, ENTITY_META_GENERATED_FIELDS, } from './constants'; -export type { Entity, EntityMeta } from './Entity'; +export type { + Entity, + EntityMeta, + EntityRelation, + EntityRelationSpec, +} from './Entity'; export * from './policies'; export { getEntityName, diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index 20a3724079..7193c1a89b 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -15,7 +15,12 @@ */ import { EntityPolicy } from '../../types'; -import { makeValidator, Validators } from '../../validation'; +import { + CommonValidatorFunctions, + KubernetesValidatorFunctions, + makeValidator, + Validators, +} from '../../validation'; import { Entity } from '../Entity'; /** @@ -50,7 +55,47 @@ export class FieldFormatEntityPolicy implements EntityPolicy { } if (!isValid) { - throw new Error(`${field} "${value}" is not valid`); + let expectation; + switch ( + validator.name as + | keyof typeof KubernetesValidatorFunctions + | keyof typeof CommonValidatorFunctions + ) { + case 'isValidLabelValue': + case 'isValidObjectName': + expectation = + 'a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total'; + break; + case 'isValidLabelKey': + case 'isValidApiVersion': + case 'isValidAnnotationKey': + expectation = 'a valid prefix and/or suffix'; + break; + case 'isValidNamespace': + case 'isValidDnsLabel': + expectation = + 'a string that is sequences of [a-zA-Z0-9] separated by [-], at most 63 characters in total'; + break; + case 'isValidAnnotationValue': + expectation = 'a string'; + break; + case 'isValidKind': + expectation = + 'a string that is a sequence of [a-zA-Z][a-z0-9A-Z], at most 63 characters in total'; + break; + default: + expectation = undefined; + break; + } + + // ensure that if there are other/future validators, the error message defaults to a general "is not valid, visit link" + const message = expectation + ? ` expected ${expectation} but found "${value}".` + : ''; + + throw new Error( + `"${field}" is not valid;${message} To learn more about catalog file format, visit: https://github.com/spotify/backstage/blob/master/docs/architecture-decisions/adr002-default-catalog-file-format.md`, + ); } } diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 6df4f1212a..4a3faf977b 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -44,3 +44,4 @@ export type { UserEntityV1alpha1 as UserEntity, UserEntityV1alpha1, } from './UserEntityV1alpha1'; +export * from './relations'; diff --git a/packages/catalog-model/src/kinds/relations.ts b/packages/catalog-model/src/kinds/relations.ts new file mode 100644 index 0000000000..846313ec8c --- /dev/null +++ b/packages/catalog-model/src/kinds/relations.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* +Naming rules for relations in priority order: + +1. Use at most two words. One main verb and a specifier, e.g. "ownerOf" +2. Reading out " " should make sense in English. +3. Maintain symmetry between pairs, e.g. "ownedBy" and "ownerOf" rather than "owns". +*/ + +/** + * An ownership relation where the owner is usually an organizational + * entity (user or group), and the other entity can be anything. + */ +export const RELATION_OWNED_BY = 'ownedBy'; +export const RELATION_OWNER_OF = 'ownerOf'; + +/** + * A relation with an API entity, typically from a component or system + */ +export const RELATION_CONSUMES_API = 'consumesApi'; +export const RELATION_PROVIDES_API = 'providesApi'; + +/** + * A relation denoting a dependency on another entity. + */ +export const RELATION_DEPENDS_ON = 'dependsOn'; +export const RELATION_DEPENDENCY_OF = 'dependencyOf'; + +/** + * A parent/child relation to build up a tree, used for example to describe + * the organizational structure between groups. + */ +export const RELATION_PARENT_OF = 'parentOf'; +export const RELATION_CHILD_OF = 'childOf'; + +/** + * A membership relation, typically for users in a group. + */ +export const RELATION_MEMBER_OF = 'memberOf'; +export const RELATION_HAS_MEMBER = 'hasMember'; diff --git a/packages/catalog-model/src/location/types.ts b/packages/catalog-model/src/location/types.ts index 3c13393c06..33e443e04f 100644 --- a/packages/catalog-model/src/location/types.ts +++ b/packages/catalog-model/src/location/types.ts @@ -18,8 +18,9 @@ export type LocationSpec = { type: string; target: string; // When using repo importer plugin, location is being created before the component yaml file is merged to the main branch. - // This flag is then set to disable validation that prevents creation of location if target file does not yet exist. - pendingLocation?: boolean; + // This flag is then set to indicate that the file can be not present. + // default value: 'required'. + presence?: 'optional' | 'required'; }; export type Location = { diff --git a/packages/catalog-model/src/location/validation.ts b/packages/catalog-model/src/location/validation.ts index 93ac4d8d18..27d74b9a82 100644 --- a/packages/catalog-model/src/location/validation.ts +++ b/packages/catalog-model/src/location/validation.ts @@ -21,7 +21,7 @@ export const locationSpecSchema = yup .object({ type: yup.string().required(), target: yup.string().required(), - pendingLocation: yup.bool(), + presence: yup.string(), }) .noUnknown() .required(); diff --git a/packages/cli/package.json b/packages/cli/package.json index dd9e82c325..d6e73b41fa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,7 +36,7 @@ "@lerna/project": "^3.18.0", "@rollup/plugin-commonjs": "^13.0.0", "@rollup/plugin-json": "^4.0.2", - "@rollup/plugin-node-resolve": "^8.1.0", + "@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-yaml": "^2.1.1", "@spotify/eslint-config-base": "^8.0.0", "@spotify/eslint-config-react": "^8.0.0", @@ -114,6 +114,7 @@ "@types/http-proxy": "^1.17.4", "@types/inquirer": "^7.3.1", "@types/mini-css-extract-plugin": "^0.9.1", + "@types/mock-fs": "^4.13.0", "@types/node": "^13.7.2", "@types/ora": "^3.2.0", "@types/react-dev-utils": "^9.0.4", diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts index ae67616515..c5f5f5aa2f 100644 --- a/packages/cli/src/commands/app/build.ts +++ b/packages/cli/src/commands/app/build.ts @@ -15,22 +15,15 @@ */ import { Command } from 'commander'; -import { loadConfig } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; -import { paths } from '../../lib/paths'; import { buildBundle } from '../../lib/bundler'; import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; +import { loadCliConfig } from '../../lib/config'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production', - rootPaths: [paths.targetRoot, paths.targetDir], - }); await buildBundle({ entry: 'src/index', parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), statsJsonEnabled: cmd.stats, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, + ...(await loadCliConfig(cmd.config)), }); }; diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts index 031214c264..c3f58774b5 100644 --- a/packages/cli/src/commands/app/serve.ts +++ b/packages/cli/src/commands/app/serve.ts @@ -15,21 +15,14 @@ */ import { Command } from 'commander'; -import { loadConfig } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; -import { paths } from '../../lib/paths'; import { serveBundle } from '../../lib/bundler'; +import { loadCliConfig } from '../../lib/config'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development', - rootPaths: [paths.targetRoot, paths.targetDir], - }); const waitForExit = await serveBundle({ entry: 'src/index', checksEnabled: cmd.check, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, + ...(await loadCliConfig(cmd.config)), }); await waitForExit(); diff --git a/packages/cli/src/commands/backend/dev.ts b/packages/cli/src/commands/backend/dev.ts index ca76f19da3..88c069a92b 100644 --- a/packages/cli/src/commands/backend/dev.ts +++ b/packages/cli/src/commands/backend/dev.ts @@ -14,24 +14,14 @@ * limitations under the License. */ -import { ConfigReader } from '@backstage/config'; -import { loadConfig } from '@backstage/config-loader'; import { Command } from 'commander'; -import { paths } from '../../lib/paths'; import { serveBackend } from '../../lib/bundler/backend'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development', - rootPaths: [paths.targetRoot, paths.targetDir], - }); - const waitForExit = await serveBackend({ entry: 'src/index', checksEnabled: cmd.check, inspectEnabled: cmd.inspect, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, }); await waitForExit(); diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts index 526f4d70b1..eb814a5bee 100644 --- a/packages/cli/src/commands/config/print.ts +++ b/packages/cli/src/commands/config/print.ts @@ -15,20 +15,13 @@ */ import { Command } from 'commander'; -import { loadConfig } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; -import { paths } from '../../lib/paths'; import { stringify as stringifyYaml } from 'yaml'; +import { loadCliConfig } from '../../lib/config'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: - cmd.env ?? process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development', - shouldReadSecrets: cmd.withSecrets ?? false, - rootPaths: [paths.targetRoot, paths.targetDir], - }); + const { config } = await loadCliConfig(cmd.config, cmd.withSecrets ?? false); - const flatConfig = ConfigReader.fromConfigs(appConfigs).get(); + const flatConfig = config.get(); if (cmd.format === 'json') { process.stdout.write(`${JSON.stringify(flatConfig, null, 2)}\n`); diff --git a/packages/cli/src/commands/create-plugin/createPlugin.test.ts b/packages/cli/src/commands/create-plugin/createPlugin.test.ts index 72b95b072c..a08bb0da87 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.test.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.test.ts @@ -16,14 +16,20 @@ import fs from 'fs-extra'; import path from 'path'; +import mockFs from 'mock-fs'; import os from 'os'; import del from 'del'; import { createTemporaryPluginFolder, movePlugin } from './createPlugin'; +const id = 'testPluginMock'; + describe('createPlugin', () => { + afterAll(() => { + mockFs.restore(); + }); + describe('createPluginFolder', () => { it('should create a temporary plugin directory in the correct place', async () => { - const id = 'testPlugin'; const tempDir = path.join(os.tmpdir(), id); try { await createTemporaryPluginFolder(tempDir); @@ -35,35 +41,27 @@ describe('createPlugin', () => { }); it('should not create a temporary plugin directory if it already exists', async () => { - const id = 'testPlugin'; - const tempDir = path.join(os.tmpdir(), id); - try { - await createTemporaryPluginFolder(tempDir); - await expect(fs.pathExists(tempDir)).resolves.toBe(true); - await expect(createTemporaryPluginFolder(tempDir)).rejects.toThrow( - /Failed to create temporary plugin directory/, - ); - } finally { - await del(tempDir, { force: true }); - } + mockFs({ + [id]: {}, + }); + + await expect(createTemporaryPluginFolder(id)).rejects.toThrow( + /Failed to create temporary plugin directory/, + ); }); }); describe('movePlugin', () => { it('should move the temporary plugin directory to its final place', async () => { - const id = 'testPlugin'; - const tempDir = path.join(os.tmpdir(), id); - const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-')); - const pluginDir = path.join(rootDir, 'plugins', id); - try { - await createTemporaryPluginFolder(tempDir); - await movePlugin(tempDir, pluginDir, id); - await expect(fs.pathExists(pluginDir)).resolves.toBe(true); - expect(pluginDir).toMatch(path.join('', 'plugins', id)); - } finally { - await del(tempDir, { force: true }); - await del(rootDir, { force: true }); - } + mockFs({ + [id]: {}, + }); + const tempDir = id; + const pluginDir = path.join('test-temp', 'plugins', id); + + await movePlugin(tempDir, pluginDir, id); + await expect(fs.pathExists(pluginDir)).resolves.toBe(true); + expect(pluginDir).toMatch(path.join('', 'plugins', id)); }); }); }); diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index e5017b829a..7f028919f7 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -18,16 +18,25 @@ import { CommanderStatic } from 'commander'; import { exitWithError } from '../lib/errors'; export function registerCommands(program: CommanderStatic) { + const configOption = [ + '--config ', + 'Config files to load instead of app-config.yaml', + (opt: string, opts: string[]) => [...opts, opt], + Array(), + ] as const; + program .command('app:build') .description('Build an app for a production release') .option('--stats', 'Write bundle stats to output directory') + .option(...configOption) .action(lazy(() => import('./app/build').then(m => m.default))); program .command('app:serve') .description('Serve an app for local development') .option('--check', 'Enable type checking and linting') + .option(...configOption) .action(lazy(() => import('./app/serve').then(m => m.default))); program @@ -50,6 +59,8 @@ export function registerCommands(program: CommanderStatic) { .description('Start local development server with HMR for the backend') .option('--check', 'Enable type checking and linting') .option('--inspect', 'Enable debugger') + // We don't actually use the config in the CLI, just pass them on to the NodeJS process + .option(...configOption) .action(lazy(() => import('./backend/dev').then(m => m.default))); program @@ -89,12 +100,14 @@ export function registerCommands(program: CommanderStatic) { .command('plugin:serve') .description('Serves the dev/ folder of a plugin') .option('--check', 'Enable type checking and linting') + .option(...configOption) .action(lazy(() => import('./plugin/serve').then(m => m.default))); program .command('plugin:export') .description('Exports the dev/ folder of a plugin') .option('--stats', 'Write bundle stats to output directory') + .option(...configOption) .action(lazy(() => import('./plugin/export').then(m => m.default))); program @@ -131,14 +144,11 @@ export function registerCommands(program: CommanderStatic) { program .command('config:print') .option('--with-secrets', 'Include secrets in the printed configuration') - .option( - '--env ', - 'The environment to print configuration for [APP_ENV or NODE_ENV or development]', - ) .option( '--format ', 'Format to print the configuration in, either json or yaml [yaml]', ) + .option(...configOption) .description('Print the app configuration for the current package') .action(lazy(() => import('./config/print').then(m => m.default))); diff --git a/packages/cli/src/commands/plugin/export.ts b/packages/cli/src/commands/plugin/export.ts index 4fe9cb3c03..69955a2e5f 100644 --- a/packages/cli/src/commands/plugin/export.ts +++ b/packages/cli/src/commands/plugin/export.ts @@ -15,20 +15,13 @@ */ import { Command } from 'commander'; -import { loadConfig } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; -import { paths } from '../../lib/paths'; import { buildBundle } from '../../lib/bundler'; +import { loadCliConfig } from '../../lib/config'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production', - rootPaths: [paths.targetRoot, paths.targetDir], - }); await buildBundle({ entry: 'dev/index', statsJsonEnabled: cmd.stats, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, + ...(await loadCliConfig(cmd.config)), }); }; diff --git a/packages/cli/src/commands/plugin/serve.ts b/packages/cli/src/commands/plugin/serve.ts index bae63f2fda..cb9def3158 100644 --- a/packages/cli/src/commands/plugin/serve.ts +++ b/packages/cli/src/commands/plugin/serve.ts @@ -15,21 +15,14 @@ */ import { Command } from 'commander'; -import { loadConfig } from '@backstage/config-loader'; -import { ConfigReader } from '@backstage/config'; -import { paths } from '../../lib/paths'; import { serveBundle } from '../../lib/bundler'; +import { loadCliConfig } from '../../lib/config'; export default async (cmd: Command) => { - const appConfigs = await loadConfig({ - env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development', - rootPaths: [paths.targetRoot, paths.targetDir], - }); const waitForExit = await serveBundle({ entry: 'dev/index', checksEnabled: cmd.check, - config: ConfigReader.fromConfigs(appConfigs), - appConfigs, + ...(await loadCliConfig(cmd.config)), }); await waitForExit(); diff --git a/packages/cli/src/commands/remove-plugin/file-mocks.ts b/packages/cli/src/commands/remove-plugin/file-mocks.ts new file mode 100644 index 0000000000..5768f5c398 --- /dev/null +++ b/packages/cli/src/commands/remove-plugin/file-mocks.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const pluginsFileContent = ` +export { plugin as WelcomePlugin } from '@backstage/plugin-welcome'; +export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';`; + +export const codeownersFileContent = ` +* @spotify/backstage-core +/docs/features/techdocs @spotify/techdocs-core +/plugins/cost-insights @spotify/silver-lining +`; + +export const packageFileContent = { + name: 'example-app', + version: '0.1.1', + dependencies: {}, + devDependencies: {}, + scripts: {}, +}; diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index a5e2af5974..56cde2ca9e 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -16,13 +16,9 @@ import fse from 'fs-extra'; import path from 'path'; -import os from 'os'; +import mockFs from 'mock-fs'; import { paths } from '../../lib/paths'; -import { - addExportStatement, - capitalize, - createTemporaryPluginFolder, -} from '../create-plugin/createPlugin'; +import { addExportStatement, capitalize } from '../create-plugin/createPlugin'; import { addCodeownersEntry } from '../../lib/codeowners'; import { removeReferencesFromAppPackage, @@ -31,124 +27,162 @@ import { removeSymLink, removePluginFromCodeOwners, } from './removePlugin'; +import { + codeownersFileContent, + packageFileContent, + pluginsFileContent, +} from './file-mocks'; const BACKSTAGE = `@backstage`; const testPluginName = 'yarn-test-package'; const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; -const tempDir = path.join(os.tmpdir(), 'remove-plugin-test'); +const tempDir = '/remove-plugin-test'; const removeEmptyLines = (file: string): string => file.split(/\r?\n/).filter(Boolean).join('\n'); -const createTestPackageFile = async ( - testFilePath: string, - packageFile: string, -) => { - // Copy contents of package file for test - const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8')); +const createTestPackageFile = async (testFilePath: string) => { + const testFileContent = { + ...packageFileContent, + dependencies: { + ...packageFileContent.dependencies, + [testPluginPackage]: '0.1.0', + }, + }; - packageFileContent.dependencies[testPluginPackage] = '0.1.0'; - fse.createFileSync(testFilePath); - fse.writeFileSync( - testFilePath, - `${JSON.stringify(packageFileContent, null, 2)}\n`, - 'utf8', - ); + mockFs({ + packages: { + app: { + 'package.json': `${JSON.stringify(packageFileContent, null, 2)}\n`, + }, + }, + [tempDir]: { + [testFilePath]: `${JSON.stringify(testFileContent, null, 2)}\n`, + }, + }); return; }; const createTestPluginFile = async ( - testFilePath: string, - pluginsFilePath: string, + testFileName: string, + pluginsFileName: string, ) => { - // Copy contents of package file for test - fse.copyFileSync(pluginsFilePath, testFilePath); + mockFs({ + [tempDir]: { + [testFileName]: `${pluginsFileContent}\n`, + [pluginsFileName]: `${pluginsFileContent}\n`, + }, + packages: { + app: { + src: { + 'plugin.ts': `${pluginsFileContent}\n`, + }, + }, + }, + }); + const pluginNameCapitalized = testPluginName .split('-') .map(name => capitalize(name)) .join(''); - const exportStatement = `export { plugin as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; - await addExportStatement(testFilePath, exportStatement); + const exportStatement = `export { plugin as ${pluginNameCapitalized}} from ${testPluginPackage}`; + await addExportStatement(path.join(tempDir, testFileName), exportStatement); }; const mkTestPluginDir = (testDirPath: string) => { - fse.mkdirSync(testDirPath); - for (let i = 0; i < 50; i++) - fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); + const pluginFiles: { [index: string]: string } = {}; + for (let i = 0; i < 50; i++) { + pluginFiles[`testFile${i}.ts`] = ''; + } + + mockFs({ + [testDirPath]: pluginFiles, + }); }; describe('removePlugin', () => { beforeAll(() => { // Create temporary directory for all tests - createTemporaryPluginFolder(tempDir); + mockFs({ + [tempDir]: { + 'package.json': packageFileContent, + src: { + 'plugin.ts': pluginsFileContent, + }, + }, + }); }); afterAll(() => { - // Remove temporary directory - fse.removeSync(tempDir); + mockFs.restore(); }); describe('Remove Plugin Dependencies', () => { - const appPath = paths.resolveTargetRoot('packages', 'app'); - const githubDir = paths.resolveTargetRoot('.github'); - it('removes plugin references from /packages/app/package.json', async () => { // Set up test - const packageFilePath = path.join(appPath, 'package.json'); - const testFilePath = path.join(tempDir, 'test.json'); - createTestPackageFile(testFilePath, packageFilePath); - try { - await removeReferencesFromAppPackage(testFilePath, testPluginName); - const testFileContent = removeEmptyLines( - fse.readFileSync(testFilePath, 'utf8'), - ); - const packageFileContent = removeEmptyLines( - fse.readFileSync(packageFilePath, 'utf8'), - ); - expect(testFileContent).toBe(packageFileContent); - } finally { - fse.removeSync(testFilePath); - } - }); + const testFilePath = 'test.json'; + createTestPackageFile(testFilePath); + await removeReferencesFromAppPackage( + path.join(tempDir, testFilePath), + testPluginName, + ); + const testFileContent = removeEmptyLines( + fse.readFileSync(path.join(tempDir, testFilePath), 'utf8'), + ); + const mockedPackageFileContent = removeEmptyLines( + fse.readFileSync(path.join('packages', 'app', 'package.json'), 'utf8'), + ); + expect(testFileContent).toBe(mockedPackageFileContent); + }); it('removes plugin exports from /packages/app/src/package.json', async () => { - const testFilePath = path.join(tempDir, 'test.ts'); - const pluginsFilePaths = path.join(appPath, 'src', 'plugins.ts'); - createTestPluginFile(testFilePath, pluginsFilePaths); - try { - await removeReferencesFromPluginsFile(testFilePath, testPluginName); - const testFileContent = removeEmptyLines( - fse.readFileSync(testFilePath, 'utf8'), - ); - const pluginsFileContent = removeEmptyLines( - fse.readFileSync(pluginsFilePaths, 'utf8'), - ); - expect(testFileContent).toBe(pluginsFileContent); - } finally { - fse.removeSync(testFilePath); - } + const testFileName = 'test.ts'; + const pluginsFileName = 'plugin.ts'; + createTestPluginFile(testFileName, pluginsFileName); + await removeReferencesFromPluginsFile( + path.join(tempDir, testFileName), + testPluginName, + ); + const testFileContent = removeEmptyLines( + fse.readFileSync(path.join(tempDir, testFileName), 'utf8'), + ); + const mockedPluginsFileContent = removeEmptyLines( + fse.readFileSync( + path.join('packages', 'app', 'src', pluginsFileName), + 'utf8', + ), + ); + expect(testFileContent).toBe(mockedPluginsFileContent); }); it('removes codeOwners references', async () => { - const testFilePath = path.join(tempDir, 'test'); - const codeownersPath = path.join(githubDir, 'CODEOWNERS'); - try { - fse.copySync(codeownersPath, testFilePath); - const testFileContent = removeEmptyLines( - fse.readFileSync(testFilePath, 'utf8'), - ); - const codeOwnersFileContent = removeEmptyLines( - fse.readFileSync(codeownersPath, 'utf8'), - ); - await addCodeownersEntry(testFilePath!, `/plugins/${testPluginName}`, [ - '@thisIsAtestTeam', - 'test@gmail.com', - ]); - await removePluginFromCodeOwners(testFilePath, testPluginName); - expect(testFileContent).toBe(codeOwnersFileContent); - } finally { - if (fse.existsSync(testFilePath)) fse.removeSync(testFilePath); - } + const testFileName = 'test'; + const testFilePath = path.join(tempDir, testFileName); + + const mockedCodeownersPath = path.join('.github', 'CODEOWNERS'); + + mockFs({ + [tempDir]: { + [testFileName]: '', + }, + '.github': { + CODEOWNERS: codeownersFileContent, + }, + }); + fse.copySync(mockedCodeownersPath, testFilePath); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const codeOwnersFileContent = removeEmptyLines( + fse.readFileSync(mockedCodeownersPath, 'utf8'), + ); + await addCodeownersEntry( + testFilePath!, + path.join('plugins', testPluginName), + ['@thisIsAtestTeam', 'test@gmail.com'], + ); + await removePluginFromCodeOwners(testFilePath, testPluginName); + expect(testFileContent).toBe(codeOwnersFileContent); }); }); @@ -161,34 +195,40 @@ describe('removePlugin', () => { describe('Removes Plugin Directory', () => { it('removes plugin directory from /plugins', async () => { - try { - mkTestPluginDir(testDirPath); - expect(fse.existsSync(testDirPath)).toBeTruthy(); - await removePluginDirectory(testDirPath); - expect(fse.existsSync(testDirPath)).toBeFalsy(); - } finally { - if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); - } + mkTestPluginDir(testDirPath); + expect(fse.existsSync(testDirPath)).toBeTruthy(); + await removePluginDirectory(testDirPath); + expect(fse.existsSync(testDirPath)).toBeFalsy(); }); }); describe('Removes System Link', () => { it('removes system link from @backstage', async () => { - const scopedDir = paths.resolveTargetRoot('node_modules', '@backstage'); + const symLink = `plugin-${testPluginName}`; const testSymLinkPath = path.join( - scopedDir, - `plugin-${testPluginName}`, + '/', + 'node_modules', + '@backstage', + symLink, ); - try { - mkTestPluginDir(testDirPath); - fse.ensureSymlinkSync(testSymLinkPath, testDirPath); + const mockedTestDirPath = path.join('/', 'plugins', testPluginName); - await removeSymLink(testSymLinkPath); - expect(fse.existsSync(testSymLinkPath)).toBeFalsy(); - } finally { - if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); - if (fse.existsSync(testSymLinkPath)) fse.removeSync(testSymLinkPath); - } + mockFs({ + '/plugins': { + [testPluginName]: {}, + }, + '/node_modules': { + '@backstage': { + [symLink]: mockFs.symlink({ + path: mockedTestDirPath, + }), + }, + }, + }); + + expect(fse.existsSync(testSymLinkPath)).toBeTruthy(); + await removeSymLink(testSymLinkPath); + expect(fse.existsSync(testSymLinkPath)).toBeFalsy(); }); }); }); diff --git a/packages/cli/src/lib/bundler/backend.ts b/packages/cli/src/lib/bundler/backend.ts index e3cc8af8d1..9633c1b963 100644 --- a/packages/cli/src/lib/bundler/backend.ts +++ b/packages/cli/src/lib/bundler/backend.ts @@ -17,13 +17,9 @@ import webpack from 'webpack'; import { createBackendConfig } from './config'; import { resolveBundlingPaths } from './paths'; -import { ServeOptions } from './types'; +import { BackendServeOptions } from './types'; -export async function serveBackend( - options: ServeOptions & { - inspectEnabled: boolean; - }, -) { +export async function serveBackend(options: BackendServeOptions) { const paths = resolveBundlingPaths(options); const config = await createBackendConfig(paths, { ...options, diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 827d517fef..00f1895725 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -27,10 +27,6 @@ export type BundlingOptions = { parallel?: ParallelOption; }; -export type BackendBundlingOptions = Omit & { - inspectEnabled: boolean; -}; - export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; config: Config; @@ -43,3 +39,15 @@ export type BuildOptions = BundlingPathsOptions & { config: Config; appConfigs: AppConfig[]; }; + +export type BackendBundlingOptions = { + checksEnabled: boolean; + isDev: boolean; + parallel?: ParallelOption; + inspectEnabled: boolean; +}; + +export type BackendServeOptions = BundlingPathsOptions & { + checksEnabled: boolean; + inspectEnabled: boolean; +}; diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts new file mode 100644 index 0000000000..5801632ea5 --- /dev/null +++ b/packages/cli/src/lib/config.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { loadConfig } from '@backstage/config-loader'; +import { ConfigReader } from '@backstage/config'; +import { paths } from './paths'; + +export async function loadCliConfig( + configArgs: string[], + shouldReadSecrets: boolean = false, +) { + const configPaths = configArgs.map(arg => paths.resolveTarget(arg)); + + const appConfigs = await loadConfig({ + shouldReadSecrets, + env: process.env.APP_ENV ?? process.env.NODE_ENV ?? 'production', + configRoot: paths.targetRoot, + configPaths, + }); + + console.log( + `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`, + ); + + return { + appConfigs, + config: ConfigReader.fromConfigs(appConfigs), + }; +} diff --git a/packages/cli/src/lib/tasks.test.ts b/packages/cli/src/lib/tasks.test.ts index 871d5a4d37..d80fcbe9b0 100644 --- a/packages/cli/src/lib/tasks.test.ts +++ b/packages/cli/src/lib/tasks.test.ts @@ -15,39 +15,45 @@ */ import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { resolve as resolvePath } from 'path'; -import os from 'os'; -import del from 'del'; import { templatingTask } from './tasks'; describe('templatingTask', () => { + afterEach(() => { + mockFs.restore(); + }); + it('should template a directory with mix of regular files and templates', async () => { - // Set up a testing template directory - const tmplDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-')); - await fs.ensureDir(resolvePath(tmplDir, 'sub')); - await fs.writeFile(resolvePath(tmplDir, 'test.txt'), 'testing'); - await fs.writeFile( - resolvePath(tmplDir, 'sub/version.txt.hbs'), - 'version: {{version}}', - ); + // Testing template directory + const tmplDir = 'test-tmpl'; - // Set up a temporary dest dir to write the template to - const destDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'test-')); + // Temporary dest dir to write the template to + const destDir = 'test-dest'; - try { - await templatingTask(tmplDir, destDir, { - version: '0.0.0', - }); + // Files content + const testFileContent = 'testing'; + const testVersionFileContent = 'version: {{version}}'; - await expect( - fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'), - ).resolves.toBe('testing'); - await expect( - fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'), - ).resolves.toBe('version: 0.0.0'); - } finally { - await del(tmplDir, { force: true }); - await del(destDir, { force: true }); - } + mockFs({ + [tmplDir]: { + sub: { + 'version.txt.hbs': testVersionFileContent, + }, + 'test.txt': testFileContent, + }, + [destDir]: {}, + }); + + await templatingTask(tmplDir, destDir, { + version: '0.0.0', + }); + + await expect( + fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'), + ).resolves.toBe(testFileContent); + await expect( + fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'), + ).resolves.toBe('version: 0.0.0'); }); }); diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index 822ce584a5..1a73515622 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -29,14 +29,14 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "winston": "^3.2.1", - "node-fetch": "^2.6.1", + "cross-fetch": "^3.0.6", "yn": "^4.0.0" }, "devDependencies": { "@backstage/cli": "^{{backstageVersion}}", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", - "msw": "^0.20.5" + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/packages/cli/templates/default-backend-plugin/src/setupTests.ts b/packages/cli/templates/default-backend-plugin/src/setupTests.ts index a5907fd52f..ba33cf996b 100644 --- a/packages/cli/templates/default-backend-plugin/src/setupTests.ts +++ b/packages/cli/templates/default-backend-plugin/src/setupTests.ts @@ -15,4 +15,3 @@ */ export {}; -global.fetch = require('node-fetch'); diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index e024884547..41f942fb79 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -36,13 +36,14 @@ "devDependencies": { "@backstage/cli": "^{{backstageVersion}}", "@backstage/dev-utils": "^{{backstageVersion}}", + "@backstage/test-utils": "^{{backstageVersion}}", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs index fdb39444d8..e805900f36 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs @@ -5,18 +5,13 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; describe('ExampleComponent', () => { const server = setupServer(); - // Enable API mocking before tests. - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) - - // Reset any runtime request handlers we may add during the tests. - afterEach(() => server.resetHandlers()) - - // Disable API mocking after the tests are done. - afterAll(() => server.close()) + // Enable sane handlers for network requests + msw.setupDefaultHandlers(server); // setup mock response beforeEach(() => { diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs index ca1990b4bc..81e1b4be09 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs @@ -3,18 +3,13 @@ import { render } from '@testing-library/react'; import ExampleFetchComponent from './ExampleFetchComponent'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; describe('ExampleFetchComponent', () => { const server = setupServer(); - // Enable API mocking before tests. - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) - - // Reset any runtime request handlers we may add during the tests. - afterEach(() => server.resetHandlers()) - - // Disable API mocking after the tests are done. - afterAll(() => server.close()) - + // Enable sane handlers for network requests + msw.setupDefaultHandlers(server); + // setup mock response beforeEach(() => { server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({})))) diff --git a/packages/cli/templates/default-plugin/src/setupTests.ts b/packages/cli/templates/default-plugin/src/setupTests.ts index cc559f672e..292b0cc471 100644 --- a/packages/cli/templates/default-plugin/src/setupTests.ts +++ b/packages/cli/templates/default-plugin/src/setupTests.ts @@ -1,2 +1,2 @@ import '@testing-library/jest-dom'; -global.fetch = require('node-fetch'); +import 'cross-fetch/polyfill' diff --git a/packages/config-loader/src/lib/index.ts b/packages/config-loader/src/lib/index.ts index 40252b9cec..1b5ad2ef50 100644 --- a/packages/config-loader/src/lib/index.ts +++ b/packages/config-loader/src/lib/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export { resolveStaticConfig } from './resolver'; export { readConfigFile } from './reader'; export { readEnvConfig } from './env'; export { readSecret } from './secrets'; diff --git a/packages/config-loader/src/lib/resolver.test.ts b/packages/config-loader/src/lib/resolver.test.ts deleted file mode 100644 index 9fd71f2486..0000000000 --- a/packages/config-loader/src/lib/resolver.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import mockFs from 'mock-fs'; -import { resolveStaticConfig } from './resolver'; - -function normalizePaths(paths: string[]) { - return paths.map(p => - p - .replace(/^[a-z]:/i, '') - .split('\\') - .join('/'), - ); -} - -describe('resolveStaticConfig', () => { - afterEach(() => { - mockFs.restore(); - }); - - it('should resolve no files for empty roots', async () => { - mockFs({}); - const resolved = await resolveStaticConfig({ - env: 'development', - rootPaths: [], - }); - - expect(normalizePaths(resolved)).toEqual([]); - }); - - it('should resolve a single app-config', async () => { - mockFs({ '/repo/app-config.yaml': '' }); - const resolved = await resolveStaticConfig({ - env: 'development', - rootPaths: ['/repo'], - }); - - expect(normalizePaths(resolved)).toEqual(['/repo/app-config.yaml']); - }); - - it('should resolve a app-configs in different directories', async () => { - mockFs({ - '/repo/app-config.yaml': '', - '/repo/packages/a/app-config.yaml': '', - }); - const resolved = await resolveStaticConfig({ - env: 'development', - rootPaths: [ - '/repo', - '/other-repo', - '/repo/packages/a', - '/repo/packages/b', - ], - }); - - expect(normalizePaths(resolved)).toEqual([ - '/repo/app-config.yaml', - '/repo/packages/a/app-config.yaml', - ]); - }); - - it('should resolve env and local configs', async () => { - mockFs({ - '/repo/app-config.yaml': '', - '/repo/app-config.local.yaml': '', - '/repo/app-config.production.yaml': '', - '/repo/app-config.production.local.yaml': '', - '/repo/app-config.development.local.yaml': '', - '/repo/packages/a/app-config.development.yaml': '', - '/repo/packages/a/app-config.local.yaml': '', - }); - const resolved = await resolveStaticConfig({ - env: 'development', - rootPaths: ['/repo', '/repo/packages/a'], - }); - - expect(normalizePaths(resolved)).toEqual([ - '/repo/app-config.yaml', - '/repo/app-config.local.yaml', - '/repo/app-config.development.local.yaml', - '/repo/packages/a/app-config.local.yaml', - '/repo/packages/a/app-config.development.yaml', - ]); - }); - - it('resolves suffixed configs in the correct order', async () => { - mockFs({ - '/repo/app-config.yaml': '', - '/repo/app-config.local.yaml': '', - '/repo/app-config.production.yaml': '', - '/repo/app-config.production.local.yaml': '', - }); - - const resolved = await resolveStaticConfig({ - env: 'production', - rootPaths: ['/repo'], - }); - - expect(normalizePaths(resolved)).toEqual([ - '/repo/app-config.yaml', - '/repo/app-config.local.yaml', - '/repo/app-config.production.yaml', - '/repo/app-config.production.local.yaml', - ]); - }); -}); diff --git a/packages/config-loader/src/lib/resolver.ts b/packages/config-loader/src/lib/resolver.ts deleted file mode 100644 index 3856598331..0000000000 --- a/packages/config-loader/src/lib/resolver.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { resolve as resolvePath } from 'path'; -import { pathExists } from 'fs-extra'; - -type ResolveOptions = { - // Root paths to search for config files. Config from earlier paths has lower priority. - rootPaths: string[]; - // The environment that we're loading config for, e.g. 'development', 'production'. - env: string; -}; - -/** - * Resolves all configuration files that should be loaded in the given environment. - * - * For each root directory, search for the default app-config.yaml, along with suffixed - * APP_ENV and local variants, e.g. app-config.production.yaml or app-config.development.local.yaml - * - * The priority order of config loaded through suffixes is `env > local > none`, meaning that - * for example app-config.development.yaml has higher priority than `app-config.local.yaml`. - * - */ -export async function resolveStaticConfig( - options: ResolveOptions, -): Promise { - const filePaths = [ - `app-config.yaml`, - `app-config.local.yaml`, - `app-config.${options.env}.yaml`, - `app-config.${options.env}.local.yaml`, - ]; - - const resolvedPaths = []; - - for (const rootPath of options.rootPaths) { - for (const filePath of filePaths) { - const path = resolvePath(rootPath, filePath); - if (await pathExists(path)) { - resolvedPaths.push(path); - } - } - } - - return resolvedPaths; -} diff --git a/packages/config-loader/src/loader.test.ts b/packages/config-loader/src/loader.test.ts index 087e2309c0..3b0d8e6e92 100644 --- a/packages/config-loader/src/loader.test.ts +++ b/packages/config-loader/src/loader.test.ts @@ -38,10 +38,31 @@ describe('loadConfig', () => { mockFs.restore(); }); + it('load config from default path', async () => { + await expect( + loadConfig({ + configRoot: '/root', + configPaths: [], + env: 'production', + shouldReadSecrets: false, + }), + ).resolves.toEqual([ + { + context: 'app-config.yaml', + data: { + app: { + title: 'Example App', + }, + }, + }, + ]); + }); + it('loads config without secrets', async () => { await expect( loadConfig({ - rootPaths: ['/root'], + configRoot: '/root', + configPaths: ['/root/app-config.yaml'], env: 'production', shouldReadSecrets: false, }), @@ -60,7 +81,8 @@ describe('loadConfig', () => { it('loads config with secrets', async () => { await expect( loadConfig({ - rootPaths: ['/root'], + configRoot: '/root', + configPaths: ['/root/app-config.yaml'], env: 'production', shouldReadSecrets: true, }), @@ -80,7 +102,11 @@ describe('loadConfig', () => { it('loads development config without secrets', async () => { await expect( loadConfig({ - rootPaths: ['/root'], + configRoot: '/root', + configPaths: [ + '/root/app-config.yaml', + '/root/app-config.development.yaml', + ], env: 'development', shouldReadSecrets: false, }), @@ -105,7 +131,11 @@ describe('loadConfig', () => { it('loads development config with secrets', async () => { await expect( loadConfig({ - rootPaths: ['/root'], + configRoot: '/root', + configPaths: [ + '/root/app-config.yaml', + '/root/app-config.development.yaml', + ], env: 'development', shouldReadSecrets: true, }), diff --git a/packages/config-loader/src/loader.ts b/packages/config-loader/src/loader.ts index cf5edd3ce3..e7eb2a38fa 100644 --- a/packages/config-loader/src/loader.ts +++ b/packages/config-loader/src/loader.ts @@ -15,20 +15,18 @@ */ import fs from 'fs-extra'; -import { resolve as resolvePath, dirname } from 'path'; +import { resolve as resolvePath, dirname, isAbsolute } from 'path'; import { AppConfig, JsonObject } from '@backstage/config'; -import { - resolveStaticConfig, - readConfigFile, - readEnvConfig, - readSecret, -} from './lib'; +import { readConfigFile, readEnvConfig, readSecret } from './lib'; export type LoadConfigOptions = { - // Root paths to search for config files. Config from earlier paths has lower priority. - rootPaths: string[]; + // The root directory of the config loading context. Used to find default configs. + configRoot: string; - // The environment that we're loading config for, e.g. 'development', 'production'. + // Absolute paths to load config files from. Configs from earlier paths have lower priority. + configPaths: string[]; + + // TODO(Rugvip): This will be removed in the future, but for now we use it to warn about possible mistakes. env: string; // Whether to read secrets or omit them, defaults to false. @@ -77,13 +75,37 @@ export async function loadConfig( options: LoadConfigOptions, ): Promise { const configs = []; + const { configRoot } = options; + const configPaths = options.configPaths.slice(); - const configPaths = await resolveStaticConfig(options); + // If no paths are provided, we default to reading + // `app-config.yaml` and, if it exists, `app-config.local.yaml` + if (configPaths.length === 0) { + configPaths.push(resolvePath(configRoot, 'app-config.yaml')); + + const localConfig = resolvePath(configRoot, 'app-config.local.yaml'); + if (await fs.pathExists(localConfig)) { + configPaths.push(localConfig); + } + + const envFile = `app-config.${options.env}.yaml`; + if (await fs.pathExists(resolvePath(configRoot, envFile))) { + console.error( + `Env config file '${envFile}' is not loaded as APP_ENV and NODE_ENV-based config loading has been removed`, + ); + console.error( + `To load the config file, use --config , listing every config file that you want to load`, + ); + } + } try { const secretPaths = new Set(); for (const configPath of configPaths) { + if (!isAbsolute(configPath)) { + throw new Error(`Config load path is not absolute: '${configPath}'`); + } const config = await readConfigFile( configPath, new Context({ diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 5859382c5c..5b1456d1ba 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.1-alpha.25", + "@backstage/test-utils": "^0.1.1-alpha.25", "@backstage/theme": "^0.1.1-alpha.25", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -49,7 +50,8 @@ "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/zen-observable": "^0.8.0", - "jest-fetch-mock": "^3.0.3" + "cross-fetch": "^3.0.6", + "msw": "^0.21.3" }, "files": [ "dist" diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index 5781130799..391b86952d 100644 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -19,8 +19,9 @@ import { DefaultAuthConnector } from './DefaultAuthConnector'; import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi'; import * as loginPopup from '../loginPopup'; import { UrlPatternDiscovery } from '../../apis'; - -const anyFetch = fetch as any; +import { msw } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; const defaultOptions = { discoveryApi: UrlPatternDiscovery.compile('http://my-host/api/{{pluginId}}'), @@ -39,19 +40,25 @@ const defaultOptions = { }; describe('DefaultAuthConnector', () => { + const server = setupServer(); + msw.setupDefaultHandlers(server); + afterEach(() => { jest.resetAllMocks(); - anyFetch.resetMocks(); }); it('should refresh a session', async () => { - anyFetch.mockResponseOnce( - JSON.stringify({ - idToken: 'mock-id-token', - accessToken: 'mock-access-token', - scopes: 'a b c', - expiresInSeconds: '60', - }), + server.use( + rest.get('*', (_req, res, ctx) => + res( + ctx.json({ + idToken: 'mock-id-token', + accessToken: 'mock-access-token', + scopes: 'a b c', + expiresInSeconds: '60', + }), + ), + ), ); const helper = new DefaultAuthConnector(defaultOptions); @@ -64,7 +71,11 @@ describe('DefaultAuthConnector', () => { }); it('should handle failure to refresh session', async () => { - anyFetch.mockRejectOnce(new Error('Network NOPE')); + server.use( + rest.get('*', (_req, res, ctx) => + res(ctx.status(500, 'Error: Network NOPE')), + ), + ); const helper = new DefaultAuthConnector(defaultOptions); await expect(helper.refreshSession()).rejects.toThrow( @@ -73,7 +84,7 @@ describe('DefaultAuthConnector', () => { }); it('should handle failure response when refreshing session', async () => { - anyFetch.mockResponseOnce({}, { status: 401, statusText: 'NOPE' }); + server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE')))); const helper = new DefaultAuthConnector(defaultOptions); await expect(helper.refreshSession()).rejects.toThrow( diff --git a/packages/core-api/src/setupTests.ts b/packages/core-api/src/setupTests.ts index 8553642152..aea2220869 100644 --- a/packages/core-api/src/setupTests.ts +++ b/packages/core-api/src/setupTests.ts @@ -15,5 +15,4 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); +import 'cross-fetch/polyfill'; diff --git a/packages/core/package.json b/packages/core/package.json index 4d70457f6c..2b0fa58266 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -65,8 +65,7 @@ "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react-helmet": "^6.1.0", - "@types/zen-observable": "^0.8.0", - "jest-fetch-mock": "^3.0.3" + "@types/zen-observable": "^0.8.0" }, "files": [ "dist" diff --git a/packages/core/src/components/stories/Chip.stories.tsx b/packages/core/src/components/Chip/Chip.stories.tsx similarity index 100% rename from packages/core/src/components/stories/Chip.stories.tsx rename to packages/core/src/components/Chip/Chip.stories.tsx diff --git a/packages/core/src/components/EmptyState/assets/createComponent.svg b/packages/core/src/components/EmptyState/assets/createComponent.svg index 2634bb7f23..b81b9f39aa 100644 --- a/packages/core/src/components/EmptyState/assets/createComponent.svg +++ b/packages/core/src/components/EmptyState/assets/createComponent.svg @@ -1,36 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/core/src/components/EmptyState/assets/missingAnnotation.svg b/packages/core/src/components/EmptyState/assets/missingAnnotation.svg index 4f69605e6a..08f5a513eb 100644 --- a/packages/core/src/components/EmptyState/assets/missingAnnotation.svg +++ b/packages/core/src/components/EmptyState/assets/missingAnnotation.svg @@ -1,42 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/core/src/components/EmptyState/assets/noBuild.svg b/packages/core/src/components/EmptyState/assets/noBuild.svg index d84f36af30..832d98efa7 100644 --- a/packages/core/src/components/EmptyState/assets/noBuild.svg +++ b/packages/core/src/components/EmptyState/assets/noBuild.svg @@ -1,44 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/packages/core/src/components/EmptyState/assets/noInformation.svg b/packages/core/src/components/EmptyState/assets/noInformation.svg index c199cf8b13..9a1c230e97 100644 --- a/packages/core/src/components/EmptyState/assets/noInformation.svg +++ b/packages/core/src/components/EmptyState/assets/noInformation.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx index 91322ee2a5..23b42c0a11 100644 --- a/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx +++ b/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx @@ -20,12 +20,14 @@ import { renderInTestApp } from '@backstage/test-utils'; describe('', () => { it('should render with status code, status message and go back link', async () => { - const rendered = await renderInTestApp( + const { getByText, getByTestId } = await renderInTestApp( , ); - rendered.getByText(/page not found/i); - rendered.getByText(/404/i); - rendered.getByText(/Looks like someone dropped the mic!/i); - expect(rendered.getByTestId('go-back-link')).toBeDefined(); + expect(getByText(/page not found/i)).toBeInTheDocument(); + expect(getByText(/404/i)).toBeInTheDocument(); + expect( + getByText(/looks like someone dropped the mic!/i), + ).toBeInTheDocument(); + expect(getByTestId('go-back-link')).toBeInTheDocument(); }); }); diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.tsx index 3d80955cc9..b41308dd6d 100644 --- a/packages/core/src/layout/ErrorPage/ErrorPage.tsx +++ b/packages/core/src/layout/ErrorPage/ErrorPage.tsx @@ -30,9 +30,16 @@ interface IErrorPageProps { const useStyles = makeStyles(theme => ({ container: { padding: theme.spacing(8), + [theme.breakpoints.down('xs')]: { + padding: theme.spacing(2), + }, }, title: { paddingBottom: theme.spacing(5), + [theme.breakpoints.down('xs')]: { + paddingBottom: theme.spacing(4), + fontSize: 32, + }, }, subtitle: { color: theme.palette.textSubtle, @@ -48,9 +55,9 @@ export const ErrorPage = ({ const navigate = useNavigate(); return ( - + - + ERROR {status}: {statusMessage} diff --git a/packages/core/src/layout/ErrorPage/MicDrop.tsx b/packages/core/src/layout/ErrorPage/MicDrop.tsx index ca0a0ae0a6..aa8aaaf5e7 100644 --- a/packages/core/src/layout/ErrorPage/MicDrop.tsx +++ b/packages/core/src/layout/ErrorPage/MicDrop.tsx @@ -18,16 +18,29 @@ import React from 'react'; import { makeStyles } from '@material-ui/core'; import MicDropSvgUrl from './mic-drop.svg'; -const useStyles = makeStyles({ +const useStyles = makeStyles(theme => ({ micDrop: { maxWidth: '60%', - bottom: 10, - right: 10, position: 'absolute', + bottom: theme.spacing(2), + right: theme.spacing(2), + [theme.breakpoints.down('xs')]: { + maxWidth: '96%', + position: 'relative', + bottom: 'unset', + right: 'unset', + margin: `${theme.spacing(10)}px auto ${theme.spacing(4)}px`, + }, }, -}); +})); export const MicDrop = () => { const classes = useStyles(); - return ; + return ( + Girl dropping mic from her hands + ); }; diff --git a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx index 2b48cc4630..75031a0765 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx @@ -24,7 +24,7 @@ export default { export const Default = () => ( - + ( onClick={() => {}} /> - + ( export const Tags = () => ( - + - + diff --git a/packages/core/src/layout/ItemCard/ItemCard.test.tsx b/packages/core/src/layout/ItemCard/ItemCard.test.tsx new file mode 100644 index 0000000000..6afeb16c5b --- /dev/null +++ b/packages/core/src/layout/ItemCard/ItemCard.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { ItemCard } from './ItemCard'; + +const minProps = { + description: 'This is the description of an Item Card', + label: 'Button', + title: 'Item Card', + type: 'Pretitle', +}; + +describe('', () => { + it('renders default without exploding', async () => { + const { description, label, title, type } = minProps; + const { getByText } = await renderInTestApp(); + expect(getByText(description)).toBeInTheDocument(); + expect(getByText(title)).toBeInTheDocument(); + expect(getByText(label)).toBeInTheDocument(); + expect(getByText(type)).toBeInTheDocument(); + }); + + it('renders with tags without exploding', async () => { + const { description, label, title } = minProps; + const tags = ['tag one', 'tag two']; + const { getByText } = await renderInTestApp( + , + ); + expect(getByText(description)).toBeInTheDocument(); + expect(getByText(title)).toBeInTheDocument(); + expect(getByText(label)).toBeInTheDocument(); + tags.forEach(tag => { + expect(getByText(tag)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core/src/layout/ItemCard/ItemCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx index 2d425026a5..a17cbed563 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.tsx @@ -61,8 +61,8 @@ export const ItemCard: FC = ({ {title}
- {tags?.map(tag => ( - + {tags?.map((tag, i) => ( + ))} {description} diff --git a/packages/core/src/layout/Page/Page.stories.tsx b/packages/core/src/layout/Page/Page.stories.tsx index b3a75c19e9..3b1dd07c7a 100644 --- a/packages/core/src/layout/Page/Page.stories.tsx +++ b/packages/core/src/layout/Page/Page.stories.tsx @@ -118,14 +118,14 @@ const DataGrid = () => ( justify="space-between" direction="row" > - + - + ( ); const ExampleHeader = () => ( -
+
diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index 2eca89f873..bb47701280 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -25,7 +25,14 @@ import { BackstageTheme } from '@backstage/theme'; import { IconComponent } from '@backstage/core-api'; import SearchIcon from '@material-ui/icons/Search'; import clsx from 'clsx'; -import React, { FC, useContext, useState, KeyboardEventHandler } from 'react'; +import React, { + FC, + useContext, + useState, + KeyboardEventHandler, + forwardRef, + ReactNode, +} from 'react'; import { NavLink } from 'react-router-dom'; import { sidebarConfig, SidebarContext } from './config'; @@ -115,74 +122,93 @@ type SidebarItemProps = { to?: string; hasNotifications?: boolean; onClick?: () => void; + children?: ReactNode; }; -export const SidebarItem: FC = ({ - icon: Icon, - text, - to, - hasNotifications = false, - onClick, - children, -}) => { - const classes = useStyles(); - // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component - // depend on the current location, and at least have it being optionally forced to selected. - // Still waiting on a Q answered to fine tune the implementation - const { isOpen } = useContext(SidebarContext); +export const SidebarItem = forwardRef( + ( + { icon: Icon, text, to, hasNotifications = false, onClick, children }, + ref, + ) => { + const classes = useStyles(); + // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component + // depend on the current location, and at least have it being optionally forced to selected. + // Still waiting on a Q answered to fine tune the implementation + const { isOpen } = useContext(SidebarContext); - const itemIcon = ( - - - - ); + const itemIcon = ( + + + + ); - const childProps = { - onClick, - className: clsx(classes.root, isOpen ? classes.open : classes.closed), - }; + const childProps = { + onClick, + className: clsx(classes.root, isOpen ? classes.open : classes.closed), + }; + + if (!isOpen) { + if (to === undefined) { + return ( +
+ {itemIcon} +
+ ); + } + + return ( + + {itemIcon} + + ); + } + + const content = ( + <> +
+ {itemIcon} +
+ {text && ( + + {text} + + )} +
{children}
+ + ); - if (!isOpen) { if (to === undefined) { - return
{itemIcon}
; + return ( +
+ {content} +
+ ); } return ( - - {itemIcon} + + {content} ); - } - - const content = ( - <> -
- {itemIcon} -
- {text && ( - - {text} - - )} -
{children}
- - ); - - if (to === undefined) { - return
{content}
; - } - - return ( - - {content} - - ); -}; + }, +); type SidebarSearchFieldProps = { onSearch: (input: string) => void; diff --git a/packages/core/src/layout/Sidebar/Page.tsx b/packages/core/src/layout/Sidebar/Page.tsx index 2a4cf0fbb2..0c6f4a526d 100644 --- a/packages/core/src/layout/Sidebar/Page.tsx +++ b/packages/core/src/layout/Sidebar/Page.tsx @@ -45,7 +45,9 @@ export const SidebarPinStateContext = createContext( ); export const SidebarPage: FC<{}> = props => { - const [isPinned, setIsPinned] = useState(LocalStorage.getSidebarPinState()); + const [isPinned, setIsPinned] = useState(() => + LocalStorage.getSidebarPinState(), + ); useEffect(() => { LocalStorage.setSidebarPinState(isPinned); diff --git a/packages/core/src/setupTests.ts b/packages/core/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/packages/core/src/setupTests.ts +++ b/packages/core/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/packages/create-app/templates/default-app/app-config.development.yaml b/packages/create-app/templates/default-app/app-config.development.yaml deleted file mode 100644 index 817847c6d6..0000000000 --- a/packages/create-app/templates/default-app/app-config.development.yaml +++ /dev/null @@ -1,13 +0,0 @@ -app: - baseUrl: http://localhost:3000 - -backend: - baseUrl: http://localhost:7000 - listen: - port: 7000 - cors: - origin: http://localhost:3000 - methods: [GET, POST, PUT, DELETE] - credentials: true - csp: - connect-src: ["'self'", 'http:', 'https:'] diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index b92cdf22bd..a0b9f156ed 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -1,6 +1,6 @@ app: title: Scaffolded Backstage App - baseUrl: http://localhost:7000 + baseUrl: http://localhost:3000 organization: name: My Company @@ -10,7 +10,11 @@ backend: listen: port: 7000 csp: - connect-src: ["'self'", 'https:'] + connect-src: ["'self'", 'http:', 'https:'] + cors: + origin: http://localhost:3000 + methods: [GET, POST, PUT, DELETE] + credentials: true {{#if dbTypeSqlite}} database: client: sqlite3 diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index d58ebe4e42..51de1ad6b3 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -17,7 +17,7 @@ import { SingleHostDiscovery, UrlReaders, } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; +import { Config } from '@backstage/config'; import auth from './plugins/auth'; import catalog from './plugins/catalog'; import scaffolder from './plugins/scaffolder'; @@ -25,7 +25,7 @@ import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; import { PluginEnvironment } from './types'; -function makeCreateEnv(config: ConfigReader) { +function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); @@ -42,9 +42,11 @@ function makeCreateEnv(config: ConfigReader) { } async function main() { - const configs = await loadBackendConfig(); - const configReader = ConfigReader.fromConfigs(configs); - const createEnv = makeCreateEnv(configReader); + const config = await loadBackendConfig({ + argv: process.argv, + logger: getRootLogger(), + }); + const createEnv = makeCreateEnv(config); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); @@ -53,16 +55,16 @@ async function main() { const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const apiRouter = Router(); - apiRouter.use('/catalog', await catalog(catalogEnv)) - apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)) - apiRouter.use('/auth', await auth(authEnv)) - apiRouter.use('/techdocs', await techdocs(techdocsEnv)) - apiRouter.use('/proxy', await proxy(proxyEnv)) + apiRouter.use('/catalog', await catalog(catalogEnv)); + apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); + apiRouter.use('/auth', await auth(authEnv)); + apiRouter.use('/techdocs', await techdocs(techdocsEnv)); + apiRouter.use('/proxy', await proxy(proxyEnv)); apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) - .loadConfig(configReader) - .addRouter('/api', apiRouter) + .loadConfig(config) + .addRouter('/api', apiRouter); await service.start().catch(err => { console.log(err); diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json index 8a0246d4db..764e3c415a 100644 --- a/packages/e2e-test/package.json +++ b/packages/e2e-test/package.json @@ -31,7 +31,7 @@ "commander": "^6.1.0", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", - "node-fetch": "^2.6.0", + "cross-fetch": "^3.0.6", "pgtools": "^0.3.0", "tree-kill": "^1.2.2", "ts-node": "^8.6.2", diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index 5caed14227..905cb20d52 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -16,7 +16,7 @@ import os from 'os'; import fs from 'fs-extra'; -import fetch from 'node-fetch'; +import fetch from 'cross-fetch'; import handlebars from 'handlebars'; import killTree from 'tree-kill'; import { resolve as resolvePath, join as joinPath } from 'path'; diff --git a/packages/techdocs-container/Dockerfile b/packages/techdocs-container/Dockerfile index b34fcbd861..49f7940d32 100644 --- a/packages/techdocs-container/Dockerfile +++ b/packages/techdocs-container/Dockerfile @@ -17,7 +17,7 @@ FROM python:3.8-alpine RUN apk update && apk --no-cache add gcc musl-dev openjdk11-jdk curl graphviz ttf-dejavu fontconfig -RUN curl -L http://sourceforge.net/projects/plantuml/files/plantuml.1.2020.16.jar/download > /opt/plantuml.jar +RUN curl -o plantuml.jar -L http://sourceforge.net/projects/plantuml/files/plantuml.1.2020.16.jar/download && echo "c789ace48347c43073232b1458badc5810c01fe8 plantuml.jar" | sha1sum -c - && mv plantuml.jar /opt/plantuml.jar RUN pip install --upgrade pip && pip install mkdocs-techdocs-core==0.0.8 # Create script to call plantuml.jar from a location in path diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 1b7d35a197..2a290802d3 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -38,6 +38,7 @@ "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/react": "^16.9", + "msw": "^0.21.3", "react": "^16.12.0", "react-dom": "^16.12.0", "react-router": "6.0.0-beta.0", diff --git a/packages/test-utils/src/testUtils/index.tsx b/packages/test-utils/src/testUtils/index.tsx index 4c5eb642bd..8206d10834 100644 --- a/packages/test-utils/src/testUtils/index.tsx +++ b/packages/test-utils/src/testUtils/index.tsx @@ -17,3 +17,4 @@ export * from './apis'; export { default as mockBreakpoint } from './mockBreakpoint'; export { wrapInTestApp, renderInTestApp } from './appWrappers'; +export * from './msw'; diff --git a/plugins/cost-insights/src/hooks/useQueryParams.tsx b/packages/test-utils/src/testUtils/msw/index.ts similarity index 52% rename from plugins/cost-insights/src/hooks/useQueryParams.tsx rename to packages/test-utils/src/testUtils/msw/index.ts index 1e6ce8f0fd..0deaac0986 100644 --- a/plugins/cost-insights/src/hooks/useQueryParams.tsx +++ b/packages/test-utils/src/testUtils/msw/index.ts @@ -14,22 +14,14 @@ * limitations under the License. */ -import { useLocation } from 'react-router-dom'; -import { Location } from 'history'; -import { PageFilters, ProductFilters } from '../types'; -import { parse } from '../utils/history'; - -export type FilterParams = { - pageFilters?: Partial; - productFilters?: ProductFilters; +export const msw = { + setupDefaultHandlers: (worker: { + listen: (t: any) => void; + close: () => void; + resetHandlers: () => void; + }) => { + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); + afterAll(() => worker.close()); + afterEach(() => worker.resetHandlers()); + }, }; - -export function useQueryParams(): FilterParams { - const location: Location = useLocation(); - const { products: productFilters, ...pageFilters } = parse(location.search); - - return { - productFilters: productFilters, - pageFilters: pageFilters, - }; -} diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index b6764f6593..6820027c6e 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -49,9 +49,8 @@ "@types/node": "^12.0.0", "@types/react": "^16.9", "@types/swagger-ui-react": "^3.23.3", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/plugins/api-docs/src/setupTests.ts b/plugins/api-docs/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/api-docs/src/setupTests.ts +++ b/plugins/api-docs/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index aff536d334..25129f33f7 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -37,7 +37,6 @@ "knex": "^0.21.1", "moment": "^2.26.0", "morgan": "^1.10.0", - "node-fetch": "^2.6.1", "passport": "^0.4.1", "passport-github2": "^0.1.12", "passport-gitlab2": "^5.0.0", @@ -48,6 +47,7 @@ "passport-saml": "^1.3.3", "uuid": "^8.0.0", "winston": "^3.2.1", + "cross-fetch": "^3.0.6", "yn": "^4.0.0" }, "devDependencies": { @@ -55,14 +55,12 @@ "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/jwt-decode": "2.2.1", - "@types/node-fetch": "^2.5.7", "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", "@types/passport-microsoft": "^0.0.0", "@types/passport-saml": "^1.1.2", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5" + "msw": "^0.21.2" }, "files": [ "dist", diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index 2c1d66e1f8..b6919a98c1 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import fetch from 'node-fetch'; +import fetch from 'cross-fetch'; import { UserEntity } from '@backstage/catalog-model'; import { ConflictError, diff --git a/plugins/auth-backend/src/service/standaloneServer.ts b/plugins/auth-backend/src/service/standaloneServer.ts index d00e7d523d..a4f2377507 100644 --- a/plugins/auth-backend/src/service/standaloneServer.ts +++ b/plugins/auth-backend/src/service/standaloneServer.ts @@ -17,7 +17,6 @@ import Knex from 'knex'; import { Server } from 'http'; import { Logger } from 'winston'; -import { ConfigReader } from '@backstage/config'; import { createRouter } from './router'; import { createServiceBuilder, @@ -34,7 +33,7 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'auth-backend' }); - const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const config = await loadBackendConfig({ logger, argv: process.argv }); const discovery = SingleHostDiscovery.fromConfig(config); const database = useHotMemoize(module, () => { diff --git a/plugins/auth-backend/src/setupTests.ts b/plugins/auth-backend/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/plugins/auth-backend/src/setupTests.ts +++ b/plugins/auth-backend/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/plugins/catalog-backend/migrations/20201019130742_add_relations_table.js b/plugins/catalog-backend/migrations/20201019130742_add_relations_table.js new file mode 100644 index 0000000000..70150ff173 --- /dev/null +++ b/plugins/catalog-backend/migrations/20201019130742_add_relations_table.js @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + await knex.schema.createTable('entities_relations', table => { + table.comment('All relations between entities in the catalog'); + table + .uuid('originating_entity_id') + .references('id') + .inTable('entities') + .onDelete('CASCADE') + .notNullable() + .comment('The entity that provided the relation'); + table + .string('source_full_name') + .notNullable() + .comment('The full name of the source entity of the relation'); + table + .string('type') + .notNullable() + .comment('The type of the relation between the entities'); + table + .string('target_full_name') + .notNullable() + .comment('The full name of the target entity of the relation'); + + table.primary(['source_full_name', 'type', 'target_full_name']); + }); +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('entities_relations'); +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 34f9c7b360..ecc154bc5a 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -25,9 +25,9 @@ "@backstage/config": "^0.1.1-alpha.25", "@octokit/graphql": "^4.5.6", "@types/express": "^4.17.6", - "@types/node-fetch": "^2.5.7", "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", + "cross-fetch": "^3.0.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", @@ -36,7 +36,6 @@ "ldapjs": "^2.2.0", "lodash": "^4.17.15", "morgan": "^1.10.0", - "node-fetch": "^2.6.0", "p-limit": "^3.0.2", "sqlite3": "^5.0.0", "uuid": "^8.0.0", @@ -47,6 +46,7 @@ }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", + "@backstage/test-utils": "^0.1.1-alpha.25", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", "@types/ldapjs": "^1.0.9", @@ -54,8 +54,7 @@ "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", "@types/yup": "^0.29.8", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", + "msw": "^0.21.2", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 3d1f887e1f..a801015915 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -18,6 +18,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import type { Entity } from '@backstage/catalog-model'; import { Database, DatabaseManager } from '../database'; import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; +import { EntityUpsertRequest } from './types'; describe('DatabaseEntitiesCatalog', () => { let db: jest.Mocked; @@ -31,6 +32,7 @@ describe('DatabaseEntitiesCatalog', () => { entityByName: jest.fn(), entityByUid: jest.fn(), removeEntityByUid: jest.fn(), + setRelations: jest.fn(), addLocation: jest.fn(), removeLocation: jest.fn(), location: jest.fn(), @@ -45,7 +47,7 @@ describe('DatabaseEntitiesCatalog', () => { db.transaction.mockImplementation(async f => f('tx')); }); - describe('addOrUpdateEntity', () => { + describe('batchAddOrUpdateEntities', () => { it('adds when no given uid and no matching by name', async () => { const entity: Entity = { apiVersion: 'a', @@ -57,19 +59,25 @@ describe('DatabaseEntitiesCatalog', () => { }; db.entities.mockResolvedValue([]); - db.addEntities.mockResolvedValue([{ entity }]); + db.addEntities.mockResolvedValue([ + { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, + ]); const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); - const result = await catalog.addOrUpdateEntity(entity); + const result = await catalog.batchAddOrUpdateEntities([ + { entity, relations: [] }, + ]); - expect(db.entityByName).toHaveBeenCalledTimes(1); - expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), { + expect(db.entities).toHaveBeenCalledTimes(1); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), { kind: 'b', - namespace: 'd', - name: 'c', + 'metadata.namespace': 'd', + 'metadata.name': ['c'], }); + expect(db.setRelations).toHaveBeenCalledTimes(1); + expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); expect(db.addEntities).toHaveBeenCalledTimes(1); - expect(result).toBe(entity); + expect(result).toEqual([{ entityId: 'u' }]); }); it('updates when given uid', async () => { @@ -81,9 +89,11 @@ describe('DatabaseEntitiesCatalog', () => { name: 'c', namespace: 'd', }, + spec: { + x: 'b', + }, }; - - db.entityByUid.mockResolvedValue({ + const existing = { entity: { apiVersion: 'a', kind: 'b', @@ -94,14 +104,28 @@ describe('DatabaseEntitiesCatalog', () => { name: 'c', namespace: 'd', }, + spec: { + x: 'a', + }, }, - }); + }; + + db.entities.mockResolvedValue([existing]); + db.entityByUid.mockResolvedValue(existing); db.updateEntity.mockResolvedValue({ entity }); const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); - const result = await catalog.addOrUpdateEntity(entity); + const result = await catalog.batchAddOrUpdateEntities([ + { entity, relations: [] }, + ]); - expect(db.entities).toHaveBeenCalledTimes(0); + expect(db.entities).toHaveBeenCalledTimes(1); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), { + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': ['c'], + }); + expect(db.entityByName).not.toHaveBeenCalled(); expect(db.entityByUid).toHaveBeenCalledTimes(1); expect(db.entityByUid).toHaveBeenCalledWith(expect.anything(), 'u'); expect(db.updateEntity).toHaveBeenCalledTimes(1); @@ -113,17 +137,22 @@ describe('DatabaseEntitiesCatalog', () => { kind: 'b', metadata: { uid: 'u', - etag: 'e', - generation: 1, + etag: expect.any(String), + generation: 2, name: 'c', namespace: 'd', }, + spec: { + x: 'b', + }, }, }, 'e', 1, ); - expect(result).toBe(entity); + expect(db.setRelations).toHaveBeenCalledTimes(1); + expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); + expect(result).toEqual([{ entityId: 'u' }]); }); it('update when no given uid and matching by name', async () => { @@ -134,25 +163,42 @@ describe('DatabaseEntitiesCatalog', () => { name: 'c', namespace: 'd', }, + spec: { + x: 'b', + }, }; - const existing: Entity = { - apiVersion: 'a', - kind: 'b', - metadata: { - uid: 'u', - etag: 'e', - generation: 1, - name: 'c', - namespace: 'd', + const existing = { + entity: { + apiVersion: 'a', + kind: 'b', + metadata: { + uid: 'u', + etag: 'e', + generation: 1, + name: 'c', + namespace: 'd', + }, + spec: { + x: 'a', + }, }, }; - db.entityByName.mockResolvedValue({ entity: existing }); - db.updateEntity.mockResolvedValue({ entity: existing }); + db.entities.mockResolvedValue([existing]); + db.entityByName.mockResolvedValue(existing); + db.updateEntity.mockResolvedValue(existing); const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); - const result = await catalog.addOrUpdateEntity(added); + const result = await catalog.batchAddOrUpdateEntities([ + { entity: added, relations: [] }, + ]); + expect(db.entities).toHaveBeenCalledTimes(1); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), { + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': ['c'], + }); expect(db.entityByName).toHaveBeenCalledTimes(1); expect(db.entityByName).toHaveBeenCalledWith(expect.anything(), { kind: 'b', @@ -168,51 +214,95 @@ describe('DatabaseEntitiesCatalog', () => { kind: 'b', metadata: { uid: 'u', - etag: 'e', - generation: 1, + etag: expect.any(String), + generation: 2, name: 'c', namespace: 'd', }, + spec: { + x: 'b', + }, }, }, 'e', 1, ); - expect(result).toEqual(existing); + expect(result).toEqual([{ entityId: 'u' }]); + }); + + it('should not update if entity is unchanged', async () => { + const entity: Entity = { + apiVersion: 'a', + kind: 'b', + metadata: { + uid: 'u', + name: 'c', + namespace: 'd', + }, + spec: { + x: 'a', + }, + }; + + db.entities.mockResolvedValue([{ entity }]); + db.entityByUid.mockResolvedValue({ entity }); + db.updateEntity.mockResolvedValue({ entity }); + + const catalog = new DatabaseEntitiesCatalog(db, getVoidLogger()); + const result = await catalog.batchAddOrUpdateEntities([ + { entity, relations: [] }, + ]); + + expect(db.entities).toHaveBeenCalledTimes(1); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), { + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': ['c'], + }); + expect(db.entityByName).not.toHaveBeenCalled(); + expect(db.entityByUid).not.toHaveBeenCalled(); + expect(db.updateEntity).not.toHaveBeenCalled(); + expect(db.setRelations).toHaveBeenCalledTimes(1); + expect(db.setRelations).toHaveBeenCalledWith(expect.anything(), 'u', []); + expect(result).toEqual([{ entityId: 'u' }]); }); - }); - describe('batchAddOrUpdateEntities', () => { it('both adds and updates', async () => { const catalog = new DatabaseEntitiesCatalog( await DatabaseManager.createTestDatabase(), getVoidLogger(), ); - const entities: Entity[] = []; - for (let i = 0; i < 500; ++i) { + const entities: EntityUpsertRequest[] = []; + for (let i = 0; i < 300; ++i) { entities.push({ - apiVersion: 'a', - kind: 'k', - metadata: { name: `n${i}` }, + entity: { + apiVersion: 'a', + kind: 'k', + metadata: { name: `n${i}` }, + }, + relations: [], }); } await catalog.batchAddOrUpdateEntities(entities); const afterFirst = await catalog.entities(); - expect(afterFirst.length).toBe(500); + expect(afterFirst.length).toBe(300); - entities[40].metadata.op = 'changed'; + entities[40].entity.metadata.op = 'changed'; entities.push({ - apiVersion: 'a', - kind: 'k', - metadata: { name: `n500`, op: 'added' }, + entity: { + apiVersion: 'a', + kind: 'k', + metadata: { name: `n300`, op: 'added' }, + }, + relations: [], }); await catalog.batchAddOrUpdateEntities(entities); const afterSecond = await catalog.entities(); - expect(afterSecond.length).toBe(501); + expect(afterSecond.length).toBe(301); expect(afterSecond.find(e => e.metadata.op === 'changed')).toBeDefined(); expect(afterSecond.find(e => e.metadata.op === 'added')).toBeDefined(); - }); + }, 10000); }); }); diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 0a213dd186..6c3065aeaa 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -15,20 +15,25 @@ */ import { ConflictError, NotFoundError } from '@backstage/backend-common'; -import type { Entity } from '@backstage/catalog-model'; import { entityHasChanges, generateUpdatedEntity, getEntityName, LOCATION_ANNOTATION, serializeEntityRef, + Entity, + EntityRelationSpec, } from '@backstage/catalog-model'; import { chunk, groupBy } from 'lodash'; import limiterFactory from 'p-limit'; import { Logger } from 'winston'; import type { Database, DbEntityResponse, EntityFilters } from '../database'; import { durationText } from '../util/timing'; -import type { EntitiesCatalog } from './types'; +import type { + EntitiesCatalog, + EntityUpsertRequest, + EntityUpsertResponse, +} from './types'; type BatchContext = { kind: string; @@ -62,7 +67,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { return items.map(i => i.entity); } - async addOrUpdateEntity( + private async addOrUpdateEntity( entity: Entity, locationId?: string, ): Promise { @@ -95,15 +100,6 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { }); } - async addEntities(entities: Entity[], locationId?: string): Promise { - await this.database.transaction(async tx => { - await this.database.addEntities( - tx, - entities.map(entity => ({ locationId, entity })), - ); - }); - } - async removeEntityByUid(uid: string): Promise { return await this.database.transaction(async tx => { const entityResponse = await this.database.entityByUid(tx, uid); @@ -137,27 +133,31 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { * @param entities Some entities * @param locationId The location that they all belong to */ - async batchAddOrUpdateEntities(entities: Entity[], locationId?: string) { + async batchAddOrUpdateEntities( + requests: EntityUpsertRequest[], + locationId?: string, + ): Promise { // Group the entities by unique kind+namespace combinations - const entitiesByKindAndNamespace = groupBy(entities, entity => { + const entitiesByKindAndNamespace = groupBy(requests, ({ entity }) => { const name = getEntityName(entity); return `${name.kind}:${name.namespace}`.toLowerCase(); }); const limiter = limiterFactory(BATCH_CONCURRENCY); - const tasks: Promise[] = []; + const tasks: Promise[] = []; - for (const groupEntities of Object.values(entitiesByKindAndNamespace)) { - const { kind, namespace } = getEntityName(groupEntities[0]); + for (const groupRequests of Object.values(entitiesByKindAndNamespace)) { + const { kind, namespace } = getEntityName(groupRequests[0].entity); // Go through the new entities in reasonable chunk sizes (sometimes, // sources produce tens of thousands of entities, and those are too large // batch sizes to reasonably send to the database) - for (const batch of chunk(groupEntities, BATCH_SIZE)) { + for (const batch of chunk(groupRequests, BATCH_SIZE)) { tasks.push( limiter(async () => { - const first = serializeEntityRef(batch[0]); - const last = serializeEntityRef(batch[batch.length - 1]); + const first = serializeEntityRef(batch[0].entity); + const last = serializeEntityRef(batch[batch.length - 1].entity); + const modifiedEntityIds: EntityUpsertResponse[] = []; this.logger.debug( `Considering batch ${first}-${last} (${batch.length} entries)`, ); @@ -166,12 +166,28 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { const context = { kind, namespace, locationId }; for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) { try { - const { toAdd, toUpdate } = await this.analyzeBatch( + const { toAdd, toUpdate, toIgnore } = await this.analyzeBatch( batch, context, ); - if (toAdd.length) await this.batchAdd(toAdd, context); - if (toUpdate.length) await this.batchUpdate(toUpdate, context); + if (toAdd.length) { + modifiedEntityIds.push( + ...(await this.batchAdd(toAdd, context)), + ); + } + if (toUpdate.length) { + modifiedEntityIds.push( + ...(await this.batchUpdate(toUpdate, context)), + ); + } + // TODO(Rugvip): We currently always update relations, but we + // likely want to figure out a way to avoid that + for (const { entity, relations } of toIgnore) { + const entityId = entity.metadata.uid!; + await this.setRelations(entityId, relations); + modifiedEntityIds.push({ entityId }); + } + break; } catch (e) { if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) { @@ -183,12 +199,24 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { } } } + + return modifiedEntityIds; }), ); } } - await Promise.all(tasks); + return (await Promise.all(tasks)).flat(); + } + + // Set the relations originating from an entity using the DB layer + private async setRelations( + originatingEntityId: string, + relations: EntityRelationSpec[], + ): Promise { + return await this.database.transaction(tx => + this.database.setRelations(tx, originatingEntityId, relations), + ); } // Given a batch of entities that were just read from a location, take them @@ -196,15 +224,16 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // produce the list of entities to be added, and the list of entities to be // updated private async analyzeBatch( - newEntities: Entity[], + requests: EntityUpsertRequest[], { kind, namespace }: BatchContext, ): Promise<{ - toAdd: Entity[]; - toUpdate: Entity[]; + toAdd: EntityUpsertRequest[]; + toUpdate: EntityUpsertRequest[]; + toIgnore: EntityUpsertRequest[]; }> { const markTimestamp = process.hrtime(); - const names = newEntities.map(e => e.metadata.name); + const names = requests.map(({ entity }) => entity.metadata.name); const oldEntities = await this.entities({ kind: kind, 'metadata.namespace': namespace, @@ -215,18 +244,22 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { oldEntities.map(e => [e.metadata.name, e]), ); - const toAdd: Entity[] = []; - const toUpdate: Entity[] = []; + const toAdd: EntityUpsertRequest[] = []; + const toUpdate: EntityUpsertRequest[] = []; + const toIgnore: EntityUpsertRequest[] = []; - for (const newEntity of newEntities) { + for (const request of requests) { + const newEntity = request.entity; const oldEntity = oldEntitiesByName.get(newEntity.metadata.name); if (!oldEntity) { - toAdd.push(newEntity); + toAdd.push(request); } else if (entityHasChanges(oldEntity, newEntity)) { // TODO(freben): This currently uses addOrUpdateEntity under the hood, // but should probably calculate the end result entity right here // instead and call a dedicated batch update database method instead - toUpdate.push(newEntity); + toUpdate.push(request); + } else { + toIgnore.push(request); } } @@ -236,33 +269,60 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { } entities to update in ${durationText(markTimestamp)}`, ); - return { toAdd, toUpdate }; + return { toAdd, toUpdate, toIgnore }; } // Efficiently adds the given entities to storage, under the assumption that // they do not conflict with any existing entities - private async batchAdd(entities: Entity[], { locationId }: BatchContext) { + private async batchAdd( + requests: EntityUpsertRequest[], + { locationId }: BatchContext, + ): Promise { const markTimestamp = process.hrtime(); - await this.addEntities(entities, locationId); + const res = await this.database.transaction( + async tx => + await this.database.addEntities( + tx, + requests.map(({ entity }) => ({ locationId, entity })), + ), + ); + + const entityIds = res.map(({ entity }) => ({ + entityId: entity.metadata.uid!, + })); + + for (const [index, { entityId }] of entityIds.entries()) { + await this.setRelations(entityId, requests[index].relations); + } this.logger.debug( - `Added ${entities.length} entities in ${durationText(markTimestamp)}`, + `Added ${requests.length} entities in ${durationText(markTimestamp)}`, ); + + return entityIds; } // Efficiently updates the given entities into storage, under the assumption // that there already exist entities with the same names - private async batchUpdate(entities: Entity[], { locationId }: BatchContext) { + private async batchUpdate( + requests: EntityUpsertRequest[], + { locationId }: BatchContext, + ): Promise { const markTimestamp = process.hrtime(); - + const responseIds: EntityUpsertResponse[] = []; // TODO(freben): Still not batched - for (const entity of entities) { - await this.addOrUpdateEntity(entity, locationId); + for (const entity of requests) { + const res = await this.addOrUpdateEntity(entity.entity, locationId); + const entityId = res.metadata.uid!; + responseIds.push({ entityId }); + await this.setRelations(entityId, entity.relations); } this.logger.debug( - `Updated ${entities.length} entities in ${durationText(markTimestamp)}`, + `Updated ${requests.length} entities in ${durationText(markTimestamp)}`, ); + + return responseIds; } } diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index ca73eddce9..4d821c428a 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -14,17 +14,24 @@ * limitations under the License. */ -import { Entity, Location } from '@backstage/catalog-model'; +import { Entity, Location, EntityRelationSpec } from '@backstage/catalog-model'; import type { EntityFilters } from '../database'; // // Entities // +export type EntityUpsertRequest = { + entity: Entity; + relations: EntityRelationSpec[]; +}; + +export type EntityUpsertResponse = { + entityId: string; +}; + export type EntitiesCatalog = { entities(filters?: EntityFilters): Promise; - addOrUpdateEntity(entity: Entity, locationId?: string): Promise; - addEntities(entities: Entity[], locationId?: string): Promise; removeEntityByUid(uid: string): Promise; /** @@ -34,9 +41,9 @@ export type EntitiesCatalog = { * @param locationId The location that they all belong to */ batchAddOrUpdateEntities( - entities: Entity[], + entities: EntityUpsertRequest[], locationId?: string, - ): Promise; + ): Promise; }; // diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index f3ca212df0..0e59e0ff5a 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -15,7 +15,7 @@ */ import { ConflictError } from '@backstage/backend-common'; -import type { Entity, Location } from '@backstage/catalog-model'; +import { Entity, Location, parseEntityRef } from '@backstage/catalog-model'; import { DatabaseManager } from './DatabaseManager'; import type { DbEntityRequest, @@ -494,6 +494,196 @@ describe('CommonDatabase', () => { }); }); + describe('setRelations', () => { + it('adds a relation for an entity', async () => { + const mockRelations = [ + { + source: { + kind: entityRequest.entity.kind, + namespace: entityRequest.entity.metadata.namespace!, + name: entityRequest.entity.metadata.name, + }, + target: { + kind: 'component', + namespace: 'asd', + name: 'bleb', + }, + type: 'child', + }, + ]; + + const entityId = await db.transaction(async tx => { + const [{ entity }] = await db.addEntities(tx, [entityRequest]); + + await db.setRelations(tx, entity?.metadata?.uid!, mockRelations); + return entity.metadata.uid; + }); + + const returnedEntity1 = await db.transaction(tx => + db.entityByUid(tx, entityId!), + ); + expect(returnedEntity1?.entity.relations).toEqual([ + { target: mockRelations[0].target, type: 'child' }, + ]); + + const returnedEntity2 = await db.transaction(tx => + db.entityByName(tx, mockRelations[0].source), + ); + expect(returnedEntity2?.entity.relations).toEqual([ + { target: mockRelations[0].target, type: 'child' }, + ]); + + const [returnedEntity3] = await db.transaction(tx => db.entities(tx)); + expect(returnedEntity3?.entity.relations).toEqual([ + { target: mockRelations[0].target, type: 'child' }, + ]); + }); + + function makeRelation(source: string, type: string, target: string) { + return { + source: parseEntityRef(source, { + defaultKind: 'x', + defaultNamespace: 'x', + }), + type, + target: parseEntityRef(target, { + defaultKind: 'x', + defaultNamespace: 'x', + }), + }; + } + + it('should not allow setting relations on nonexistent entities', async () => { + await expect( + db.transaction(async tx => { + await db.setRelations(tx, 'nonexistent', [ + makeRelation('a:b/c', 'rel1', 'x:y/z'), + ]); + }), + ).rejects.toThrow(/constraint failed/); + }); + + it('should allow setting relations on nonexistent entities without any relations', async () => { + await expect( + db.transaction(async tx => { + await db.setRelations(tx, 'nonexistent', []); + }), + ).resolves.toBeUndefined(); + }); + + it('adds multiple relations for entities', async () => { + const entity1 = { + apiVersion: 'v1', + kind: 'a', + metadata: { + name: 'c', + namespace: 'b', + }, + }; + const entity2 = { + apiVersion: 'v1', + kind: 'x', + metadata: { + name: 'z', + namespace: 'y', + }, + }; + const fromEntity1 = [ + makeRelation('a:b/c', 'rel1', 'x:y/z'), + makeRelation('x:y/z', 'rel2', 'a:b/c'), + makeRelation('a:b/c', 'rel2', 'x:y/z'), + ]; + const fromEntity2 = [ + makeRelation('a:b/c', 'rel4', 'x:y/z'), + makeRelation('a:b/c', 'rel5', 'x:y/z'), + makeRelation('x:y/z', 'rel6', 'a:b/c'), + // relations don't have to reference the originating entity, so this should be fine, but not show up + makeRelation('g:h/i', 'rel8', 'd:e/f'), + ]; + + const { id2: secondEntityId } = await db.transaction(async tx => { + const [{ entity: e1 }, { entity: e2 }] = await db.addEntities(tx, [ + { entity: entity1 }, + { entity: entity2 }, + ]); + const id1 = e1?.metadata?.uid!; + const id2 = e2?.metadata?.uid!; + + await db.setRelations(tx, id1, fromEntity1); + await db.setRelations(tx, id2, fromEntity2); + + return { id1, id2 }; + }); + + const res = await db.transaction(tx => db.entities(tx)); + expect( + res.map(r => ({ + name: r.entity.metadata.name, + relations: r.entity.relations, + })), + ).toEqual([ + { + name: 'c', + relations: [ + { + type: 'rel1', + target: { kind: 'x', namespace: 'y', name: 'z' }, + }, + { + type: 'rel2', + target: { kind: 'x', namespace: 'y', name: 'z' }, + }, + { + type: 'rel4', + target: { kind: 'x', namespace: 'y', name: 'z' }, + }, + { + type: 'rel5', + target: { kind: 'x', namespace: 'y', name: 'z' }, + }, + ], + }, + { + name: 'z', + relations: [ + { + type: 'rel2', + target: { kind: 'a', namespace: 'b', name: 'c' }, + }, + { + type: 'rel6', + target: { kind: 'a', namespace: 'b', name: 'c' }, + }, + ], + }, + ]); + + await db.transaction(tx => db.removeEntityByUid(tx, secondEntityId)); + + const res2 = await db.transaction(tx => db.entities(tx)); + expect( + res2.map(r => ({ + name: r.entity.metadata.name, + relations: r.entity.relations, + })), + ).toEqual([ + { + name: 'c', + relations: [ + { + type: 'rel1', + target: { kind: 'x', namespace: 'y', name: 'z' }, + }, + { + type: 'rel2', + target: { kind: 'x', namespace: 'y', name: 'z' }, + }, + ], + }, + ]); + }); + }); + describe('entityByName', () => { it('can get entities case insensitively', async () => { const entities: Entity[] = [ diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index dca5072b30..e72926b13a 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -27,15 +27,18 @@ import { generateEntityEtag, generateEntityUid, Location, + EntityRelationSpec, + parseEntityName, } from '@backstage/catalog-model'; import Knex from 'knex'; import lodash from 'lodash'; import type { Logger } from 'winston'; import { buildEntitySearch } from './search'; -import type { +import { Database, DatabaseLocationUpdateLogEvent, DatabaseLocationUpdateLogStatus, + DbEntitiesRelationsRow, DbEntitiesRow, DbEntitiesSearchRow, DbEntityRequest, @@ -123,6 +126,8 @@ export class CommonDatabase implements Database { throw new InputError('May not specify etag for new entities'); } else if (entity.metadata.generation !== undefined) { throw new InputError('May not specify generation for new entities'); + } else if (entity.relations !== undefined) { + throw new InputError('May not specify relations for new entities'); } const newEntity = { @@ -281,7 +286,7 @@ export class CommonDatabase implements Database { .select('entities.*') .orderBy('full_name', 'asc'); - return rows.map(row => this.toEntityResponse(row)); + return Promise.all(rows.map(row => this.toEntityResponse(tx, row))); } async entityByName( @@ -300,7 +305,7 @@ export class CommonDatabase implements Database { return undefined; } - return this.toEntityResponse(rows[0]); + return this.toEntityResponse(tx, rows[0]); } async entityByUid( @@ -317,7 +322,7 @@ export class CommonDatabase implements Database { return undefined; } - return this.toEntityResponse(rows[0]); + return this.toEntityResponse(tx, rows[0]); } async removeEntityByUid(txOpaque: unknown, uid: string): Promise { @@ -330,6 +335,34 @@ export class CommonDatabase implements Database { } } + async setRelations( + txOpaque: unknown, + originatingEntityId: string, + relations: EntityRelationSpec[], + ): Promise { + const tx = txOpaque as Knex.Transaction; + + // remove all relations that exist for the originating entity id. + await tx('entities_relations') + .where({ originating_entity_id: originatingEntityId }) + .del(); + + const serializeName = (e: EntityName) => + `${e.kind}:${e.namespace}/${e.name}`.toLowerCase(); + + const relationsRows: DbEntitiesRelationsRow[] = relations.map( + ({ source, target, type }) => ({ + originating_entity_id: originatingEntityId, + source_full_name: serializeName(source), + target_full_name: serializeName(target), + type, + }), + ); + + // TODO(blam): translate constraint failures to sane NotFoundError instead + await tx.batchInsert('entities_relations', relationsRows, BATCH_SIZE); + } + async addLocation(location: Location): Promise { return await this.database.transaction(async tx => { const row: DbLocationsRow = { @@ -469,12 +502,27 @@ export class CommonDatabase implements Database { }; } - private toEntityResponse(row: DbEntitiesRow): DbEntityResponse { + private async toEntityResponse( + tx: Knex.Transaction, + row: DbEntitiesRow, + ): Promise { const entity = JSON.parse(row.data) as Entity; entity.metadata.uid = row.id; entity.metadata.etag = row.etag; entity.metadata.generation = Number(row.generation); // cast due to sqlite + // TODO(Rugvip): This is here because it's simple for now, but we likely + // need to refactor this to be more efficient or introduce pagination. + const relations = await tx('entities_relations') + .where({ source_full_name: row.full_name }) + .orderBy(['type', 'target_full_name']) + .select(); + + entity.relations = relations.map(r => ({ + target: parseEntityName(r.target_full_name), + type: r.type, + })); + return { locationId: row.location_id || undefined, entity, diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 14e8d5be64..fa038c0e1d 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import type { Entity, EntityName, Location } from '@backstage/catalog-model'; +import type { + Entity, + EntityName, + Location, + EntityRelationSpec, +} from '@backstage/catalog-model'; export type DbEntitiesRow = { id: string; @@ -35,6 +40,13 @@ export type DbEntityResponse = { entity: Entity; }; +export type DbEntitiesRelationsRow = { + originating_entity_id: string; + source_full_name: string; + type: string; + target_full_name: string; +}; + export type DbEntitiesSearchRow = { entity_id: string; key: string; @@ -152,6 +164,18 @@ export type Database = { removeEntityByUid(tx: unknown, uid: string): Promise; + /** + * Remove current relations for the entity and replace them with the new relations array + * @param tx An ongoing transaction + * @param entityUid the entity uid + * @param relations the relationships to be set + */ + setRelations( + tx: unknown, + entityUid: string, + relations: EntityRelationSpec[], + ): Promise; + addLocation(location: Location): Promise; removeLocation(tx: unknown, id: string): Promise; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index 7fe164d05f..933433b47e 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -31,8 +31,6 @@ describe('HigherOrderOperations', () => { beforeAll(() => { entitiesCatalog = { entities: jest.fn(), - addOrUpdateEntity: jest.fn(), - addEntities: jest.fn(), removeEntityByUid: jest.fn(), batchAddOrUpdateEntities: jest.fn(), }; @@ -68,7 +66,10 @@ describe('HigherOrderOperations', () => { }; locationsCatalog.addLocation.mockImplementation(x => Promise.resolve(x)); locationsCatalog.locations.mockResolvedValue([]); - locationReader.read.mockResolvedValue({ entities: [], errors: [] }); + locationReader.read.mockResolvedValue({ + entities: [], + errors: [], + }); const result = await higherOrderOperation.addLocation(spec); @@ -82,7 +83,7 @@ describe('HigherOrderOperations', () => { expect(locationsCatalog.locations).toBeCalledTimes(1); expect(locationReader.read).toBeCalledTimes(1); expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' }); - expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled(); + expect(entitiesCatalog.batchAddOrUpdateEntities).not.toBeCalled(); expect(locationsCatalog.addLocation).toBeCalledTimes(1); expect(locationsCatalog.addLocation).toBeCalledWith( expect.objectContaining({ @@ -108,7 +109,10 @@ describe('HigherOrderOperations', () => { data: location, }, ]); - locationReader.read.mockResolvedValue({ entities: [], errors: [] }); + locationReader.read.mockResolvedValue({ + entities: [], + errors: [], + }); const result = await higherOrderOperation.addLocation(spec); @@ -117,7 +121,7 @@ describe('HigherOrderOperations', () => { expect(locationsCatalog.locations).toBeCalledTimes(1); expect(locationReader.read).toBeCalledTimes(1); expect(locationReader.read).toBeCalledWith({ type: 'a', target: 'b' }); - expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled(); + expect(entitiesCatalog.batchAddOrUpdateEntities).not.toBeCalled(); expect(locationsCatalog.addLocation).not.toBeCalled(); }); @@ -135,7 +139,7 @@ describe('HigherOrderOperations', () => { locationsCatalog.locations.mockResolvedValue([]); locationReader.read.mockResolvedValue({ - entities: [{ entity, location }], + entities: [{ entity, location, relations: [] }], errors: [{ error: new Error('abcd'), location }], }); @@ -143,7 +147,7 @@ describe('HigherOrderOperations', () => { /abcd/, ); expect(locationsCatalog.locations).toBeCalledTimes(1); - expect(entitiesCatalog.addOrUpdateEntity).not.toBeCalled(); + expect(entitiesCatalog.batchAddOrUpdateEntities).not.toBeCalled(); expect(locationsCatalog.addLocation).not.toBeCalled(); }); }); @@ -158,7 +162,7 @@ describe('HigherOrderOperations', () => { expect(locationsCatalog.locations).toHaveBeenCalledTimes(1); expect(locationReader.read).not.toHaveBeenCalled(); - expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled(); + expect(entitiesCatalog.batchAddOrUpdateEntities).not.toHaveBeenCalled(); }); it('can update a single location where a matching entity did not exist', async () => { @@ -178,15 +182,18 @@ describe('HigherOrderOperations', () => { metadata: { name: 'c1' }, spec: { type: 'service' }, }; + const entityId = 'xyz123'; locationsCatalog.locations.mockResolvedValue([ { currentStatus: locationStatus, data: location }, ]); locationReader.read.mockResolvedValue({ - entities: [{ entity: desc, location }], + entities: [{ entity: desc, location, relations: [] }], errors: [], }); - entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue(undefined); + entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([ + { entityId }, + ]); await expect( higherOrderOperation.refreshAllLocations(), @@ -199,9 +206,13 @@ describe('HigherOrderOperations', () => { target: 'thing', }); expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenNthCalledWith( - 1, - [expect.objectContaining({ metadata: { name: 'c1' } })], + expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenCalledWith( + [ + expect.objectContaining({ + entity: expect.objectContaining({ metadata: { name: 'c1' } }), + relations: [], + }), + ], '123', ); }); @@ -228,11 +239,11 @@ describe('HigherOrderOperations', () => { { currentStatus: locationStatus, data: location }, ]); locationReader.read.mockResolvedValue({ - entities: [{ entity: desc, location }], + entities: [{ entity: desc, location, relations: [] }], errors: [], }); entitiesCatalog.entities.mockResolvedValue([]); - entitiesCatalog.addEntities.mockResolvedValue(undefined); + entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([]); await expect( higherOrderOperation.refreshAllLocations(), diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index 8da65dbd85..cb161d1ccf 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { InputError } from '@backstage/backend-common'; -import { Entity, Location, LocationSpec } from '@backstage/catalog-model'; +import { Location, LocationSpec } from '@backstage/catalog-model'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; @@ -79,11 +78,9 @@ export class HigherOrderOperations implements HigherOrderOperation { // Read the location fully, bailing on any errors const readerOutput = await this.locationReader.read(spec); - if (!spec.pendingLocation && readerOutput.errors.length) { + if (!(spec.presence === 'optional') && readerOutput.errors.length) { const item = readerOutput.errors[0]; - throw new InputError( - `Failed to read location ${item.location.type}:${item.location.target}, ${item.error}`, - ); + throw item.error; } // TODO(freben): At this point, we could detect orphaned entities, by way @@ -94,16 +91,20 @@ export class HigherOrderOperations implements HigherOrderOperation { if (!previousLocation) { await this.locationsCatalog.addLocation(location); } - const outputEntities: Entity[] = []; - for (const entity of readerOutput.entities) { - const out = await this.entitiesCatalog.addOrUpdateEntity( - entity.entity, - location.id, - ); - outputEntities.push(out); + if (readerOutput.entities.length === 0) { + return { location, entities: [] }; } - return { location, entities: outputEntities }; + const writtenEntities = await this.entitiesCatalog.batchAddOrUpdateEntities( + readerOutput.entities, + location.id, + ); + + const entities = await this.entitiesCatalog.entities({ + 'metadata.uid': writtenEntities.map(e => e.entityId), + }); + + return { location, entities }; } /** @@ -166,7 +167,7 @@ export class HigherOrderOperations implements HigherOrderOperation { try { await this.entitiesCatalog.batchAddOrUpdateEntities( - readerOutput.entities.map(e => e.entity), + readerOutput.entities, location.id, ); } catch (e) { diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index e23a4524b6..11ae45cae9 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -18,6 +18,7 @@ import { UrlReader } from '@backstage/backend-common'; import { Entity, EntityPolicy, + EntityRelationSpec, ENTITY_DEFAULT_NAMESPACE, LocationSpec, } from '@backstage/catalog-model'; @@ -61,7 +62,10 @@ export class LocationReaders implements LocationReader { async read(location: LocationSpec): Promise { const { rulesEnforcer, logger } = this.options; - const output: ReadLocationResult = { entities: [], errors: [] }; + const output: ReadLocationResult = { + entities: [], + errors: [], + }; let items: CatalogProcessorResult[] = [result.location(location, false)]; for (let depth = 0; depth < MAX_DEPTH; ++depth) { @@ -75,11 +79,21 @@ export class LocationReaders implements LocationReader { await this.handleData(item, emit); } else if (item.type === 'entity') { if (rulesEnforcer.isAllowed(item.entity, item.location)) { - const entity = await this.handleEntity(item, emit); + const relations = Array(); + + const entity = await this.handleEntity(item, emitResult => { + if (emitResult.type === 'relation') { + relations.push(emitResult.relation); + return; + } + emit(emitResult); + }); + if (entity) { output.entities.push({ entity, location: item.location, + relations, }); } } else { @@ -118,11 +132,23 @@ export class LocationReaders implements LocationReader { ) { const { processors, logger } = this.options; + const validatedEmit: CatalogProcessorEmit = emitResult => { + if (emitResult.type === 'relation') { + throw new Error('readLocation may not emit entity relations'); + } + + emit(emitResult); + }; + for (const processor of processors) { if (processor.readLocation) { try { if ( - await processor.readLocation(item.location, item.optional, emit) + await processor.readLocation( + item.location, + item.optional, + validatedEmit, + ) ) { return; } @@ -145,10 +171,20 @@ export class LocationReaders implements LocationReader { ) { const { processors, logger } = this.options; + const validatedEmit: CatalogProcessorEmit = emitResult => { + if (emitResult.type === 'relation') { + throw new Error('parseData may not emit entity relations'); + } + + emit(emitResult); + }; + for (const processor of processors) { if (processor.parseData) { try { - if (await processor.parseData(item.data, item.location, emit)) { + if ( + await processor.parseData(item.data, item.location, validatedEmit) + ) { return; } } catch (e) { @@ -189,7 +225,7 @@ export class LocationReaders implements LocationReader { ); } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`; - emit(result.generalError(item.location, message)); + emit(result.generalError(item.location, e.message)); logger.warn(message); return undefined; } @@ -207,7 +243,7 @@ export class LocationReaders implements LocationReader { current = next; } catch (e) { const message = `Policy check failed while analyzing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`; - emit(result.inputError(item.location, message)); + emit(result.inputError(item.location, e.message)); logger.warn(message); return undefined; } @@ -242,10 +278,18 @@ export class LocationReaders implements LocationReader { `Encountered error at location ${item.location.type} ${item.location.target}, ${item.error}`, ); + const validatedEmit: CatalogProcessorEmit = emitResult => { + if (emitResult.type === 'relation') { + throw new Error('handleError may not emit entity relations'); + } + + emit(emitResult); + }; + for (const processor of processors) { if (processor.handleError) { try { - await processor.handleError(item.error, item.location, emit); + await processor.handleError(item.error, item.location, validatedEmit); } catch (e) { const message = `Processor ${processor.constructor.name} threw an error while handling another error at ${item.location.type} ${item.location.target}, ${e}`; emit(result.generalError(item.location, message)); diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts index b6e65a7d7a..9b9ba586ef 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts @@ -15,8 +15,9 @@ */ import { LocationSpec } from '@backstage/catalog-model'; + import { Config } from '@backstage/config'; -import fetch, { HeadersInit, RequestInit } from 'node-fetch'; +import fetch from 'cross-fetch'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; @@ -44,11 +45,9 @@ export class AzureApiReaderProcessor implements CatalogProcessor { ).toString('base64')}`; } - const requestOptions: RequestInit = { + return { headers, }; - - return requestOptions; } async readLocation( @@ -67,7 +66,7 @@ export class AzureApiReaderProcessor implements CatalogProcessor { // for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html if (response.ok && response.status !== 203) { - const data = await response.buffer(); + const data = Buffer.from(await response.text()); emit(result.data(location, data)); } else { const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`; diff --git a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts index 4efcec2884..337053965f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BitbucketApiReaderProcessor.ts @@ -15,9 +15,9 @@ */ import { LocationSpec } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; -import fetch, { HeadersInit, RequestInit } from 'node-fetch'; +import fetch from 'cross-fetch'; import * as result from './results'; +import { Config } from '@backstage/config'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; // *********************************************************************** @@ -70,8 +70,8 @@ export class BitbucketApiReaderProcessor implements CatalogProcessor { const response = await fetch(url.toString(), this.getRequestOptions()); if (response.ok) { - const data = await response.buffer(); - emit(result.data(location, data)); + const data = await response.text(); + emit(result.data(location, Buffer.from(data))); } else { const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`; if (response.status === 404) { diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts index 622af9ea59..e65714bcfd 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts @@ -123,6 +123,10 @@ describe('CodeOwnersProcessor', () => { }); describe('normalizeCodeOwner', () => { + it('should remove the @ symbol', () => { + expect(normalizeCodeOwner('@yoda')).toBe('yoda'); + }); + it('should remove org from org/team format', () => { expect(normalizeCodeOwner('@acme/foo')).toBe('foo'); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts index bc78b6e34a..f186a9425c 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts @@ -127,6 +127,8 @@ export function findPrimaryCodeOwner( export function normalizeCodeOwner(owner: string) { if (owner.match(/^@.*\/.*/)) { return owner.split('/')[1]; + } else if (owner.match(/^@.*/)) { + return owner.substring(1); } else if (owner.match(/^.*@.*\..*$/)) { return owner.split('@')[0]; } diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts index 2f03900cdb..fed7f2a5c2 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts @@ -17,7 +17,7 @@ import { LocationSpec } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import parseGitUri from 'git-url-parse'; -import fetch, { HeadersInit, RequestInit } from 'node-fetch'; +import fetch from 'cross-fetch'; import { Logger } from 'winston'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; @@ -252,8 +252,8 @@ export class GithubReaderProcessor implements CatalogProcessor { const response = await fetch(url.toString(), options); if (response.ok) { - const data = await response.buffer(); - emit(result.data(location, data)); + const data = await response.text(); + emit(result.data(location, Buffer.from(data))); } else { const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`; if (response.status === 404) { diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts index e47b7d1aad..51a286c7ea 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts @@ -15,8 +15,8 @@ */ import { LocationSpec } from '@backstage/catalog-model'; +import fetch from 'cross-fetch'; import { Config } from '@backstage/config'; -import fetch, { HeadersInit, RequestInit } from 'node-fetch'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; @@ -61,8 +61,8 @@ export class GitlabApiReaderProcessor implements CatalogProcessor { const url = this.buildRawUrl(location.target, projectID); const response = await fetch(url.toString(), this.getRequestOptions()); if (response.ok) { - const data = await response.buffer(); - emit(result.data(location, data)); + const data = await response.text(); + emit(result.data(location, Buffer.from(data))); } else { const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`; if (response.status === 404) { diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts index 2c58a6d43e..38498b44fa 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabReaderProcessor.ts @@ -15,7 +15,7 @@ */ import { LocationSpec } from '@backstage/catalog-model'; -import fetch from 'node-fetch'; +import fetch from 'cross-fetch'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; @@ -40,8 +40,8 @@ export class GitlabReaderProcessor implements CatalogProcessor { const response = await fetch(url.toString()); if (response.ok) { - const data = await response.buffer(); - emit(result.data(location, data)); + const data = await response.text(); + emit(result.data(location, Buffer.from(data))); } else { const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`; if (response.status === 404) { diff --git a/plugins/catalog-backend/src/ingestion/processors/OwnerRelationProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/OwnerRelationProcessor.ts new file mode 100644 index 0000000000..4c67589789 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/OwnerRelationProcessor.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + ENTITY_DEFAULT_NAMESPACE, + LocationSpec, + parseEntityRef, + ApiEntityV1alpha1, + ComponentEntityV1alpha1, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + getEntityName, +} from '@backstage/catalog-model'; +import { CatalogProcessor, CatalogProcessorEmit } from './types'; +import * as result from './results'; + +const includedKinds = new Set(['api', 'component']); + +export class OwnerRelationProcessor implements CatalogProcessor { + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise { + if (!includedKinds.has(entity.kind.toLowerCase())) { + return entity; + } + const apiOrComponentEntity = entity as + | ApiEntityV1alpha1 + | ComponentEntityV1alpha1; + + const owner = apiOrComponentEntity.spec?.owner; + if (owner) { + const namespace = entity.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE; + + const selfRef = getEntityName(entity); + const ownerRef = parseEntityRef(owner, { + defaultKind: 'group', + defaultNamespace: namespace, + }); + + emit( + result.relation({ + source: selfRef, + type: RELATION_OWNED_BY, + target: ownerRef, + }), + ); + emit( + result.relation({ + source: ownerRef, + type: RELATION_OWNER_OF, + target: selfRef, + }), + ); + } + + return entity; + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts index a2b9678ba0..73046f0ed1 100644 --- a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -14,25 +14,23 @@ * limitations under the License. */ +import { UrlReaderProcessor } from './UrlReaderProcessor'; import { getVoidLogger, UrlReaders } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; import { CatalogProcessorDataResult, CatalogProcessorErrorResult, CatalogProcessorResult, } from './types'; -import { UrlReaderProcessor } from './UrlReaderProcessor'; describe('UrlReaderProcessor', () => { const mockApiOrigin = 'http://localhost:23000'; const server = setupServer(); - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); - + msw.setupDefaultHandlers(server); it('should load from url', async () => { const logger = getVoidLogger(); const reader = UrlReaders.default({ logger, config: new ConfigReader({}) }); diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index 59a87af555..92303f04a3 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -28,6 +28,7 @@ export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor'; export { GithubReaderProcessor } from './GithubReaderProcessor'; export { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor'; export { GitlabReaderProcessor } from './GitlabReaderProcessor'; +export { OwnerRelationProcessor } from './OwnerRelationProcessor'; export { LocationRefProcessor } from './LocationEntityProcessor'; export { PlaceholderProcessor } from './PlaceholderProcessor'; export type { PlaceholderResolver } from './PlaceholderProcessor'; diff --git a/plugins/catalog-backend/src/ingestion/processors/results.ts b/plugins/catalog-backend/src/ingestion/processors/results.ts index 2718d871dd..158bc3d950 100644 --- a/plugins/catalog-backend/src/ingestion/processors/results.ts +++ b/plugins/catalog-backend/src/ingestion/processors/results.ts @@ -15,7 +15,11 @@ */ import { InputError, NotFoundError } from '@backstage/backend-common'; -import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { + Entity, + EntityRelationSpec, + LocationSpec, +} from '@backstage/catalog-model'; import { CatalogProcessorResult } from './types'; export function notFoundError( @@ -67,3 +71,7 @@ export function entity( ): CatalogProcessorResult { return { type: 'entity', location: atLocation, entity: newEntity }; } + +export function relation(spec: EntityRelationSpec): CatalogProcessorResult { + return { type: 'relation', relation: spec }; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/types.ts b/plugins/catalog-backend/src/ingestion/processors/types.ts index c9adf07eff..7821a61437 100644 --- a/plugins/catalog-backend/src/ingestion/processors/types.ts +++ b/plugins/catalog-backend/src/ingestion/processors/types.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { + Entity, + EntityRelationSpec, + LocationSpec, +} from '@backstage/catalog-model'; export type CatalogProcessor = { /** @@ -113,6 +117,12 @@ export type CatalogProcessorEntityResult = { location: LocationSpec; }; +export type CatalogProcessorRelationResult = { + type: 'relation'; + relation: EntityRelationSpec; + entityRef?: string; +}; + export type CatalogProcessorErrorResult = { type: 'error'; error: Error; @@ -123,4 +133,5 @@ export type CatalogProcessorResult = | CatalogProcessorLocationResult | CatalogProcessorDataResult | CatalogProcessorEntityResult + | CatalogProcessorRelationResult | CatalogProcessorErrorResult; diff --git a/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts index 25a37e0546..54afdd1bdc 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/github.test.ts @@ -17,6 +17,7 @@ import { graphql } from '@octokit/graphql'; import { graphql as graphqlMsw } from 'msw'; import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; import { getOrganizationTeams, getOrganizationUsers, @@ -26,9 +27,7 @@ import { describe('github', () => { const server = setupServer(); - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); + msw.setupDefaultHandlers(server); describe('getOrganizationUsers', () => { it('reads members', async () => { diff --git a/plugins/catalog-backend/src/ingestion/types.ts b/plugins/catalog-backend/src/ingestion/types.ts index 5bbec01e7e..c586e2d438 100644 --- a/plugins/catalog-backend/src/ingestion/types.ts +++ b/plugins/catalog-backend/src/ingestion/types.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import type { Entity, Location, LocationSpec } from '@backstage/catalog-model'; +import type { + Entity, + EntityRelationSpec, + Location, + LocationSpec, +} from '@backstage/catalog-model'; // // HigherOrderOperation @@ -53,6 +58,7 @@ export type ReadLocationResult = { export type ReadLocationEntity = { location: LocationSpec; entity: Entity; + relations: EntityRelationSpec[]; }; export type ReadLocationError = { diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts index 3f7c613c00..bc71934488 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts @@ -128,12 +128,16 @@ describe('CatalogBuilder', () => { { apiVersion: 'av', kind: 'Component', - metadata: expect.objectContaining({ + metadata: { name: 'n', namespace: 'ns', post: 'p', replaced: 'tt2', - }), + uid: expect.any(String), + etag: expect.any(String), + generation: expect.any(Number), + }, + relations: [], }, ]); }); diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index e6fb540f16..4ec2a0834d 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -52,6 +52,7 @@ import { GithubReaderProcessor, GitlabApiReaderProcessor, GitlabReaderProcessor, + OwnerRelationProcessor, HigherOrderOperation, HigherOrderOperations, LocationReaders, @@ -334,6 +335,7 @@ export class CatalogBuilder { new YamlProcessor(), new CodeOwnersProcessor({ reader }), new LocationRefProcessor(), + new OwnerRelationProcessor(), new AnnotateLocationEntityProcessor(), ]; diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index 414f8a93de..cf843acf3c 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -32,8 +32,6 @@ describe('createRouter', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - addOrUpdateEntity: jest.fn(), - addEntities: jest.fn(), removeEntityByUid: jest.fn(), batchAddOrUpdateEntities: jest.fn(), }; @@ -172,7 +170,7 @@ describe('createRouter', () => { .set('Content-Type', 'application/json') .send(); - expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled(); + expect(entitiesCatalog.batchAddOrUpdateEntities).not.toHaveBeenCalled(); expect(response.status).toEqual(400); expect(response.text).toMatch(/body/); }); @@ -187,18 +185,24 @@ describe('createRouter', () => { }, }; - entitiesCatalog.addOrUpdateEntity.mockResolvedValue(entity); + entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([ + { entityId: 'u' }, + ]); + entitiesCatalog.entities.mockResolvedValue([entity]); const response = await request(app) .post('/entities') .send(entity) .set('Content-Type', 'application/json'); - expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith( - 1, - entity, - ); + expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.batchAddOrUpdateEntities).toHaveBeenCalledWith([ + { entity, relations: [] }, + ]); + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + 'metadata.uid': 'u', + }); expect(response.status).toEqual(200); expect(response.body).toEqual(entity); }); diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index a262e9b5f4..c7d05afb2d 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -49,8 +49,13 @@ export async function createRouter( }) .post('/entities', async (req, res) => { const body = await requireRequestBody(req); - const result = await entitiesCatalog.addOrUpdateEntity(body as Entity); - res.status(200).send(result); + const [result] = await entitiesCatalog.batchAddOrUpdateEntities([ + { entity: body as Entity, relations: [] }, + ]); + const [entity] = await entitiesCatalog.entities({ + 'metadata.uid': result.entityId, + }); + res.status(200).send(entity); }) .get('/entities/by-uid/:uid', async (req, res) => { const { uid } = req.params; diff --git a/plugins/catalog-backend/src/service/standaloneServer.ts b/plugins/catalog-backend/src/service/standaloneServer.ts index 713a5783ba..73a33c8c8e 100644 --- a/plugins/catalog-backend/src/service/standaloneServer.ts +++ b/plugins/catalog-backend/src/service/standaloneServer.ts @@ -20,7 +20,6 @@ import { UrlReaders, useHotMemoize, } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; import { Server } from 'http'; import { Logger } from 'winston'; import { DatabaseManager } from '../database'; @@ -37,7 +36,7 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'catalog-backend' }); - const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const config = await loadBackendConfig({ logger, argv: process.argv }); const reader = UrlReaders.default({ logger, config }); const db = useHotMemoize(module, () => DatabaseManager.createInMemoryDatabaseConnection(), diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 73832bb8ef..3c8c45d246 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -28,19 +28,20 @@ "graphql": "^15.3.0", "graphql-tag": "^2.11.0", "graphql-type-json": "^0.3.2", - "node-fetch": "^2.6.0", + "cross-fetch": "^3.0.6", "winston": "^3.2.1" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", + "@backstage/test-utils": "^0.1.1-alpha.25", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", "@graphql-codegen/typescript-resolvers": "^1.17.7", "@types/express": "^4.17.7", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", - "msw": "^0.20.5", "supertest": "^4.0.2", + "msw": "^0.21.2", "ts-node": "^8.10.2" }, "files": [ diff --git a/plugins/catalog-graphql/src/graphql/module.test.ts b/plugins/catalog-graphql/src/graphql/module.test.ts index e3e03a9b4a..b359c4396b 100644 --- a/plugins/catalog-graphql/src/graphql/module.test.ts +++ b/plugins/catalog-graphql/src/graphql/module.test.ts @@ -21,6 +21,7 @@ import { setupServer } from 'msw/node'; import { ConfigReader } from '@backstage/config'; import { ReaderEntity } from '../service/client'; import { createLogger } from 'winston'; +import { msw } from '@backstage/test-utils'; import { gql } from 'apollo-server'; describe('Catalog Module', () => { @@ -37,9 +38,7 @@ describe('Catalog Module', () => { }, ]); - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterAll(() => worker.close()); - afterEach(() => worker.resetHandlers()); + msw.setupDefaultHandlers(worker); describe('Default Entity', () => { beforeEach(() => { diff --git a/plugins/catalog-graphql/src/service/client.test.ts b/plugins/catalog-graphql/src/service/client.test.ts index d8cc59d1a8..c9708af2d0 100644 --- a/plugins/catalog-graphql/src/service/client.test.ts +++ b/plugins/catalog-graphql/src/service/client.test.ts @@ -16,13 +16,11 @@ import { CatalogClient } from './client'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; describe('Catalog GraphQL Module', () => { const worker = setupServer(); - - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterAll(() => worker.close()); - afterEach(() => worker.resetHandlers()); + msw.setupDefaultHandlers(worker); const baseUrl = 'http://localhost:1234'; diff --git a/plugins/catalog-graphql/src/service/client.ts b/plugins/catalog-graphql/src/service/client.ts index 8af97a89b1..59f9e90254 100644 --- a/plugins/catalog-graphql/src/service/client.ts +++ b/plugins/catalog-graphql/src/service/client.ts @@ -15,7 +15,7 @@ */ import { Entity, EntityMeta } from '@backstage/catalog-model'; -import fetch from 'node-fetch'; +import fetch from 'cross-fetch'; import { JsonObject } from '@backstage/config'; export interface ReaderEntityMeta extends EntityMeta { diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index b29a93e69d..3d0c932cdd 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -50,11 +50,9 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1", + "msw": "^0.21.2", "react-test-renderer": "^16.13.1", - "whatwg-fetch": "^3.4.0" + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/plugins/catalog/src/api/CatalogClient.test.ts b/plugins/catalog/src/api/CatalogClient.test.ts index 832a114276..7c0123317d 100644 --- a/plugins/catalog/src/api/CatalogClient.test.ts +++ b/plugins/catalog/src/api/CatalogClient.test.ts @@ -19,18 +19,17 @@ import { setupServer } from 'msw/node'; import { CatalogClient } from './CatalogClient'; import { Entity } from '@backstage/catalog-model'; import { UrlPatternDiscovery } from '@backstage/core'; +import { msw } from '@backstage/test-utils'; const server = setupServer(); const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); describe('CatalogClient', () => { - beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); - let client = new CatalogClient({ discoveryApi }); + msw.setupDefaultHandlers(server); + beforeEach(() => { client = new CatalogClient({ discoveryApi }); }); diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 9ba0e56bc1..6b10541107 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -109,13 +109,15 @@ export class CatalogClient implements CatalogApi { const { location, entities } = await response.json(); - if (!location) throw new Error(`Location wasn't added: ${target}`); + if (!location) { + throw new Error(`Location wasn't added: ${target}`); + } - if (entities.length === 0) + if (entities.length === 0) { throw new Error( `Location was added but has no entities specified yet: ${target}`, ); - + } return { location, entities, diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 266d5fa843..fbbf5fa11c 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { + Entity, + ENTITY_DEFAULT_NAMESPACE, + RELATION_OWNED_BY, + serializeEntityRef, +} from '@backstage/catalog-model'; import { Card, CardContent, @@ -26,6 +31,7 @@ import { makeStyles, Typography, } from '@material-ui/core'; +import ExtensionIcon from '@material-ui/icons/Extension'; import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; import GitHubIcon from '@material-ui/icons/GitHub'; @@ -120,6 +126,12 @@ export function AboutCard({ entity, variant }: AboutCardProps) { entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE }/${entity.kind}/${entity.metadata.name}`} /> + } + href="api" + /> } /> @@ -139,7 +151,20 @@ export function AboutCard({ entity, variant }: AboutCardProps) { r.type === RELATION_OWNED_BY) + .map(({ target: { kind, name, namespace } }) => + // TODO(Rugvip): we want to provide some utils for this + serializeEntityRef({ + kind, + name, + namespace: + namespace === ENTITY_DEFAULT_NAMESPACE + ? undefined + : namespace, + }), + ) + .join(', ')} gridSizes={{ xs: 12, sm: 6, lg: 4 }} /> + {icon} + {props.label} + + ); + } + return ( - + {icon} {props.label} diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index f5885145b3..4e63c6099b 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -155,7 +155,8 @@ export const CatalogTable = ({ isLoading={loading} columns={columns} options={{ - paging: false, + paging: true, + pageSize: 10, actionsColumnIndex: -1, loadingType: 'linear', showEmptyDataSourceMessage: !loading, diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index e3a225a03e..fa5a6292af 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -18,7 +18,7 @@ import { useParams, useNavigate } from 'react-router'; import { EntityContext } from '../../hooks/useEntity'; import { Page, Header, HeaderLabel, Content, Progress } from '@backstage/core'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { Box } from '@material-ui/core'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; @@ -46,7 +46,11 @@ function headerProps( entity: Entity | undefined, ): { headerTitle: string; headerType: string } { return { - headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`, + headerTitle: `${name}${ + namespace && namespace !== ENTITY_DEFAULT_NAMESPACE + ? ` in ${namespace}` + : '' + }`, headerType: (() => { let t = kind.toLowerCase(); if (entity && entity.spec && 'type' in entity.spec) { diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index c6e706c15b..0a002ba907 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -31,7 +31,7 @@ const DefaultEntityPage = () => ( title="Overview" element={ - This is default entity page. + This is the default entity page. To override this component with your custom implementation, read docs on{' '} @@ -62,9 +62,9 @@ export const Router = ({ EntityPage?: ComponentType; }) => ( - } /> + } /> diff --git a/plugins/catalog/src/setupTests.ts b/plugins/catalog/src/setupTests.ts index 4016f3f38e..aea2220869 100644 --- a/plugins/catalog/src/setupTests.ts +++ b/plugins/catalog/src/setupTests.ts @@ -15,4 +15,4 @@ */ import '@testing-library/jest-dom'; -import 'whatwg-fetch'; +import 'cross-fetch/polyfill'; diff --git a/plugins/circleci/README.md b/plugins/circleci/README.md index d329768514..00a39ce6c8 100644 --- a/plugins/circleci/README.md +++ b/plugins/circleci/README.md @@ -50,7 +50,7 @@ import { Router as CircleCIRouter } from '@backstage/plugin-circleci'; 4. Add proxy config: -``` +```yaml // app-config.yaml proxy: '/circleci/api': @@ -63,6 +63,19 @@ proxy: 5. Get and provide `CIRCLECI_AUTH_TOKEN` as env variable (https://circleci.com/docs/api/#add-an-api-token) 6. Add `circleci.com/project-slug` annotation to your component-info.yaml file in format // (https://backstage.io/docs/architecture-decisions/adrs-adr002#format) +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + # ... + annotations: + # This also supports bitbucket/xxx/yyy + circleci.com/project-slug: github/my-org/my-repo +spec: + type: service + # ... +``` + ## Features - List top 50 builds for a project diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 64684b0f99..464bb41441 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -46,9 +46,9 @@ "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react-lazylog": "^4.5.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/circleci/src/setupTests.ts b/plugins/circleci/src/setupTests.ts index a838246198..8925258421 100644 --- a/plugins/circleci/src/setupTests.ts +++ b/plugins/circleci/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom/extend-expect'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index d36b661664..f8bf338dd1 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -46,9 +46,9 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/cloudbuild/src/setupTests.ts b/plugins/cloudbuild/src/setupTests.ts index 4b4cdbdaaf..0bfa67b49a 100644 --- a/plugins/cloudbuild/src/setupTests.ts +++ b/plugins/cloudbuild/src/setupTests.ts @@ -14,5 +14,3 @@ * limitations under the License. */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 68561fc041..e79c4ab962 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -40,7 +40,8 @@ "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", "react-use": "^15.3.3", - "recharts": "^1.8.5" + "recharts": "^1.8.5", + "yup": "^0.29.3" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", @@ -50,9 +51,11 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "@types/yup": "^0.29.8", + "@types/recharts": "^1.8.14", + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 028aead853..be5cd54fdb 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -27,6 +27,13 @@ import { } from '../types'; export type CostInsightsApi = { + /** + * Get the most current date for which billing data is complete, in YYYY-MM-DD format. This helps + * define the intervals used in other API methods to avoid showing incomplete cost. The costs for + * today, for example, will not be complete. This ideally comes from the cloud provider. + */ + getLastCompleteBillingDate(): Promise; + /** * Get a list of groups the given user belongs to. These may be LDAP groups or similar * organizational groups. Cost Insights is designed to show costs based on group membership; diff --git a/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx index 82954baa86..e230e94130 100644 --- a/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx +++ b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ReactNode } from 'react'; +import React, { PropsWithChildren } from 'react'; import { Box, Button, Container, makeStyles } from '@material-ui/core'; import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; import { Header, Page } from '@backstage/core'; @@ -30,13 +30,12 @@ const useStyles = makeStyles(theme => ({ type AlertInstructionsLayoutProps = { title: string; - children: ReactNode; }; const AlertInstructionsLayout = ({ title, children, -}: AlertInstructionsLayoutProps) => { +}: PropsWithChildren) => { const classes = useStyles(); return ( diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx index 3e12c18f90..9e49cb14dd 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { Box } from '@material-ui/core'; import { useBarChartLabelStyles } from '../../utils/styles'; @@ -23,10 +23,15 @@ type BarChartLabel = { y: number; height: number; width: number; - children?: React.ReactNode; }; -const BarChartLabel = ({ x, y, height, width, children }: BarChartLabel) => { +const BarChartLabel = ({ + x, + y, + height, + width, + children, +}: PropsWithChildren) => { const classes = useBarChartLabelStyles(); const translateX = width * -0.5; const childArray = React.Children.toArray(children); diff --git a/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx index b668ffaa5c..66d1424576 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx @@ -14,18 +14,21 @@ * limitations under the License. */ -import React, { forwardRef, Ref } from 'react'; +import React, { forwardRef, PropsWithChildren, Ref } from 'react'; import { ButtonBase, ButtonBaseProps } from '@material-ui/core'; import { useBarChartStepperButtonStyles as useStyles } from '../../utils/styles'; interface BarChartStepperButtonProps extends ButtonBaseProps { name: string; - children?: React.ReactNode; } const BarChartStepperButton = forwardRef( ( - { name, children, ...buttonBaseProps }: BarChartStepperButtonProps, + { + name, + children, + ...buttonBaseProps + }: PropsWithChildren, ref: Ref, ) => { const classes = useStyles(); diff --git a/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx index e2ca0fca93..1e5a56f1ff 100644 --- a/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx +++ b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { makeStyles } from '@material-ui/core'; import { Header, Page } from '@backstage/core'; import { Group } from '../../types'; @@ -34,10 +34,12 @@ const useStyles = makeStyles(theme => ({ type CostInsightsLayoutProps = { title?: string; groups: Group[]; - children?: React.ReactNode; }; -const CostInsightsLayout = ({ groups, children }: CostInsightsLayoutProps) => { +const CostInsightsLayout = ({ + groups, + children, +}: PropsWithChildren) => { const classes = useStyles(); return ( diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index 77d9f5a05e..8e96a6f5a4 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -16,7 +16,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Box, Container, Divider, Grid, Typography } from '@material-ui/core'; -import { Progress, useApi, featureFlagsApiRef } from '@backstage/core'; +import { featureFlagsApiRef, Progress, useApi } from '@backstage/core'; import { default as MaterialAlert } from '@material-ui/lab/Alert'; import { costInsightsApiRef } from '../../api'; import AlertActionCardList from '../AlertActionCardList'; @@ -33,11 +33,12 @@ import CostOverviewCard from '../CostOverviewCard'; import ProductInsights from '../ProductInsights'; import CostInsightsSupportButton from '../CostInsightsSupportButton'; import { - useLoading, + useConfig, + useCurrency, useFilters, useGroups, - useCurrency, - useConfig, + useLastCompleteBillingDate, + useLoading, } from '../../hooks'; import { Alert, @@ -49,14 +50,17 @@ import { } from '../../types'; import { mapLoadingToProps } from './selector'; import ProjectSelect from '../ProjectSelect'; +import { useSubtleTypographyStyles } from '../../utils/styles'; const CostInsightsPage = () => { + const classes = useSubtleTypographyStyles(); const flags = useApi(featureFlagsApiRef).getFlags(); // There is not currently a UI to set feature flags // flags.set('cost-insights-currencies', FeatureFlagState.On); const client = useApi(costInsightsApiRef); const config = useConfig(); const groups = useGroups(); + const lastCompleteBillingDate = useLastCompleteBillingDate(); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); const [dailyCost, setDailyCost] = useState>(null); @@ -69,6 +73,7 @@ const CostInsightsPage = () => { const { loadingActions, loadingGroups, + loadingBillingDate, loadingInitial, dispatchInitial, dispatchInsights, @@ -101,7 +106,10 @@ const CostInsightsPage = () => { try { if (pageFilters.group) { dispatchLoadingInsights(true); - const intervals = intervalsOf(pageFilters.duration); + const intervals = intervalsOf( + pageFilters.duration, + lastCompleteBillingDate, + ); const [ fetchedProjects, fetchedAlerts, @@ -133,8 +141,8 @@ const CostInsightsPage = () => { } } - // Wait for user groups to finish loading - if (!loadingGroups) { + // Wait for metadata to finish loading + if (!(loadingGroups && loadingBillingDate)) { getInsights(); } }, [ @@ -142,9 +150,11 @@ const CostInsightsPage = () => { pageFilters, loadingActions, loadingGroups, + loadingBillingDate, dispatchLoadingInsights, dispatchLoadingInitial, dispatchLoadingNone, + lastCompleteBillingDate, ]); if (loadingInitial) { @@ -195,6 +205,9 @@ const CostInsightsPage = () => { > Cost Overview + + Billing data as of {lastCompleteBillingDate} + {!!flags.get('cost-insights-currencies') && ( diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx index b2276481b2..6dca7982db 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx @@ -22,6 +22,7 @@ import { GroupsProvider } from '../../hooks/useGroups'; import { CurrencyProvider } from '../../hooks/useCurrency'; import { ScrollProvider } from '../../hooks/useScroll'; import { ConfigProvider } from '../../hooks/useConfig'; +import { BillingDateProvider } from '../../hooks/useLastCompleteBillingDate'; import { CostInsightsThemeProvider } from './CostInsightsThemeProvider'; const CostInsightsPageRoot = () => ( @@ -29,13 +30,15 @@ const CostInsightsPageRoot = () => ( - - - - - - - + + + + + + + + + diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx index cecb076d35..a96f0cb4d3 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { createMuiTheme, ThemeProvider } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; import { @@ -23,13 +23,9 @@ import { } from '../../utils/styles'; import { CostInsightsTheme } from '../../types'; -interface CostInsightsThemeProviderProps { - children?: React.ReactNode; -} - export const CostInsightsThemeProvider = ({ children, -}: CostInsightsThemeProviderProps) => { +}: PropsWithChildren<{}>) => { return ( diff --git a/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx index 8a9cf021c9..a73c0ff975 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/selector.tsx @@ -23,6 +23,7 @@ import { type CostInsightsPageLoadingProps = { loadingActions: Array; loadingGroups: boolean; + loadingBillingDate: boolean; loadingInitial: boolean; dispatchInitial: (isLoading: boolean) => void; dispatchInsights: (isLoading: boolean) => void; @@ -37,6 +38,7 @@ export const mapLoadingToProps: MapLoadingToProps }) => ({ loadingActions: actions, loadingGroups: state[DefaultLoadingAction.UserGroups], + loadingBillingDate: state[DefaultLoadingAction.LastCompleteBillingDate], loadingInitial: state[DefaultLoadingAction.CostInsightsInitial], dispatchInitial: (isLoading: boolean) => dispatch({ [DefaultLoadingAction.CostInsightsInitial]: isLoading }), diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx index 5de90d155a..76a0d944de 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx @@ -13,20 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { Box, Typography } from '@material-ui/core'; type CostOverviewHeaderProps = { title: string; subtitle?: string; - children?: React.ReactNode; }; const CostOverviewHeader = ({ title, subtitle, children, -}: CostOverviewHeaderProps) => ( +}: PropsWithChildren) => ( { +}: PropsWithChildren) => { const classes = useCostGrowthLegendStyles(); return ( diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx index c632ebad34..b5381cdd84 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -17,32 +17,41 @@ import React from 'react'; import { getByRole, waitFor } from '@testing-library/react'; import UserEvent from '@testing-library/user-event'; -import PeriodSelect, { DEFAULT_OPTIONS as options } from './PeriodSelect'; +import PeriodSelect, { getDefaultOptions } from './PeriodSelect'; import { Duration, getDefaultPageFilters, Group } from '../../types'; import { renderInTestApp } from '@backstage/test-utils'; +import { MockBillingDateProvider } from '../../utils/tests'; const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]); - -Date.now = jest.fn(() => new Date(Date.parse('2020-05-01')).valueOf()); +const lastCompleteBillingDate = '2020-05-01'; +const options = getDefaultOptions(lastCompleteBillingDate); describe('', () => { it('Renders without exploding', async () => { const rendered = await renderInTestApp( - , + + + , ); expect(rendered.getByTestId('period-select')).toBeInTheDocument(); }); it('Should display all costGrowth period options', async () => { const rendered = await renderInTestApp( - , + + + , ); const periodSelectContainer = rendered.getByTestId('period-select'); const button = getByRole(periodSelectContainer, 'button'); @@ -70,7 +79,11 @@ describe('', () => { : DefaultPageFilters.duration; const rendered = await renderInTestApp( - , + + , + , ); const periodSelect = rendered.getByTestId('period-select'); const button = getByRole(periodSelect, 'button'); diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx index 688e22452e..08cf8a73f9 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -22,35 +22,35 @@ import { } from '../../utils/formatters'; import { Duration, findAlways } from '../../types'; import { useSelectStyles as useStyles } from '../../utils/styles'; +import { useLastCompleteBillingDate } from '../../hooks'; export type PeriodOption = { value: Duration; label: string; }; -const LAST_6_MONTHS = 'Past 6 Months'; -const LAST_60_DAYS = 'Past 60 Days'; -const LAST_2_COMPLETED_MONTHS = formatLastTwoMonths(); -const LAST_2_LOOKAHEAD_QUARTERS = formatLastTwoLookaheadQuarters(); - -export const DEFAULT_OPTIONS: PeriodOption[] = [ - { - value: Duration.P90D, - label: LAST_6_MONTHS, - }, - { - value: Duration.P30D, - label: LAST_60_DAYS, - }, - { - value: Duration.P1M, - label: LAST_2_COMPLETED_MONTHS, - }, - { - value: Duration.P3M, - label: LAST_2_LOOKAHEAD_QUARTERS, - }, -]; +export function getDefaultOptions( + lastCompleteBillingDate: string, +): PeriodOption[] { + return [ + { + value: Duration.P90D, + label: 'Past 6 Months', + }, + { + value: Duration.P30D, + label: 'Past 60 Days', + }, + { + value: Duration.P1M, + label: formatLastTwoMonths(lastCompleteBillingDate), + }, + { + value: Duration.P3M, + label: formatLastTwoLookaheadQuarters(lastCompleteBillingDate), + }, + ]; +} type PeriodSelectProps = { duration: Duration; @@ -58,19 +58,18 @@ type PeriodSelectProps = { options?: PeriodOption[]; }; -const PeriodSelect = ({ - duration, - onSelect, - options = DEFAULT_OPTIONS, -}: PeriodSelectProps) => { +const PeriodSelect = ({ duration, onSelect, options }: PeriodSelectProps) => { const classes = useStyles(); + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const optionsOrDefault = + options ?? getDefaultOptions(lastCompleteBillingDate); const handleOnChange: SelectProps['onChange'] = e => { onSelect(e.target.value as Duration); }; const renderValue: SelectProps['renderValue'] = value => { - const option = findAlways(DEFAULT_OPTIONS, o => o.value === value); + const option = findAlways(optionsOrDefault, o => o.value === value); return {option.label}; }; @@ -83,7 +82,7 @@ const PeriodSelect = ({ renderValue={renderValue} data-testid="period-select" > - {options.map(option => ( + {optionsOrDefault.map(option => ( ({ const renderProductInsightsCardInTestApp = async ( productCost: ProductCost, product: Product, + duration: Duration, ) => await renderInTestApp( @@ -102,19 +105,22 @@ const renderProductInsightsCardInTestApp = async ( }} > - - - - - - - + + + + + + + + + @@ -126,6 +132,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( mockProductCost, MockComputeEngine, + Duration.P1M, ); expect( rendered.queryByTestId(`scroll-test-compute-engine`), @@ -140,6 +147,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( productCost, MockComputeEngine, + Duration.P1M, ); const subheader = 'entities, sorted by cost'; const subheaderRgx = new RegExp( @@ -154,6 +162,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( productCost, MockComputeEngine, + Duration.P1M, ); const subheaderRgx = new RegExp(subheader); expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); @@ -164,4 +173,27 @@ describe('', () => { rendered.queryByTestId('.insights-bar-chart'), ).not.toBeInTheDocument(); }); + + describe.each` + duration | periodStartText | periodEndText + ${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'} + ${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'} + `( + 'Should display the correct relative time', + ({ duration, periodStartText, periodEndText }) => { + it(`Should display the correct relative time for ${duration}`, async () => { + const productCost = { + ...mockProductCost, + entities: [...Array(3)].map(createMockEntity), + }; + const rendered = await renderProductInsightsCardInTestApp( + productCost, + MockComputeEngine, + duration, + ); + expect(rendered.getByText(periodStartText)).toBeInTheDocument(); + expect(rendered.getByText(periodEndText)).toBeInTheDocument(); + }); + }, + ); }); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index c9790a5bfa..bf56eb23e8 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -22,11 +22,17 @@ import { costInsightsApiRef } from '../../api'; import PeriodSelect from '../PeriodSelect'; import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; -import { useFilters, useLoading, useScroll } from '../../hooks'; +import { + useFilters, + useLastCompleteBillingDate, + useLoading, + useScroll, +} from '../../hooks'; import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; import { mapFiltersToProps, mapLoadingToProps } from './selector'; import { Duration, Maybe, Product, ProductCost } from '../../types'; import { pluralOf } from '../../utils/grammar'; +import { formatPeriod } from '../../utils/formatters'; type ProductInsightsCardProps = { product: Product; @@ -36,6 +42,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { const client = useApi(costInsightsApiRef); const classes = useStyles(); const { ScrollAnchor } = useScroll(product.kind); + const lastCompleteBillingDate = useLastCompleteBillingDate(); const [resource, setResource] = useState>(null); const [error, setError] = useState>(null); @@ -51,7 +58,18 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { const dispatchLoadingProduct = useCallback(dispatchLoading, [product.kind]); const amount = resource?.entities?.length || 0; - const hasCostsWithinTimeframe = resource?.change && amount; + const hasCostsWithinTimeframe = resource?.change && !!amount; + + const previousName = formatPeriod( + productFilter.duration, + lastCompleteBillingDate, + false, + ); + const currentName = formatPeriod( + productFilter.duration, + lastCompleteBillingDate, + true, + ); const subheader = amount ? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost` @@ -131,12 +149,15 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx index b914b3885d..dd70433003 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -18,7 +18,11 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import ProjectGrowthAlertCard from './ProjectGrowthAlertCard'; import { createMockProjectGrowthData } from '../../utils/mockData'; -import { MockCurrencyProvider, MockConfigProvider } from '../../utils/tests'; +import { + MockCurrencyProvider, + MockConfigProvider, + MockBillingDateProvider, +} from '../../utils/tests'; import { AlertCost, defaultCurrencies, findAlways } from '../../types'; const engineers = findAlways(defaultCurrencies, c => c.kind === null); @@ -49,13 +53,18 @@ describe('', () => { engineerCost={200_000} currencies={[]} > - - , - + + + , + + , ); expect(rendered.getByText(title)).toBeInTheDocument(); expect(rendered.getByText(subheader)).toBeInTheDocument(); + // ISO 8601 quarter format (YYYY-QX) should be transformed to QX YYYY + expect(rendered.getByText('Q4 2019')).toBeInTheDocument(); + expect(rendered.getByText('Q1 2020')).toBeInTheDocument(); }); it('renders the correct title and subheader for a single service', async () => { @@ -69,14 +78,16 @@ describe('', () => { engineerCost={200_000} currencies={[]} > - - - + + + + + , ); expect(rendered.getByText(title)).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx index 8d1d1d29b1..1b37a5a131 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -15,6 +15,7 @@ */ import React from 'react'; +import moment from 'moment'; import { Box } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; @@ -33,6 +34,10 @@ const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ alert.products.length > 1 ? ', sorted by cost' : '' }`; + const previousName = moment(alert.periodStart, 'YYYY-[Q]Q').format( + '[Q]Q YYYY', + ); + const currentName = moment(alert.periodEnd, 'YYYY-[Q]Q').format('[Q]Q YYYY'); return ( { diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx index cde989c9dd..634514c1df 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -160,14 +160,17 @@ const ProjectGrowthInstructionsPage = () => { diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx index 8355deab7d..9a3899f2ff 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx @@ -16,7 +16,6 @@ import React from 'react'; import ResourceGrowthBarChart from './ResourceGrowthBarChart'; -import { Duration } from '../../types'; import { renderInTestApp } from '@backstage/test-utils'; import { createMockEntity } from '../../utils/mockData'; @@ -32,8 +31,9 @@ describe('', () => { it('Pre-renders without exploding', async () => { const rendered = await renderInTestApp( , ); expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx index 3ae35ba871..daa6d88683 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx @@ -16,41 +16,33 @@ import React from 'react'; import { TooltipPayload } from 'recharts'; +import { currencyFormatter } from '../../utils/formatters'; import { - currencyFormatter, - dateRegex, - formatDuration, -} from '../../utils/formatters'; -import { + AlertCost, BarChartData, CostInsightsTheme, DataKey, - Duration, Entity, - inclusiveEndDateOf, - inclusiveStartDateOf, Maybe, ResourceData, - AlertCost, } from '../../types'; import BarChart from '../BarChart'; import { TooltipItemProps } from '../Tooltip'; import { useTheme } from '@material-ui/core'; export type ResourceGrowthBarChartProps = { - duration: Duration; resources: Array; + previousName: string; + currentName: string; }; const ResourceGrowthBarChart = ({ - duration, resources, + previousName, + currentName, }: ResourceGrowthBarChartProps) => { const theme = useTheme(); const getTooltipItem = (payload: TooltipPayload): Maybe => { - const label = dateRegex.test(payload.name) - ? formatDuration(payload.name, duration) - : payload.name; const value = typeof payload.value === 'number' ? currencyFormatter.format(payload.value) @@ -61,7 +53,7 @@ const ResourceGrowthBarChart = ({ case DataKey.Current: case DataKey.Previous: return { - label: label, + label: payload.name, value: value, fill: fill, }; @@ -73,8 +65,8 @@ const ResourceGrowthBarChart = ({ const barChartData: BarChartData = { previousFill: theme.palette.lightBlue, currentFill: theme.palette.darkBlue, - previousName: inclusiveStartDateOf(duration), - currentName: inclusiveEndDateOf(duration), + previousName: previousName, + currentName: currentName, }; const resourceData: ResourceData[] = resources.map(resource => { diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx index 1e432ff3bc..ecc601a0b7 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx @@ -56,6 +56,8 @@ describe('', () => { @@ -66,28 +68,4 @@ describe('', () => { }); }, ); - - describe.each` - duration | periodStartText | periodEndText - ${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'} - ${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'} - `( - 'Should display the correct relative time', - ({ duration, periodStartText, periodEndText }) => { - it(`Should display the correct relative time for ${duration}`, async () => { - const rendered = await renderInTestApp( - - - , - ); - expect(rendered.getByText(periodStartText)).toBeInTheDocument(); - expect(rendered.getByText(periodEndText)).toBeInTheDocument(); - }); - }, - ); }); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx index 1466e18ee1..9b0f77ceda 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx @@ -18,18 +18,14 @@ import React from 'react'; import { Box, useTheme } from '@material-ui/core'; import LegendItem from '../LegendItem'; import CostGrowth from '../CostGrowth'; -import { currencyFormatter, formatDuration } from '../../utils/formatters'; -import { - ChangeStatistic, - CostInsightsTheme, - Duration, - inclusiveEndDateOf, - inclusiveStartDateOf, -} from '../../types'; +import { currencyFormatter } from '../../utils/formatters'; +import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types'; export type ResourceGrowthBarChartLegendProps = { change: ChangeStatistic; duration: Duration; + previousName: string; + currentName: string; costStart: number; costEnd: number; }; @@ -37,28 +33,22 @@ export type ResourceGrowthBarChartLegendProps = { const ResourceGrowthBarChartLegend = ({ change, duration, + previousName, + currentName, costStart, costEnd, }: ResourceGrowthBarChartLegendProps) => { const theme = useTheme(); - const startOf = inclusiveStartDateOf(duration); - const endOf = inclusiveEndDateOf(duration); - const periodStartTitle = formatDuration(startOf, duration); - const periodEndTitle = formatDuration(endOf, duration); - return ( - + {currencyFormatter.format(costStart)} - + {currencyFormatter.format(costEnd)} diff --git a/plugins/cost-insights/src/hooks/index.ts b/plugins/cost-insights/src/hooks/index.ts index 7065bcf91b..94c556763c 100644 --- a/plugins/cost-insights/src/hooks/index.ts +++ b/plugins/cost-insights/src/hooks/index.ts @@ -20,5 +20,5 @@ export * from './useFilters'; export * from './useCurrency'; export * from './useGroups'; export * from './useLoading'; -export * from './useQueryParams'; export * from './useScroll'; +export * from './useLastCompleteBillingDate'; diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx index 6cbdd1a2ef..f63b9228a9 100644 --- a/plugins/cost-insights/src/hooks/useConfig.tsx +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -15,15 +15,15 @@ */ import React, { - ReactNode, createContext, + PropsWithChildren, useContext, useEffect, useState, } from 'react'; -import { useApi, configApiRef } from '@backstage/core'; +import { configApiRef, useApi } from '@backstage/core'; import { Config as BackstageConfig } from '@backstage/config'; -import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types'; +import { Currency, defaultCurrencies, Icon, Metric, Product } from '../types'; import { getIcon } from '../utils/navigation'; import { validateMetrics } from '../utils/config'; @@ -67,7 +67,7 @@ const defaultState: ConfigContextProps = { currencies: defaultCurrencies, }; -export const ConfigProvider = ({ children }: { children: ReactNode }) => { +export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => { const c: BackstageConfig = useApi(configApiRef); const [config, setConfig] = useState(defaultState); const [loading, setLoading] = useState(true); diff --git a/plugins/cost-insights/src/hooks/useCurrency.tsx b/plugins/cost-insights/src/hooks/useCurrency.tsx index f5d0df929a..34a4f54d5f 100644 --- a/plugins/cost-insights/src/hooks/useCurrency.tsx +++ b/plugins/cost-insights/src/hooks/useCurrency.tsx @@ -16,9 +16,9 @@ import React, { Dispatch, SetStateAction, - ReactNode, useState, useContext, + PropsWithChildren, } from 'react'; import { Currency, defaultCurrencies, findAlways } from '../types'; @@ -27,15 +27,11 @@ export type CurrencyContextProps = { setCurrency: Dispatch>; }; -export type CurrencyProviderProps = { - children: ReactNode; -}; - export const CurrencyContext = React.createContext< CurrencyContextProps | undefined >(undefined); -export const CurrencyProvider = ({ children }: CurrencyProviderProps) => { +export const CurrencyProvider = ({ children }: PropsWithChildren<{}>) => { const engineers = findAlways(defaultCurrencies, c => c.kind === null); const [currency, setCurrency] = useState(engineers); return ( diff --git a/plugins/cost-insights/src/hooks/useFilters.tsx b/plugins/cost-insights/src/hooks/useFilters.tsx index 7dcf8ddb26..22ef735186 100644 --- a/plugins/cost-insights/src/hooks/useFilters.tsx +++ b/plugins/cost-insights/src/hooks/useFilters.tsx @@ -16,114 +16,98 @@ import React, { Dispatch, - ReactNode, + PropsWithChildren, SetStateAction, useContext, useEffect, - useRef, useState, } from 'react'; -import { - getDefaultPageFilters, - PageFilters, - ProductFilters, - Duration, - Group, -} from '../types'; +import { Alert } from '@material-ui/lab'; +import { Maybe, PageFilters, ProductFilters } from '../types'; import { useLocation, useNavigate } from 'react-router-dom'; -import { useQueryParams } from './useQueryParams'; -import { stringify } from '../utils/history'; +import { + stringify, + validate, + getInitialPageState, + getInitialProductState, +} from '../utils/history'; import { useGroups } from './useGroups'; import { useConfig } from './useConfig'; -const getInitialPageState = ( - groups: Group[], - queryParams?: Partial, -) => { - // The group is written initially to queryParams as null, since user groups are asynchronously - // loaded. We preserve nulls in queryParams for other parameters where null is meaningful; for - // group, avoid overwriting the default with null after groups are loaded. - const { group, ...otherParams } = queryParams || {}; - return { - ...getDefaultPageFilters(groups), - ...otherParams, - ...(group ? { group: group } : {}), - }; -}; - export type FilterContextProps = { pageFilters: PageFilters; productFilters: ProductFilters; - setPageFilters: Dispatch>; - setProductFilters: Dispatch>; + setPageFilters: Dispatch>>; + setProductFilters: Dispatch>>; }; export type MapFiltersToProps = (props: FilterContextProps) => T; -export type FilterProviderProps = { - children: ReactNode; -}; - export const FilterContext = React.createContext< FilterContextProps | undefined >(undefined); -export const FilterProvider = ({ children }: FilterProviderProps) => { +export const FilterProvider = ({ children }: PropsWithChildren<{}>) => { const config = useConfig(); const navigate = useNavigate(); const location = useLocation(); - const queryParams = useQueryParams(); - const qsRef = useRef(''); const groups = useGroups(); - - const defaultProductFilters = config.products.map(product => ({ - productType: product.kind, - duration: Duration.P1M, - })); - - const getInitialProductState = (productFilters?: ProductFilters) => { - if (!productFilters) return defaultProductFilters; - return defaultProductFilters.map(product => { - return ( - productFilters.find( - param => param.productType === product.productType, - ) || product - ); - }); - }; - - const [productFilters, setProductFilters] = useState( - getInitialProductState(queryParams.productFilters), - ); - const [pageFilters, setPageFilters] = useState( - getInitialPageState(groups, queryParams.pageFilters), + const [error, setError] = useState>(null); + const [pageFilters, setPageFilters] = useState>(null); + const [productFilters, setProductFilters] = useState>( + null, ); - // TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded. useEffect(() => { - const initialState = getInitialPageState(groups, queryParams.pageFilters); - const defaultMetric = config.metrics.find(m => m.default); - setPageFilters({ ...initialState, metric: defaultMetric?.kind ?? null }); + async function setPageFiltersFromLocation() { + try { + // strip extraneous parameters, validate and transform + const queryParams = await validate(location.search); + const defaultMetric = config.metrics.find(m => m.default)?.kind ?? null; + + // Group or project parameters should override defaults + const initialPageState = getInitialPageState(groups, queryParams); + const initialProductState = getInitialProductState(config); + + setProductFilters(initialProductState); + setPageFilters({ ...initialPageState, metric: defaultMetric }); + } catch (e) { + setError(e); + } + } + + setPageFiltersFromLocation(); }, [groups]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - const queryString = stringify({ ...pageFilters, products: productFilters }); - if (queryString === qsRef.current) return; - qsRef.current = queryString; - // TODO Remove workaround once issue is resolved in react-router - // (https://github.com/ReactTraining/react-router/issues/7496) - // navigate({ ...location, search: queryString }); - navigate({ ...location, search: `?${queryString}` }); - }, [pageFilters, productFilters]); // eslint-disable-line react-hooks/exhaustive-deps + function setLocationFromPageFilters(filters: PageFilters) { + const queryString = stringify({ + group: filters.group, + ...(filters.project ? { project: filters.project } : {}), + }); + // TODO Remove workaround once issue is resolved in react-router + // (https://github.com/ReactTraining/react-router/issues/7496) + // navigate({ ...location, search: queryString }); + navigate({ ...location, search: `?${queryString}` }); + } + + if (pageFilters) { + setLocationFromPageFilters(pageFilters); + } + }, [pageFilters]); // eslint-disable-line react-hooks/exhaustive-deps + + if (error) { + return {error.message}; + } + + // Wait for filters to load + if (!pageFilters || !productFilters) { + return null; + } return ( {children} @@ -132,17 +116,7 @@ export const FilterProvider = ({ children }: FilterProviderProps) => { export function useFilters(mapFiltersToProps: MapFiltersToProps): T { const context = useContext(FilterContext); - - if (!context) { - assertNever(); - } - - return mapFiltersToProps({ - pageFilters: context.pageFilters, - productFilters: context.productFilters, - setPageFilters: context.setPageFilters, - setProductFilters: context.setProductFilters, - }); + return context ? mapFiltersToProps(context) : assertNever(); } function assertNever(): never { diff --git a/plugins/cost-insights/src/hooks/useGroups.tsx b/plugins/cost-insights/src/hooks/useGroups.tsx index 02c32e7920..d05920e561 100644 --- a/plugins/cost-insights/src/hooks/useGroups.tsx +++ b/plugins/cost-insights/src/hooks/useGroups.tsx @@ -14,17 +14,23 @@ * limitations under the License. */ -import React, { ReactNode, useContext, useEffect, useState } from 'react'; +import React, { + PropsWithChildren, + useContext, + useEffect, + useState, +} from 'react'; +import { Alert } from '@material-ui/lab'; import { useApi, identityApiRef } from '@backstage/core'; import { costInsightsApiRef } from '../api'; import { MapLoadingToProps, useLoading } from './useLoading'; -import { DefaultLoadingAction, Group } from '../types'; +import { DefaultLoadingAction, Group, Maybe } from '../types'; type GroupsProviderLoadingProps = { dispatchLoadingGroups: (isLoading: boolean) => void; }; -export const mapLoadingToProps: MapLoadingToProps = ({ +const mapLoadingToProps: MapLoadingToProps = ({ dispatch, }) => ({ dispatchLoadingGroups: (isLoading: boolean) => @@ -35,33 +41,41 @@ type GroupsContextProps = { groups: Group[]; }; -export type GroupsProviderProps = { - children: ReactNode; -}; +export const GroupsContext = React.createContext< + GroupsContextProps | undefined +>(undefined); -export const GroupsContext = React.createContext({ - groups: [], -}); - -export const GroupsProvider = ({ children }: GroupsProviderProps) => { +export const GroupsProvider = ({ children }: PropsWithChildren<{}>) => { const userId = useApi(identityApiRef).getUserId(); const client = useApi(costInsightsApiRef); + const [error, setError] = useState>(null); const { dispatchLoadingGroups } = useLoading(mapLoadingToProps); - const [groups, setGroups] = useState([]); + const [groups, setGroups] = useState>(null); useEffect(() => { dispatchLoadingGroups(true); async function getUserGroups() { - const g = await client.getUserGroups(userId); - setGroups(g); - dispatchLoadingGroups(false); + try { + const g = await client.getUserGroups(userId); + setGroups(g); + } catch (e) { + setError(e); + } finally { + dispatchLoadingGroups(false); + } } getUserGroups(); }, [userId, client]); // eslint-disable-line react-hooks/exhaustive-deps + if (error) { + return {error.message}; + } + + if (!groups) return null; + return ( {children} @@ -70,13 +84,8 @@ export const GroupsProvider = ({ children }: GroupsProviderProps) => { }; export function useGroups(): Group[] { - const { groups } = useContext(GroupsContext); - - if (!groups) { - assertNever(); - } - - return groups; + const context = useContext(GroupsContext); + return context ? context.groups : assertNever(); } function assertNever(): never { diff --git a/plugins/cost-insights/src/hooks/useLastCompleteBillingDate.tsx b/plugins/cost-insights/src/hooks/useLastCompleteBillingDate.tsx new file mode 100644 index 0000000000..33d037267d --- /dev/null +++ b/plugins/cost-insights/src/hooks/useLastCompleteBillingDate.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + PropsWithChildren, + useContext, + useEffect, + useState, +} from 'react'; +import * as yup from 'yup'; +import { Alert } from '@material-ui/lab'; +import { useApi } from '@backstage/core'; +import { costInsightsApiRef } from '../api'; +import { MapLoadingToProps, useLoading } from './useLoading'; +import { DefaultLoadingAction, Maybe } from '../types'; + +type BillingDateProviderLoadingProps = { + dispatchLoadingBillingDate: (isLoading: boolean) => void; +}; + +const mapLoadingToProps: MapLoadingToProps = ({ + dispatch, +}) => ({ + dispatchLoadingBillingDate: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.LastCompleteBillingDate]: isLoading }), +}); + +export type BillingDateContextProps = { + lastCompleteBillingDate: string; // YYYY-MM-DD +}; + +export const BillingDateContext = React.createContext< + BillingDateContextProps | undefined +>(undefined); + +export const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/; +const dateFormatSchema = yup.string().matches(dateRegex, { + message: + 'Unsupported billing date format: ${value}. Date should be in YYYY-MM-DD format.', + excludeEmptyString: true, +}); + +export const BillingDateProvider = ({ children }: PropsWithChildren<{}>) => { + const client = useApi(costInsightsApiRef); + const [error, setError] = useState>(null); + const { dispatchLoadingBillingDate } = useLoading(mapLoadingToProps); + + const [lastCompleteBillingDate, setLastCompeteBillingDate] = useState< + Maybe + >(null); + + useEffect(() => { + dispatchLoadingBillingDate(true); + + async function getLastCompleteBillingDate() { + try { + const d = await client.getLastCompleteBillingDate(); + const validDate = await dateFormatSchema.validate(d); + if (validDate) setLastCompeteBillingDate(validDate); + } catch (e) { + setError(e); + } finally { + dispatchLoadingBillingDate(false); + } + } + + getLastCompleteBillingDate(); + }, [client]); // eslint-disable-line react-hooks/exhaustive-deps + + if (error) { + return {error.message}; + } + + if (!lastCompleteBillingDate) return null; + + return ( + + {children} + + ); +}; + +export function useLastCompleteBillingDate(): string { + const context = useContext(BillingDateContext); + return context ? context.lastCompleteBillingDate : assertNever(); +} + +function assertNever(): never { + throw Error( + 'Cannot use useLastCompleteBillingDate outside of BillingDateProvider', + ); +} diff --git a/plugins/cost-insights/src/hooks/useLoading.tsx b/plugins/cost-insights/src/hooks/useLoading.tsx index bc0df4bbc7..31402ba0d1 100644 --- a/plugins/cost-insights/src/hooks/useLoading.tsx +++ b/plugins/cost-insights/src/hooks/useLoading.tsx @@ -15,10 +15,10 @@ */ import React, { - Dispatch, - ReactNode, - SetStateAction, createContext, + Dispatch, + PropsWithChildren, + SetStateAction, useContext, useEffect, useMemo, @@ -27,10 +27,10 @@ import React, { } from 'react'; import { Backdrop, CircularProgress } from '@material-ui/core'; import { - Loading, - getDefaultState, DefaultLoadingAction, + getDefaultState, getLoadingActions, + Loading, } from '../types'; import { useBackdropStyles as useStyles } from '../utils/styles'; import { useConfig } from './useConfig'; @@ -41,10 +41,6 @@ export type LoadingContextProps = { actions: Array; }; -export type LoadingProviderProps = { - children: ReactNode; -}; - export type MapLoadingToProps = (props: LoadingContextProps) => T; export const LoadingContext = createContext( @@ -58,7 +54,7 @@ function reducer(prevState: Loading, action: Partial): Loading { } as Record; } -export const LoadingProvider = ({ children }: LoadingProviderProps) => { +export const LoadingProvider = ({ children }: PropsWithChildren<{}>) => { const classes = useStyles(); const { products } = useConfig(); const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [ diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx index 480495a257..0a1abbdad3 100644 --- a/plugins/cost-insights/src/hooks/useScroll.tsx +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -15,12 +15,12 @@ */ import React, { Dispatch, - ReactNode, SetStateAction, useState, useContext, useEffect, useRef, + PropsWithChildren, } from 'react'; import { CSSProperties } from '@material-ui/styles'; import { Maybe } from '../types'; @@ -32,10 +32,6 @@ export type ScrollContextProps = { setScrollTo: Dispatch>; }; -export type ScrollProviderProps = { - children: ReactNode; -}; - export type ScrollUtils = { ScrollAnchor: (props: Omit) => JSX.Element; scrollIntoView: () => void; @@ -96,7 +92,7 @@ export const ScrollAnchor = ({ return
; }; -export const ScrollProvider = ({ children }: ScrollProviderProps) => { +export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => { const [scrollTo, setScrollTo] = useState(null); return ( diff --git a/plugins/cost-insights/src/setupTests.ts b/plugins/cost-insights/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/cost-insights/src/setupTests.ts +++ b/plugins/cost-insights/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/cost-insights/src/types/Duration.test.ts b/plugins/cost-insights/src/types/Duration.test.ts index 5b9f7b4a9b..4ab84de974 100644 --- a/plugins/cost-insights/src/types/Duration.test.ts +++ b/plugins/cost-insights/src/types/Duration.test.ts @@ -16,7 +16,7 @@ import { Duration, inclusiveEndDateOf, inclusiveStartDateOf } from './Duration'; -Date.now = jest.fn(() => new Date(Date.parse('2020-06-05')).valueOf()); +const lastCompleteBillingDate = '2020-06-05'; describe.each` duration | startDate | endDate @@ -26,7 +26,9 @@ describe.each` ${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'} `('Calculates interval dates correctly', ({ duration, startDate, endDate }) => { it(`Calculates dates correctly for ${duration}`, () => { - expect(inclusiveStartDateOf(duration)).toBe(startDate); - expect(inclusiveEndDateOf(duration)).toBe(endDate); + expect(inclusiveStartDateOf(duration, lastCompleteBillingDate)).toBe( + startDate, + ); + expect(inclusiveEndDateOf(duration, lastCompleteBillingDate)).toBe(endDate); }); }); diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index 6e6c5b1100..72501d46d5 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -32,23 +32,31 @@ export enum Duration { export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; -// Derive the start date of a given period, assuming two repeating intervals -export function inclusiveStartDateOf(duration: Duration): string { +/** + * Derive the start date of a given period, assuming two repeating intervals. + * + * @param duration see comment on Duration enum + * @param endDate from CostInsightsApi.getLastCompleteBillingDate + */ +export function inclusiveStartDateOf( + duration: Duration, + endDate: string, +): string { switch (duration) { case Duration.P30D: case Duration.P90D: - return moment() + return moment(endDate) .utc() .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); case Duration.P1M: - return moment() + return moment(endDate) .utc() .startOf('month') .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment() + return moment(endDate) .utc() .startOf('quarter') .subtract(moment.duration(duration).add(moment.duration(duration))) @@ -58,28 +66,37 @@ export function inclusiveStartDateOf(duration: Duration): string { } } -export function exclusiveEndDateOf(duration: Duration): string { +export function exclusiveEndDateOf( + duration: Duration, + endDate: string, +): string { switch (duration) { case Duration.P30D: case Duration.P90D: - return moment().utc().add(1, 'day').format(DEFAULT_DATE_FORMAT); + return moment(endDate).utc().add(1, 'day').format(DEFAULT_DATE_FORMAT); case Duration.P1M: - return moment().utc().startOf('month').format(DEFAULT_DATE_FORMAT); + return moment(endDate).utc().startOf('month').format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment().utc().startOf('quarter').format(DEFAULT_DATE_FORMAT); + return moment(endDate) + .utc() + .startOf('quarter') + .format(DEFAULT_DATE_FORMAT); default: return assertNever(duration); } } -export function inclusiveEndDateOf(duration: Duration): string { - return moment(exclusiveEndDateOf(duration)) +export function inclusiveEndDateOf( + duration: Duration, + endDate: string, +): string { + return moment(exclusiveEndDateOf(duration, endDate)) .utc() .subtract(1, 'day') .format(DEFAULT_DATE_FORMAT); } // https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals -export function intervalsOf(duration: Duration) { - return `R2/${duration}/${exclusiveEndDateOf(duration)}`; +export function intervalsOf(duration: Duration, endDate: string) { + return `R2/${duration}/${exclusiveEndDateOf(duration, endDate)}`; } diff --git a/plugins/cost-insights/src/types/Filters.ts b/plugins/cost-insights/src/types/Filters.ts index f90b9b4c78..b1fbbb2848 100644 --- a/plugins/cost-insights/src/types/Filters.ts +++ b/plugins/cost-insights/src/types/Filters.ts @@ -27,8 +27,6 @@ export interface PageFilters { export type ProductFilters = Array; -export type QueryParams = PageFilters & { products: ProductFilters }; - export interface ProductPeriod { duration: Duration; productType: string; diff --git a/plugins/cost-insights/src/types/Loading.ts b/plugins/cost-insights/src/types/Loading.ts index f5da1d2b07..8bc0c4eaef 100644 --- a/plugins/cost-insights/src/types/Loading.ts +++ b/plugins/cost-insights/src/types/Loading.ts @@ -18,6 +18,7 @@ export type Loading = Record; export enum DefaultLoadingAction { UserGroups = 'user-groups', + LastCompleteBillingDate = 'billing-date', CostInsightsInitial = 'cost-insights-initial', CostInsightsPage = 'cost-insights-page', } diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts index a76e838b53..69e9086be1 100644 --- a/plugins/cost-insights/src/utils/formatters.test.ts +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import { lengthyCurrencyFormatter, quarterOf } from './formatters'; +import { + formatPeriod, + lengthyCurrencyFormatter, + quarterOf, +} from './formatters'; +import { Duration } from '../types'; Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf()); @@ -48,3 +53,19 @@ describe('date formatters', () => { ]); }); }); + +describe.each` + duration | date | isEndDate | output + ${Duration.P1M} | ${'2020-10-11'} | ${true} | ${'September 2020'} + ${Duration.P1M} | ${'2020-10-11'} | ${false} | ${'August 2020'} + ${Duration.P3M} | ${'2020-10-11'} | ${true} | ${'Q3 2020'} + ${Duration.P3M} | ${'2020-10-11'} | ${false} | ${'Q2 2020'} + ${Duration.P30D} | ${'2020-10-11'} | ${true} | ${'Last 30 Days'} + ${Duration.P30D} | ${'2020-10-11'} | ${false} | ${'First 30 Days'} + ${Duration.P90D} | ${'2020-10-11'} | ${true} | ${'Last 90 Days'} + ${Duration.P90D} | ${'2020-10-11'} | ${false} | ${'First 90 Days'} +`('formatPeriod', ({ duration, date, isEndDate, output }) => { + it(`Correctly formats ${duration} with date ${date}`, async () => { + expect(formatPeriod(duration, date, isEndDate)).toBe(output); + }); +}); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index f3790f315e..44c7517351 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -87,26 +87,33 @@ export function formatPercent(n: number): string { return `${(n * 100).toFixed(0)}%`; } -export const formatLastTwoLookaheadQuarters = () => { - const start = moment(inclusiveStartDateOf(Duration.P3M)).format('[Q]Q YYYY'); - const end = moment(inclusiveEndDateOf(Duration.P3M)).format('[Q]Q YYYY'); +export function formatLastTwoLookaheadQuarters(endDate: string) { + const start = moment(inclusiveStartDateOf(Duration.P3M, endDate)).format( + '[Q]Q YYYY', + ); + const end = moment(inclusiveEndDateOf(Duration.P3M, endDate)).format( + '[Q]Q YYYY', + ); return `${start} vs ${end}`; -}; +} -export const formatLastTwoMonths = () => { - const start = moment(inclusiveStartDateOf(Duration.P1M)).utc().format('MMMM'); - const end = moment(inclusiveEndDateOf(Duration.P1M)).utc().format('MMMM'); +export function formatLastTwoMonths(endDate: string) { + const start = moment(inclusiveStartDateOf(Duration.P1M, endDate)) + .utc() + .format('MMMM'); + const end = moment(inclusiveEndDateOf(Duration.P1M, endDate)) + .utc() + .format('MMMM'); return `${start} vs ${end}`; -}; +} -export const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/; - -export const formatRelativeDuration = ( - date: string, +const formatRelativePeriod = ( duration: Duration, + date: string, + isEndDate: boolean, ): string => { - const periodStart = inclusiveStartDateOf(duration); - const periodEnd = inclusiveEndDateOf(duration); + const periodStart = isEndDate ? inclusiveStartDateOf(duration, date) : date; + const periodEnd = isEndDate ? date : inclusiveEndDateOf(duration, date); const days = moment.duration(duration).asDays(); if (![periodStart, periodEnd].includes(date)) { throw new Error(`Invalid relative date ${date} for duration ${duration}`); @@ -114,13 +121,25 @@ export const formatRelativeDuration = ( return date === periodStart ? `First ${days} Days` : `Last ${days} Days`; }; -export function formatDuration(date: string, duration: Duration) { +export function formatPeriod( + duration: Duration, + date: string, + isEndDate: boolean, +) { switch (duration) { case Duration.P1M: - return monthOf(date); + return monthOf( + isEndDate + ? inclusiveEndDateOf(duration, date) + : inclusiveStartDateOf(duration, date), + ); case Duration.P3M: - return quarterOf(date); + return quarterOf( + isEndDate + ? inclusiveEndDateOf(duration, date) + : inclusiveStartDateOf(duration, date), + ); default: - return formatRelativeDuration(date, duration); + return formatRelativePeriod(duration, date, isEndDate); } } diff --git a/plugins/cost-insights/src/utils/history.test.ts b/plugins/cost-insights/src/utils/history.test.ts new file mode 100644 index 0000000000..e4310e13c6 --- /dev/null +++ b/plugins/cost-insights/src/utils/history.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { validate, getInitialPageState } from './history'; + +describe('getInitialPageState', () => { + describe('groups', () => { + it('should set defaults if params or group is not provided', () => { + const initialState = getInitialPageState([]); + expect(initialState.group).toBe(null); + }); + + it('should set defaults if a group is fetched but no group is present on query params', () => { + const initialState = getInitialPageState([{ id: 'group' }]); + expect(initialState.group).toMatch(/group/); + }); + + it('group param should always override fetched group', () => { + const initialState = getInitialPageState( + [{ id: 'group' }, { id: 'second-group' }], + { group: 'other-group' }, + ); + expect(initialState.group).toMatch(/other-group/); + }); + + it('first group should be set as default group if user belongs to multiple groups', () => { + const initialState = getInitialPageState([ + { id: 'group' }, + { id: 'other-group' }, + ]); + expect(initialState.group).toMatch(/group/); + }); + }); + + describe('projects', () => { + it("should set defaults if project param doesn't exist", () => { + const initialState = getInitialPageState([], {}); + expect(initialState.project).toBeNull(); + }); + + it('should override defaults if project param exists', () => { + const initialState = getInitialPageState([], { project: 'some-project' }); + expect(initialState.project).toMatch(/some-project/); + }); + }); +}); + +describe.each` + params | expected + ${''} | ${{}} + ${'?foo=bar'} | ${{}} + ${'?project'} | ${{ project: null }} + ${'?group=some-group'} | ${{ group: 'some-group' }} + ${'?group=some-group&project'} | ${{ group: 'some-group', project: null }} + ${'?group=some-group&project=some-project'} | ${{ group: 'some-group', project: 'some-project' }} + ${'?group=some-group&project=some-project&foo=bar'} | ${{ group: 'some-group', project: 'some-project' }} +`('validate', ({ params, expected }) => { + it(`should validate ${params}`, async () => { + const pageFilters = await validate(params); + expect(pageFilters).toMatchObject(expected); + }); +}); + +describe('invalidate', () => { + it("should throw an error if param values don't match schema", async () => { + await expect(validate('?group')).rejects.toThrowError(); + }); +}); diff --git a/plugins/cost-insights/src/utils/history.ts b/plugins/cost-insights/src/utils/history.ts index 71e8e2c219..90df574acf 100644 --- a/plugins/cost-insights/src/utils/history.ts +++ b/plugins/cost-insights/src/utils/history.ts @@ -15,9 +15,44 @@ */ import qs from 'qs'; -import { QueryParams } from '../types'; +import * as yup from 'yup'; +import { Group, PageFilters, getDefaultPageFilters, Duration } from '../types'; +import { ConfigContextProps } from '../hooks/useConfig'; -export const stringify = (queryParams: Partial) => +const schema = yup + .object() + .shape({ + group: yup.string(), + project: yup.string().nullable(), + }) + .required(); + +export const stringify = (queryParams: Partial) => qs.stringify(queryParams, { strictNullHandling: true }); -export const parse = (queryString: string): Partial => + +export const parse = (queryString: string): Partial => qs.parse(queryString, { ignoreQueryPrefix: true, strictNullHandling: true }); + +export const validate = (queryString: string): Promise => { + return schema.validate(parse(queryString), { + stripUnknown: true, + strict: true, + }) as Promise; +}; + +export const getInitialPageState = ( + groups: Group[], + queryParams: Partial = {}, +) => { + return { + ...getDefaultPageFilters(groups), + ...(queryParams.project ? { project: queryParams.project } : {}), + ...(queryParams.group ? { group: queryParams.group } : {}), + }; +}; + +export const getInitialProductState = (config: ConfigContextProps) => + config.products.map(product => ({ + productType: product.kind, + duration: Duration.P30D, + })); diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index e1d715c573..a25f0dad20 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -22,6 +22,7 @@ import { getDefaultState, Product, ProductCost, + ProductFilters, ProjectGrowthData, UnlabeledDataflowAlertProject, UnlabeledDataflowData, @@ -77,8 +78,8 @@ export const createMockProjectGrowthData = ( ): ProjectGrowthData => { const data: ProjectGrowthData = { project: 'test-project-growth-alert', - periodStart: '2019-10-01', - periodEnd: '2020-03-31', + periodStart: '2019-Q4', + periodEnd: '2020-Q1', aggregation: [670532.1, 970502.8], change: { ratio: 0.5, @@ -135,7 +136,7 @@ export const MockProductTypes: Record = { 'cloud-pub-sub': 'Cloud Pub/Sub', }; -export const MockProductFilters = Object.keys( +export const MockProductFilters: ProductFilters = Object.keys( MockProductTypes, ).map(productType => ({ duration: Duration.P1M, productType })); diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts index 1777d385f8..b20c601276 100644 --- a/plugins/cost-insights/src/utils/styles.ts +++ b/plugins/cost-insights/src/utils/styles.ts @@ -454,3 +454,12 @@ export const useBackdropStyles = makeStyles( }, }), ); + +export const useSubtleTypographyStyles = makeStyles( + (theme: BackstageTheme) => + createStyles({ + root: { + color: theme.palette.textSubtle, + }, + }), +); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 807232555b..fbf665c131 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -13,38 +13,49 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { Dispatch, ReactNode, SetStateAction } from 'react'; +import React, { Dispatch, PropsWithChildren, SetStateAction } from 'react'; import { + Duration, getDefaultPageFilters, + Group, + Maybe, PageFilters, ProductFilters, - Group, + ProductPeriod, } from '../types'; import { FilterContext } from '../hooks/useFilters'; import { ConfigContext, ConfigContextProps } from '../hooks/useConfig'; import { CurrencyContext, CurrencyContextProps } from '../hooks/useCurrency'; -import { ScrollContext, ScrollProviderProps } from '../hooks/useScroll'; +import { ScrollContext } from '../hooks/useScroll'; +import { + BillingDateContext, + BillingDateContextProps, +} from '../hooks/useLastCompleteBillingDate'; import { MockProductFilters } from './mockData'; export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }]; type MockFilterProviderProps = { - setPageFilters: Dispatch>; - setProductFilters: Dispatch>; - children: ReactNode; + setPageFilters: Dispatch>>; + setProductFilters: Dispatch>>; + duration?: Duration; }; export const MockFilterProvider = ({ setPageFilters, setProductFilters, children, -}: MockFilterProviderProps) => { + duration = Duration.P1M, +}: PropsWithChildren) => { const pageFilters = getDefaultPageFilters(MockGroups); return ( ({ + ...period, + duration: duration, + })), setPageFilters: setPageFilters, setProductFilters: setProductFilters, }} @@ -61,7 +72,7 @@ export const MockConfigProvider = ({ engineerCost, currencies, children, -}: ConfigContextProps & { children: React.ReactNode }) => ( +}: PropsWithChildren) => ( @@ -73,13 +84,24 @@ export const MockCurrencyProvider = ({ currency, setCurrency, children, -}: CurrencyContextProps & { children: React.ReactNode }) => ( +}: PropsWithChildren) => ( {children} ); -export const MockScrollProvider = ({ children }: ScrollProviderProps) => ( +export const MockBillingDateProvider = ({ + lastCompleteBillingDate, + children, +}: PropsWithChildren) => ( + + {children} + +); + +export const MockScrollProvider = ({ children }: PropsWithChildren<{}>) => ( {children} diff --git a/plugins/explore/package.json b/plugins/explore/package.json index feeaf31985..c3e08dc9fe 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -41,9 +41,8 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/plugins/explore/src/setupTests.ts b/plugins/explore/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/explore/src/setupTests.ts +++ b/plugins/explore/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 8de69290e0..783504a2cf 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -38,9 +38,9 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/gcp-projects/src/setupTests.ts b/plugins/gcp-projects/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/gcp-projects/src/setupTests.ts +++ b/plugins/gcp-projects/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 0a5a8c4b41..63390fdcc0 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -47,9 +47,9 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/github-actions/src/setupTests.ts b/plugins/github-actions/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/github-actions/src/setupTests.ts +++ b/plugins/github-actions/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 5cfeb24077..2daa8b103e 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -39,9 +39,9 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx index 06074ff84d..f01ac70e64 100644 --- a/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx +++ b/plugins/gitops-profiles/src/components/ProfileCatalog/ProfileCatalog.test.tsx @@ -16,7 +16,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; import ProfileCatalog from './ProfileCatalog'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; @@ -45,7 +44,6 @@ describe('ProfileCatalog', () => { }), ], ]); - mockFetch.mockResponse(() => new Promise(() => {})); const rendered = render( diff --git a/plugins/gitops-profiles/src/setupTests.ts b/plugins/gitops-profiles/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/gitops-profiles/src/setupTests.ts +++ b/plugins/gitops-profiles/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 749be5c96f..bda33bb43e 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -52,10 +52,9 @@ "@types/codemirror": "^0.0.97", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1", - "react-router-dom": "6.0.0-beta.0" + "msw": "^0.21.2", + "react-router-dom": "6.0.0-beta.0", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/plugins/graphiql/src/setupTests.ts b/plugins/graphiql/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/graphiql/src/setupTests.ts +++ b/plugins/graphiql/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index 645334f791..63fb2c88db 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -30,7 +30,6 @@ "express-promise-router": "^3.0.3", "graphql": "^15.3.0", "helmet": "^4.0.0", - "node-fetch": "^2.6.0", "reflect-metadata": "^0.1.13", "winston": "^3.2.1", "yn": "^4.0.0" diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index d14b9ff297..b3f19b2b16 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -44,9 +44,9 @@ "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/testing-library__jest-dom": "^5.9.1", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/jenkins/src/setupTests.ts b/plugins/jenkins/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/jenkins/src/setupTests.ts +++ b/plugins/jenkins/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/kubernetes-backend/examples/dice-roller/README.md b/plugins/kubernetes-backend/examples/dice-roller/README.md index 774867de04..bf3e7d261a 100644 --- a/plugins/kubernetes-backend/examples/dice-roller/README.md +++ b/plugins/kubernetes-backend/examples/dice-roller/README.md @@ -23,7 +23,7 @@ This can be used to run the kubernetes plugin locally against a mock service. 6. Register existing component in Backstage - https://github.com/mclarke47/dice-roller/blob/master/catalog-info.yaml -Update `app-config.development.yaml` as follows. +Add or update `app-config.local.yaml` with the following: ```yaml kubernetes: @@ -45,4 +45,4 @@ Mac copy to clipboard: kubectl get secret $(kubectl get sa dice-roller -o=json | jq -r .secrets[0].name) -o=json | jq -r '.data["token"]' | base64 --decode | pbcopy ``` -Paste into `app-config.development.yaml` `kubernetes.clusters[0].serviceAccountToken` +Paste into `app-config.local.yaml` `kubernetes.clusters[0].serviceAccountToken` diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 1be8b7509b..fc068621ee 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -38,7 +38,6 @@ }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", - "jest-fetch-mock": "^3.0.3", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/kubernetes-backend/src/setupTests.ts b/plugins/kubernetes-backend/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/plugins/kubernetes-backend/src/setupTests.ts +++ b/plugins/kubernetes-backend/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 9a0e41689d..1ab4e4a2fb 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -43,9 +43,8 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/plugins/kubernetes/src/setupTests.ts b/plugins/kubernetes/src/setupTests.ts index 4b4cdbdaaf..0bfa67b49a 100644 --- a/plugins/kubernetes/src/setupTests.ts +++ b/plugins/kubernetes/src/setupTests.ts @@ -14,5 +14,3 @@ * limitations under the License. */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 1cc177cba7..eb2fdde5bc 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -47,9 +47,8 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx index 7f10914d9c..15a4ba3255 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -22,7 +22,6 @@ import { LighthouseRestApi, WebsiteListResponse, } from '../../api'; -import mockFetch from 'jest-fetch-mock'; import * as data from '../../__fixtures__/website-list-response.json'; import { EntityContext } from '@backstage/plugin-catalog'; @@ -53,7 +52,7 @@ describe('', () => { [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], [errorApiRef, mockErrorApi], ]); - mockFetch.mockResponse(JSON.stringify(entityWebsite)); + (useWebsiteForEntity as jest.Mock).mockReturnValue({ value: entityWebsite, loading: false, diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx index e67f939ba1..5c67fec10a 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { wrapInTestApp, msw } from '@backstage/test-utils'; import { ApiRegistry, ApiProvider } from '@backstage/core'; import AuditListTable from './AuditListTable'; @@ -26,7 +26,7 @@ import { LighthouseRestApi, } from '../../api'; import { formatTime } from '../../utils'; -import mockFetch from 'jest-fetch-mock'; +import { setupServer } from 'msw/node'; import * as data from '../../__fixtures__/website-list-response.json'; @@ -35,11 +35,13 @@ const websiteListResponse = data as WebsiteListResponse; describe('AuditListTable', () => { let apis: ApiRegistry; + const server = setupServer(); + msw.setupDefaultHandlers(server); + beforeEach(() => { apis = ApiRegistry.from([ [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], ]); - mockFetch.mockResponse(JSON.stringify(websiteListResponse)); }); const auditList = (websiteList: WebsiteListResponse) => { diff --git a/plugins/lighthouse/src/components/AuditList/index.test.tsx b/plugins/lighthouse/src/components/AuditList/index.test.tsx index 34fd760801..5fdc4781ce 100644 --- a/plugins/lighthouse/src/components/AuditList/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.test.tsx @@ -24,10 +24,9 @@ jest.mock('react-router-dom', () => { }); import React from 'react'; -import mockFetch from 'jest-fetch-mock'; import { render, fireEvent } from '@testing-library/react'; import { ApiRegistry, ApiProvider } from '@backstage/core'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { wrapInTestApp, msw } from '@backstage/test-utils'; import { lighthouseApiRef, @@ -40,18 +39,23 @@ import * as data from '../../__fixtures__/website-list-response.json'; const { useNavigate } = jest.requireMock('react-router-dom'); const websiteListResponse = data as WebsiteListResponse; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; describe('AuditList', () => { let apis: ApiRegistry; + const server = setupServer(); + msw.setupDefaultHandlers(server); + beforeEach(() => { apis = ApiRegistry.from([ [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], ]); - mockFetch.mockResponse(JSON.stringify(websiteListResponse)); }); it('should render the table', async () => { + server.use(rest.get('*', (_req, res, ctx) => res(ctx.json(data)))); const rendered = render( wrapInTestApp( @@ -76,22 +80,6 @@ describe('AuditList', () => { }); describe('pagination', () => { - it('requests the correct limit and offset from the api based on the query', () => { - mockFetch.mockClear(); - render( - wrapInTestApp( - - - , - { routeEntries: ['?page=2'] }, - ), - ); - expect(mockFetch).toHaveBeenLastCalledWith( - 'http://lighthouse/v1/websites?limit=10&offset=10', - undefined, - ); - }); - describe('when only one page is needed', () => { it('hides pagination elements', () => { const rendered = render( @@ -111,7 +99,8 @@ describe('AuditList', () => { response.limit = 5; response.offset = 5; response.total = 7; - mockFetch.mockResponseOnce(JSON.stringify(response)); + server.use(rest.get('*', (_req, res, ctx) => res(ctx.json(response)))); + server.use(rest.post('*', (_req, res, ctx) => res(ctx.json(response)))); }); it('shows pagination elements', async () => { @@ -146,7 +135,7 @@ describe('AuditList', () => { describe('when waiting on the request', () => { it('should render the loader', async () => { - mockFetch.mockResponseOnce(() => new Promise(() => {})); + server.use(rest.get('*', (_req, res, ctx) => res(ctx.delay(20000)))); const rendered = render( wrapInTestApp( @@ -161,7 +150,11 @@ describe('AuditList', () => { describe('when the audits fail', () => { it('should render an error', async () => { - mockFetch.mockRejectOnce(new Error('failed to fetch')); + server.use( + rest.get('*', (_req, res, ctx) => + res(ctx.status(500, 'something broke')), + ), + ); const rendered = render( wrapInTestApp( diff --git a/plugins/lighthouse/src/components/AuditView/index.test.tsx b/plugins/lighthouse/src/components/AuditView/index.test.tsx index ca292a133d..df223150bc 100644 --- a/plugins/lighthouse/src/components/AuditView/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.test.tsx @@ -27,29 +27,36 @@ jest.mock('react-router-dom', () => { }); import React from 'react'; -import mockFetch from 'jest-fetch-mock'; -import { render, fireEvent } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import { wrapInTestApp, msw } from '@backstage/test-utils'; import { ApiRegistry, ApiProvider } from '@backstage/core'; - import AuditView from '.'; import { lighthouseApiRef, LighthouseRestApi, Audit, Website } from '../../api'; import { formatTime } from '../../utils'; import * as data from '../../__fixtures__/website-response.json'; -import { act } from 'react-dom/test-utils'; + +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; const { useParams }: { useParams: jest.Mock } = jest.requireMock( 'react-router-dom', ); const websiteResponse = data as Website; -const { useNavigate } = jest.requireMock('react-router-dom'); describe('AuditView', () => { let apis: ApiRegistry; let id: string; + const server = setupServer(); + msw.setupDefaultHandlers(server); + beforeEach(() => { - mockFetch.mockResponse(JSON.stringify(websiteResponse)); + server.use( + rest.get('https://lighthouse/*', async (_req, res, ctx) => + res(ctx.json(websiteResponse)), + ), + ); + apis = ApiRegistry.from([ [lighthouseApiRef, new LighthouseRestApi('https://lighthouse')], ]); @@ -74,27 +81,6 @@ describe('AuditView', () => { expect(iframe).toHaveAttribute('src', `https://lighthouse/v1/audits/${id}`); }); - it('renders a button to click to create a new audit for this website', async () => { - const rendered = render( - wrapInTestApp( - - - , - ), - ); - - const button = await rendered.findByText('Create New Audit'); - expect(button).toBeInTheDocument(); - - act(() => { - fireEvent.click(button); - }); - - expect(useNavigate()).toHaveBeenCalledWith( - `../../create-audit?url=${encodeURIComponent('https://spotify.com')}`, - ); - }); - describe('sidebar', () => { it('renders a list of all audits for the website', async () => { const rendered = render( @@ -164,7 +150,7 @@ describe('AuditView', () => { describe('when the request for the website by id is pending', () => { it('shows the loading', async () => { - mockFetch.mockImplementationOnce(() => new Promise(() => {})); + server.use(rest.get('*', (_req, res, ctx) => res(ctx.delay(20000)))); const rendered = render( wrapInTestApp( @@ -178,7 +164,11 @@ describe('AuditView', () => { describe('when the request for the website by id fails', () => { it('shows an error', async () => { - mockFetch.mockRejectOnce(new Error('failed to fetch')); + server.use( + rest.get('*', (_req, res, ctx) => + res(ctx.status(500), ctx.body('failed to fetch')), + ), + ); const rendered = render( wrapInTestApp( @@ -186,7 +176,7 @@ describe('AuditView', () => { , ), ); - expect(await rendered.findByText('failed to fetch')).toBeInTheDocument(); + expect(await rendered.findByText(/failed to fetch/)).toBeInTheDocument(); }); }); diff --git a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx index b2348bf5ff..96829926b5 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx @@ -24,20 +24,22 @@ jest.mock('react-router-dom', () => { }); import React from 'react'; -import mockFetch from 'jest-fetch-mock'; -import { wait, render, fireEvent } from '@testing-library/react'; +import { waitFor, render, fireEvent } from '@testing-library/react'; import { ApiRegistry, ApiProvider, ErrorApi, errorApiRef, } from '@backstage/core'; -import { wrapInTestApp } from '@backstage/test-utils'; +import { wrapInTestApp, msw } from '@backstage/test-utils'; import { lighthouseApiRef, LighthouseRestApi, Audit } from '../../api'; import CreateAudit from '.'; import * as data from '../../__fixtures__/create-audit-response.json'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + const { useNavigate }: { useNavigate: jest.Mock } = jest.requireMock( 'react-router-dom', ); @@ -47,6 +49,8 @@ const createAuditResponse = data as Audit; describe('CreateAudit', () => { let apis: ApiRegistry; let errorApi: ErrorApi; + const server = setupServer(); + msw.setupDefaultHandlers(server); beforeEach(() => { errorApi = { post: jest.fn(), error$: jest.fn() }; @@ -88,7 +92,7 @@ describe('CreateAudit', () => { describe('when waiting on the request', () => { it('disables the form fields', () => { - mockFetch.mockResponseOnce(() => new Promise(() => {})); + server.use(rest.get('*', (_req, res, ctx) => res(ctx.delay(20000)))); const rendered = render( wrapInTestApp( @@ -111,7 +115,11 @@ describe('CreateAudit', () => { describe('when the audit is successfully created', () => { it('triggers a location change to the table', async () => { useNavigate.mockClear(); - mockFetch.mockResponseOnce(JSON.stringify(createAuditResponse)); + server.use( + rest.post('http://lighthouse/v1/audits', (_req, res, ctx) => + res(ctx.json(createAuditResponse)), + ), + ); const rendered = render( wrapInTestApp( @@ -126,14 +134,7 @@ describe('CreateAudit', () => { }); fireEvent.click(rendered.getByText(/Create Audit/)); - expect(mockFetch).toHaveBeenCalledWith( - 'http://lighthouse/v1/audits', - expect.objectContaining({ - method: 'POST', - }), - ); - - await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); + await waitFor(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); expect(useNavigate()).toHaveBeenCalledWith('..'); }); @@ -141,9 +142,11 @@ describe('CreateAudit', () => { describe('when the audits fail', () => { it('should render an error', async () => { - (errorApi.post as jest.Mock).mockClear(); - mockFetch.mockRejectOnce(new Error('failed to post')); - + server.use( + rest.post('http://lighthouse/v1/audits', (_req, res, ctx) => + res(ctx.status(500, 'failed to post')), + ), + ); const rendered = render( wrapInTestApp( @@ -157,8 +160,7 @@ describe('CreateAudit', () => { }); fireEvent.click(rendered.getByText(/Create Audit/)); - await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); - await new Promise(r => setTimeout(r, 0)); + await waitFor(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); expect(errorApi.post).toHaveBeenCalledWith(expect.any(Error)); }); diff --git a/plugins/lighthouse/src/setupTests.ts b/plugins/lighthouse/src/setupTests.ts index 8553642152..aea2220869 100644 --- a/plugins/lighthouse/src/setupTests.ts +++ b/plugins/lighthouse/src/setupTests.ts @@ -15,5 +15,4 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); +import 'cross-fetch/polyfill'; diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 9ca33c8ad1..3372562ca2 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -38,9 +38,9 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/newrelic/src/setupTests.ts b/plugins/newrelic/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/newrelic/src/setupTests.ts +++ b/plugins/newrelic/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index c7125d8b18..0d7cf00509 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -26,7 +26,6 @@ "express-promise-router": "^3.0.3", "http-proxy-middleware": "^0.19.1", "morgan": "^1.10.0", - "node-fetch": "^2.6.0", "uuid": "^8.0.0", "winston": "^3.2.1", "yaml": "^1.9.2", @@ -36,11 +35,9 @@ "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", "@types/http-proxy-middleware": "^0.19.3", - "@types/node-fetch": "^2.5.7", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", "@types/yup": "^0.29.8", - "jest-fetch-mock": "^3.0.3", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/proxy-backend/src/service/router.test.ts b/plugins/proxy-backend/src/service/router.test.ts index 012adaed93..77db35c917 100644 --- a/plugins/proxy-backend/src/service/router.test.ts +++ b/plugins/proxy-backend/src/service/router.test.ts @@ -16,7 +16,6 @@ import { buildMiddleware, createRouter } from './router'; import * as winston from 'winston'; -import { ConfigReader } from '@backstage/config'; import { loadBackendConfig, SingleHostDiscovery, @@ -42,7 +41,7 @@ const mockCreateProxyMiddleware = createProxyMiddleware as jest.MockedFunction< describe('createRouter', () => { it('works', async () => { const logger = winston.createLogger(); - const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const config = await loadBackendConfig({ logger, argv: [] }); const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ config, diff --git a/plugins/proxy-backend/src/service/standaloneServer.ts b/plugins/proxy-backend/src/service/standaloneServer.ts index 83980729d8..c64d69e2a4 100644 --- a/plugins/proxy-backend/src/service/standaloneServer.ts +++ b/plugins/proxy-backend/src/service/standaloneServer.ts @@ -22,7 +22,6 @@ import { import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; -import { ConfigReader } from '@backstage/config'; export interface ServerOptions { port: number; @@ -37,7 +36,7 @@ export async function startStandaloneServer( logger.debug('Creating application...'); - const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const config = await loadBackendConfig({ logger, argv: process.argv }); const discovery = SingleHostDiscovery.fromConfig(config); const router = await createRouter({ config, diff --git a/plugins/proxy-backend/src/setupTests.ts b/plugins/proxy-backend/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/plugins/proxy-backend/src/setupTests.ts +++ b/plugins/proxy-backend/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 464dd60a98..9729a78d80 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -43,9 +43,9 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/register-component/src/setupTests.ts b/plugins/register-component/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/register-component/src/setupTests.ts +++ b/plugins/register-component/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 2e559a698f..e00f046901 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -39,7 +39,6 @@ "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", "@types/supertest": "^2.0.8", - "jest-fetch-mock": "^3.0.3", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/rollbar-backend/src/service/standaloneServer.ts b/plugins/rollbar-backend/src/service/standaloneServer.ts index a96d30d836..b30bf6fc6b 100644 --- a/plugins/rollbar-backend/src/service/standaloneServer.ts +++ b/plugins/rollbar-backend/src/service/standaloneServer.ts @@ -20,7 +20,6 @@ import { createServiceBuilder, loadBackendConfig, } from '@backstage/backend-common'; -import { ConfigReader } from '@backstage/config'; import { createRouter } from './router'; export interface ServerOptions { @@ -33,7 +32,7 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'rollbar-backend' }); - const config = ConfigReader.fromConfigs(await loadBackendConfig()); + const config = await loadBackendConfig({ logger, argv: process.argv }); logger.debug('Creating application...'); diff --git a/plugins/rollbar-backend/src/setupTests.ts b/plugins/rollbar-backend/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/plugins/rollbar-backend/src/setupTests.ts +++ b/plugins/rollbar-backend/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 2289096ce7..4b44d17c62 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -47,9 +47,8 @@ "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react": "^16.9", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/plugins/rollbar/src/setupTests.ts b/plugins/rollbar/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/rollbar/src/setupTests.ts +++ b/plugins/rollbar/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 66d6457333..0d05156791 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -51,7 +51,7 @@ "@octokit/types": "^5.4.1", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", - "@types/nodegit": "0.26.8", + "@types/nodegit": "0.26.11", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", "yaml": "^1.10.0" diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 97f5cd891c..bda3ff52aa 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -49,9 +49,8 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/plugins/scaffolder/src/components/JobStage/JobStage.tsx b/plugins/scaffolder/src/components/JobStage/JobStage.tsx index 0baba0c9b9..5128a761ec 100644 --- a/plugins/scaffolder/src/components/JobStage/JobStage.tsx +++ b/plugins/scaffolder/src/components/JobStage/JobStage.tsx @@ -125,7 +125,9 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => { {log.length === 0 ? ( - No logs available for this step + + No logs available for this step + ) : ( }>
diff --git a/plugins/scaffolder/src/setupTests.ts b/plugins/scaffolder/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/scaffolder/src/setupTests.ts +++ b/plugins/scaffolder/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/sentry-backend/package.json b/plugins/sentry-backend/package.json index 9e69e58507..4702242b2c 100644 --- a/plugins/sentry-backend/package.json +++ b/plugins/sentry-backend/package.json @@ -34,8 +34,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.25", - "jest-fetch-mock": "^3.0.3" + "@backstage/cli": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index a926c6b4db..0060777491 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -38,14 +38,14 @@ "devDependencies": { "@backstage/cli": "^0.1.1-alpha.25", "@backstage/dev-utils": "^0.1.1-alpha.25", + "@backstage/test-utils": "^0.1.1-alpha.25", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" }, "files": [ "dist" diff --git a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx index d508ddf7c2..21c21ce385 100644 --- a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx +++ b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx @@ -16,10 +16,13 @@ import React from 'react'; import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; import SentryPluginPage from './SentryPluginPage'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; +import { msw } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + import { ApiProvider, ApiRegistry, @@ -31,8 +34,11 @@ const errorApi = { post: () => {} }; const ConfigApi = { getString: () => 'test' }; describe('SentryPluginPage', () => { + const server = setupServer(); + msw.setupDefaultHandlers(server); + it('should render header and time switched', () => { - mockFetch.mockResponse('{}'); + server.use(rest.get('/', (_req, res, ctx) => res(ctx.json({})))); const rendered = render( { - const path = getGithubApiUrl(repositoryUrl).toString(); - const options = getGithubRequestOptions(config); + const path = getGithubApiUrl(config, repositoryUrl).toString(); + const host = getGitHost(repositoryUrl); + const options = getGithubRequestOptions(config, host); try { const raw = await fetch(path, options); @@ -163,7 +170,8 @@ async function getGitlabDefaultBranch( ): Promise { const path = getGitlabApiUrl(repositoryUrl).toString(); - const options = getGitlabRequestOptions(config); + const gitlabHost = getGitHost(repositoryUrl); + const options = getGitlabRequestOptions(config, gitlabHost); try { const raw = await fetch(path, options); @@ -194,8 +202,8 @@ async function getAzureDefaultBranch( config: Config, ): Promise { const path = getAzureApiUrl(repositoryUrl).toString(); - - const options = getAzureRequestOptions(config); + const host = getGitHost(repositoryUrl); + const options = getAzureRequestOptions(config, host); try { const urlResponse = await fetch(path, options); @@ -228,15 +236,12 @@ async function getAzureDefaultBranch( export const getDefaultBranch = async ( repositoryUrl: string, ): Promise => { - const config = ConfigReader.fromConfigs(await loadBackendConfig()); - const typeMapping = [ - { url: /github*/g, type: 'github' }, - { url: /gitlab*/g, type: 'gitlab' }, - { url: /azure*/g, type: 'azure/api' }, - ]; - - const type = typeMapping.filter(item => item.url.test(repositoryUrl))[0] - ?.type; + // TODO(Rugvip): Config should not be loaded here, pass it in instead + const config = await loadBackendConfig({ + logger: getRootLogger(), + argv: process.argv, + }); + const type = getGitRepoType(repositoryUrl); try { switch (type) { diff --git a/plugins/techdocs-backend/src/git-auth.ts b/plugins/techdocs-backend/src/git-auth.ts new file mode 100644 index 0000000000..b580a6f05b --- /dev/null +++ b/plugins/techdocs-backend/src/git-auth.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import parseGitUrl from 'git-url-parse'; +import { Config } from '@backstage/config'; +import { getRootLogger, loadBackendConfig } from '@backstage/backend-common'; + +export function getGitHost(url: string): string { + const { resource } = parseGitUrl(url); + return resource; +} + +export function getGitRepoType(url: string): string { + const typeMapping = [ + { url: /github*/g, type: 'github' }, + { url: /gitlab*/g, type: 'gitlab' }, + { url: /azure*/g, type: 'azure/api' }, + ]; + + const type = typeMapping.filter(item => item.url.test(url))[0]?.type; + + return type; +} + +export function getGithubHostToken( + config: Config, + host: string, +): string | undefined { + const providerConfigs = + config.getOptionalConfigArray('integrations.github') ?? []; + + const hostConfig = providerConfigs.filter( + providerConfig => providerConfig.getOptionalString('host') === host, + ); + const token = + hostConfig[0]?.getOptionalString('token') ?? + config.getOptionalString('catalog.processors.github.privateToken') ?? + config.getOptionalString('catalog.processors.githubApi.privateToken') ?? + process.env.GITHUB_TOKEN; + + return token; +} + +export function getGitlabHostToken( + config: Config, + host: string, +): string | undefined { + const providerConfigs = + config.getOptionalConfigArray('integrations.gitlab') ?? []; + + const hostConfig = providerConfigs.filter( + providerConfig => providerConfig.getOptionalString('host') === host, + ); + const token = + hostConfig[0]?.getOptionalString('token') ?? + config.getOptionalString('catalog.processors.gitlab.privateToken') ?? + config.getOptionalString('catalog.processors.gitlabApi.privateToken') ?? + process.env.GITLAB_TOKEN; + + return token; +} + +export function getAzureHostToken( + config: Config, + host: string, +): string | undefined { + const providerConfigs = + config.getOptionalConfigArray('integrations.azure') ?? []; + + const hostConfig = providerConfigs.filter( + providerConfig => providerConfig.getOptionalString('host') === host, + ); + const token = + hostConfig[0]?.getOptionalString('token') ?? + config.getOptionalString('catalog.processors.azureApi.privateToken') ?? + process.env.AZURE_TOKEN; + + return token; +} + +export const getTokenForGitRepo = async ( + repositoryUrl: string, +): Promise => { + // TODO(Rugvip): Config should not be loaded here, pass it in instead + const config = await loadBackendConfig({ + logger: getRootLogger(), + argv: process.argv, + }); + + const host = getGitHost(repositoryUrl); + const type = getGitRepoType(repositoryUrl); + + try { + switch (type) { + case 'github': + return getGithubHostToken(config, host); + case 'gitlab': + return getGitlabHostToken(config, host); + case 'azure/api': + return getAzureHostToken(config, host); + + default: + throw new Error('Failed to get repository type'); + } + } catch (error) { + throw error; + } +}; diff --git a/plugins/techdocs-backend/src/helpers.ts b/plugins/techdocs-backend/src/helpers.ts index b2dcd1e8e8..4cbf46516f 100644 --- a/plugins/techdocs-backend/src/helpers.ts +++ b/plugins/techdocs-backend/src/helpers.ts @@ -20,6 +20,7 @@ import parseGitUrl from 'git-url-parse'; import NodeGit, { Clone, Repository } from 'nodegit'; import fs from 'fs-extra'; import { getDefaultBranch } from './default-branch'; +import { getGitRepoType, getTokenForGitRepo } from './git-auth'; import { Entity } from '@backstage/catalog-model'; import { InputError } from '@backstage/backend-common'; import { RemoteProtocol } from './techdocs/stages/prepare/types'; @@ -120,18 +121,7 @@ export const checkoutGitRepository = async ( ): Promise => { const parsedGitLocation = parseGitUrl(repoUrl); const repositoryTmpPath = await getGitRepositoryTempFolder(repoUrl); - - // TODO: Should propably not be hardcoded names of env variables, but seems too hard to access config down here - const user = - process.env.GITHUB_PRIVATE_TOKEN_USER || - process.env.GITLAB_PRIVATE_TOKEN_USER || - process.env.AZURE_PRIVATE_TOKEN_USER || - ''; - const token = - process.env.GITHUB_TOKEN || - process.env.GITLAB_PRIVATE_TOKEN_USER || - process.env.AZURE_TOKEN || - ''; + const token = await getTokenForGitRepo(repoUrl); if (fs.existsSync(repositoryTmpPath)) { try { @@ -153,8 +143,10 @@ export const checkoutGitRepository = async ( } } - if (user && token) { - parsedGitLocation.token = `${user}:${token}`; + if (token) { + const type = getGitRepoType(repoUrl); + const auth = type === 'github' ? `${token}:x-oauth-basic` : `:${token}`; + parsedGitLocation.token = auth; } const repositoryCheckoutUrl = parsedGitLocation.toString('https'); diff --git a/plugins/techdocs-backend/src/service/metadata.ts b/plugins/techdocs-backend/src/service/metadata.ts index 671fcdda17..760180f8d2 100644 --- a/plugins/techdocs-backend/src/service/metadata.ts +++ b/plugins/techdocs-backend/src/service/metadata.ts @@ -1,4 +1,3 @@ -import fetch from 'node-fetch'; /* * Copyright 2020 Spotify AB * @@ -15,6 +14,8 @@ import fetch from 'node-fetch'; * limitations under the License. */ +import fetch from 'cross-fetch'; + export class TechDocsMetadata { private async getMetadataFile(docsUrl: String) { const metadataURL = `${docsUrl}/techdocs_metadata.json`; diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index f2317f749e..b6f8865a57 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -17,7 +17,7 @@ import { Logger } from 'winston'; import Router from 'express-promise-router'; import express from 'express'; import Knex from 'knex'; -import fetch from 'node-fetch'; +import fetch from 'cross-fetch'; import { Config } from '@backstage/config'; import Docker from 'dockerode'; import { @@ -111,7 +111,9 @@ export async function createRouter({ const catalogRes = await fetch(`${catalogUrl}/entities/by-name/${triple}`); if (!catalogRes.ok) { - catalogRes.body.pipe(res.status(catalogRes.status)); + const catalogResText = await catalogRes.text(); + res.status(catalogRes.status); + res.send(catalogResText); return; } diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 99742d2661..eb6bb597d9 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -48,9 +48,9 @@ "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "canvas": "^2.6.1", - "jest-fetch-mock": "^3.0.3", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6", + "@backstage/test-utils": "^0.1.1-alpha.25" }, "files": [ "dist" diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts index c83a94f35d..67ae38c56b 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts @@ -25,14 +25,20 @@ import { onCssReady } from '../transformers'; const docStorageUrl: string = 'https://techdocs-mock-sites.storage.googleapis.com'; -jest.useFakeTimers(); - const fixture = ` `; describe('onCssReady', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + beforeEach(() => { mockStylesheetEventListener(100); }); diff --git a/plugins/techdocs/src/setupTests.ts b/plugins/techdocs/src/setupTests.ts index 8553642152..825bcd4115 100644 --- a/plugins/techdocs/src/setupTests.ts +++ b/plugins/techdocs/src/setupTests.ts @@ -15,5 +15,3 @@ */ import '@testing-library/jest-dom'; - -require('jest-fetch-mock').enableMocks(); diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 029ff99222..9748bb1a9e 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -40,8 +40,8 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", - "msw": "^0.20.5", - "node-fetch": "^2.6.1" + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" }, "files": [ "dist" diff --git a/plugins/user-settings/src/components/General/General.tsx b/plugins/user-settings/src/components/General/General.tsx index b6d30d0103..2d30c00eec 100644 --- a/plugins/user-settings/src/components/General/General.tsx +++ b/plugins/user-settings/src/components/General/General.tsx @@ -14,24 +14,28 @@ * limitations under the License. */ import { InfoCard } from '@backstage/core'; -import { Grid, List } from '@material-ui/core'; +import { Grid, List, useMediaQuery, useTheme } from '@material-ui/core'; import React from 'react'; import { PinButton } from './PinButton'; import { Profile } from './Profile'; import { ThemeToggle } from './ThemeToggle'; -export const General = () => ( - - - +export const General = () => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + return ( + + + + + + + + + {!fullScreen && } + + + - - - - - - - - - -); + ); +}; diff --git a/plugins/user-settings/src/components/General/ThemeToggle.tsx b/plugins/user-settings/src/components/General/ThemeToggle.tsx index dc24a61f8f..e142b6bf1e 100644 --- a/plugins/user-settings/src/components/General/ThemeToggle.tsx +++ b/plugins/user-settings/src/components/General/ThemeToggle.tsx @@ -25,6 +25,7 @@ import { ListItemText, ListItemSecondaryAction, Tooltip, + makeStyles, } from '@material-ui/core'; type ThemeIconProps = { @@ -48,6 +49,29 @@ type TooltipToggleButtonProps = { value: string; }; +const useStyles = makeStyles(theme => ({ + list: { + [theme.breakpoints.down('xs')]: { + padding: `0 0 12px`, + }, + }, + listItemText: { + [theme.breakpoints.down('xs')]: { + paddingRight: 0, + paddingLeft: 0, + }, + }, + listItemSecondaryAction: { + [theme.breakpoints.down('xs')]: { + width: '100%', + top: 'auto', + right: 'auto', + position: 'relative', + transform: 'unset', + }, + }, +})); + // ToggleButtonGroup uses React.children.map instead of context // so wrapping with Tooltip breaks ToggleButton functionality. const TooltipToggleButton = ({ @@ -64,6 +88,7 @@ const TooltipToggleButton = ({ ); export const ThemeToggle = () => { + const classes = useStyles(); const appThemeApi = useApi(appThemeApiRef); const themeId = useObservable( appThemeApi.activeThemeId$(), @@ -84,9 +109,13 @@ export const ThemeToggle = () => { }; return ( - - - + + + { > {themeIds.map(theme => { const themeIcon = themeIds.find(t => t.id === theme.id)?.icon; - return ( = 8" -"@open-draft/until@^1.0.0", "@open-draft/until@^1.0.3": +"@open-draft/until@^1.0.3": version "1.0.3" resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== @@ -3644,11 +3628,6 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" -"@rehooks/local-storage@^2.4.0": - version "2.4.0" - resolved "https://registry.npmjs.org/@rehooks/local-storage/-/local-storage-2.4.0.tgz#fb884b2b657cad5f77aa6ab60bb3532f0e0725d2" - integrity sha512-LoXDbEHsuIckVgBsFAv8SuU/M7memjyfWut9Zf36TQXqqCHBRFv8bweg9PymQCa1aWIMjNrZQflFdo55FDlXYg== - "@rjsf/core@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@rjsf/core/-/core-2.4.0.tgz#c50bcff0d8178948ce08123177399d8816d51929" @@ -3691,6 +3670,51 @@ react-router "^6.0.0-beta.0" react-use "^15.3.3" +"@roadiehq/backstage-plugin-github-pull-requests@^0.5.2": + version "0.5.2" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-pull-requests/-/backstage-plugin-github-pull-requests-0.5.2.tgz#455355fe5cdcc43c22b80a8ef3697378084680a1" + integrity sha512-GM+nWQZ+aehMclN70Pp35UQe3I46SQCjWiGZ6/0TfDzrueHln39L2UspUj1L/713xDDtDK8ZnTJdFn/qKx7j4A== + dependencies: + "@backstage/catalog-model" "^0.1.1-alpha.25" + "@backstage/core" "^0.1.1-alpha.25" + "@backstage/plugin-catalog" "^0.1.1-alpha.25" + "@backstage/theme" "^0.1.1-alpha.25" + "@material-ui/core" "^4.11.0" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "^4.0.0-alpha.56" + "@octokit/rest" "^18.0.0" + "@octokit/types" "^5.0.1" + "@types/react-dom" "^16.9.8" + history "^5.0.0" + moment "^2.27.0" + react "^16.13.1" + react-dom "^16.13.1" + react-router "6.0.0-beta.0" + react-use "^15.3.3" + +"@roadiehq/backstage-plugin-travis-ci@^0.2.3": + version "0.2.3" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.2.3.tgz#2d1c5a7ed3eab4fdcf95243b73dee44c0cff5a57" + integrity sha512-Xi8vZFbcm+D4ykyU3I9hImAd22F1v6M1F/H9CJanRHqj5esJxnuCS2+Kf+w09bwb9DuM8/c68PuMlf3PMIsOwg== + dependencies: + "@backstage/catalog-model" "^0.1.1-alpha.22" + "@backstage/core" "^0.1.1-alpha.22" + "@backstage/core-api" "^0.1.1-alpha.22" + "@backstage/plugin-catalog" "^0.1.1-alpha.22" + "@backstage/theme" "^0.1.1-alpha.22" + "@material-ui/core" "^4.9.1" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + date-fns "^2.15.0" + history "^5.0.0" + moment "^2.27.0" + react "^16.13.1" + react-dom "^16.13.1" + react-lazylog "^4.5.2" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-use "^15.3.3" + "@rollup/plugin-commonjs@^13.0.0": version "13.0.0" resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-13.0.0.tgz#8a1d684ba6848afe8b9e3d85649d4b2f6f7217ec" @@ -3704,19 +3728,6 @@ magic-string "^0.25.2" resolve "^1.11.0" -"@rollup/plugin-commonjs@^14.0.0": - version "14.0.0" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-14.0.0.tgz#4285f9ec2db686a31129e5a2b415c94aa1f836f0" - integrity sha512-+PSmD9ePwTAeU106i9FRdc+Zb3XUWyW26mo5Atr2mk82hor8+nPwkztEjFo8/B1fJKfaQDg9aM2bzQkjhi7zOw== - dependencies: - "@rollup/pluginutils" "^3.0.8" - commondir "^1.0.1" - estree-walker "^1.0.1" - glob "^7.1.2" - is-reference "^1.1.2" - magic-string "^0.25.2" - resolve "^1.11.0" - "@rollup/plugin-json@^4.0.2": version "4.1.0" resolved "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3" @@ -3724,15 +3735,14 @@ dependencies: "@rollup/pluginutils" "^3.0.8" -"@rollup/plugin-node-resolve@^8.1.0", "@rollup/plugin-node-resolve@^8.4.0": - version "8.4.0" - resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-8.4.0.tgz#261d79a680e9dc3d86761c14462f24126ba83575" - integrity sha512-LFqKdRLn0ShtQyf6SBYO69bGE1upV6wUhBX0vFOUnLAyzx5cwp8svA0eHUnu8+YU57XOkrMtfG63QOpQx25pHQ== +"@rollup/plugin-node-resolve@^9.0.0": + version "9.0.0" + resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-9.0.0.tgz#39bd0034ce9126b39c1699695f440b4b7d2b62e6" + integrity sha512-gPz+utFHLRrd41WMP13Jq5mqqzHL3OXrfj3/MkSyB6UBIcuNt9j60GCbarzMzdf1VHFpOxfQh/ez7wyadLMqkg== dependencies: "@rollup/pluginutils" "^3.1.0" "@types/resolve" "1.17.1" builtin-modules "^3.1.0" - deep-freeze "^0.0.1" deepmerge "^4.2.2" is-module "^1.0.0" resolve "^1.17.0" @@ -3755,15 +3765,6 @@ estree-walker "^1.0.1" picomatch "^2.2.2" -"@rollup/pluginutils@^4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.0.0.tgz#e18e9f5a3925779fc15209dd316c1bd260d195ef" - integrity sha512-b5QiJRye4JlSg29bKNEECoKbLuPXZkPEHSgEjjP1CJV1CPdDBybfYHfm6kyq8yK51h/Zsyl8OvWUrp0FUBukEQ== - dependencies: - "@types/estree" "0.0.45" - estree-walker "^2.0.1" - picomatch "^2.2.2" - "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -3828,16 +3829,16 @@ integrity sha512-so8w32ZV42CHWxOEXcBtbNO/hLXFrQNXVmhfzhUI6dVB9cq2xjRaiqu8GjFj8LvKbWpPj+S+KwTIS4aDVWqrFQ== "@storybook/addon-actions@^6.0.21": - version "6.0.21" - resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.0.21.tgz#0de1d109d4b1eb99f644bbe84e74c25cfd2b1b6b" - integrity sha512-9y3ve+3GK1TsxQ5pxDjhB7E/XJXY+WqcSNlOX8Mb+XbS6AAgpFbkZCw1q8CGzyEUclHsQ6UK2+lo+IRGs4TLpA== + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.0.26.tgz#d0de9e4d78a8f8f5bf8730c04d0b6d1741c29273" + integrity sha512-9tWbAqSwzWWVz8zwAndZFusZYjIcRYgZUC0LqC8QlH79DgF3ASjw9y97+w1VTTAzdb6LYnsMuSpX6+8m5hrR4g== dependencies: - "@storybook/addons" "6.0.21" - "@storybook/api" "6.0.21" - "@storybook/client-api" "6.0.21" - "@storybook/components" "6.0.21" - "@storybook/core-events" "6.0.21" - "@storybook/theming" "6.0.21" + "@storybook/addons" "6.0.26" + "@storybook/api" "6.0.26" + "@storybook/client-api" "6.0.26" + "@storybook/components" "6.0.26" + "@storybook/core-events" "6.0.26" + "@storybook/theming" "6.0.26" core-js "^3.0.1" fast-deep-equal "^3.1.1" global "^4.3.2" @@ -3890,7 +3891,7 @@ react-syntax-highlighter "^12.2.1" regenerator-runtime "^0.13.3" -"@storybook/addons@6.0.21", "@storybook/addons@^6.0.21": +"@storybook/addons@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.0.21.tgz#bd5229652102c3aed59b78ef6920ff6b482b4d78" integrity sha512-yDttNLc3vXqBxwK795ykgzTC6MpvuXDQuF4LHSlHZQe6wsMu1m3fljnbYdafJWdx6cNZwUblU3KYcR11PqhkPg== @@ -3905,6 +3906,21 @@ global "^4.3.2" regenerator-runtime "^0.13.3" +"@storybook/addons@6.0.26", "@storybook/addons@^6.0.21": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.0.26.tgz#343cbea3eee2d39413b80bc2d66535a7f61488fc" + integrity sha512-OhAApFKgsj9an7FLYfHI4cJQuZ4Zm6yoGOpaxhOvKQMw7dXUPsLvbCyw/6dZOLvaFhjJjQiXtbxtZG+UjR8nvA== + dependencies: + "@storybook/api" "6.0.26" + "@storybook/channels" "6.0.26" + "@storybook/client-logger" "6.0.26" + "@storybook/core-events" "6.0.26" + "@storybook/router" "6.0.26" + "@storybook/theming" "6.0.26" + core-js "^3.0.1" + global "^4.3.2" + regenerator-runtime "^0.13.3" + "@storybook/api@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/api/-/api-6.0.21.tgz#a25a1eb4d07dc43500e03c856db43baba46726f1" @@ -3931,6 +3947,32 @@ ts-dedent "^1.1.1" util-deprecate "^1.0.2" +"@storybook/api@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/api/-/api-6.0.26.tgz#c45222c132eb8bc2e383536adfebbeb7a89867d0" + integrity sha512-aszDoz1c6T+eRtTUwWvySoyd3gRXmQxsingD084NnEp4VfFLA5H7VS/0sre0ZvU5GWh8d9COxY0DS2Ry/QSKvw== + dependencies: + "@reach/router" "^1.3.3" + "@storybook/channels" "6.0.26" + "@storybook/client-logger" "6.0.26" + "@storybook/core-events" "6.0.26" + "@storybook/csf" "0.0.1" + "@storybook/router" "6.0.26" + "@storybook/semver" "^7.3.2" + "@storybook/theming" "6.0.26" + "@types/reach__router" "^1.3.5" + core-js "^3.0.1" + fast-deep-equal "^3.1.1" + global "^4.3.2" + lodash "^4.17.15" + memoizerific "^1.11.3" + react "^16.8.3" + regenerator-runtime "^0.13.3" + store2 "^2.7.1" + telejson "^5.0.2" + ts-dedent "^1.1.1" + util-deprecate "^1.0.2" + "@storybook/channel-postmessage@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.0.21.tgz#97e8f43c1b66f84c7b8271e447d45d4f66d355d1" @@ -3944,6 +3986,19 @@ qs "^6.6.0" telejson "^5.0.2" +"@storybook/channel-postmessage@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.0.26.tgz#a98a0132d6bdf06741afac2607e9feabe34ab98b" + integrity sha512-FT6lC8M5JlNBxPT0rYfmF1yl9mBv04nfYs82TZpp1CzpLxf7wxdCBZ8SSRmvWIVBoNwGZPDhIk5+6JWyDEISBg== + dependencies: + "@storybook/channels" "6.0.26" + "@storybook/client-logger" "6.0.26" + "@storybook/core-events" "6.0.26" + core-js "^3.0.1" + global "^4.3.2" + qs "^6.6.0" + telejson "^5.0.2" + "@storybook/channels@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.0.21.tgz#bc0951efacbaa5f8827693fba4fe7c2290b5772c" @@ -3953,6 +4008,15 @@ ts-dedent "^1.1.1" util-deprecate "^1.0.2" +"@storybook/channels@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.0.26.tgz#3e8678b4b40085081257a39b9e85fab13a19943c" + integrity sha512-H0iUorayYqS+zfhbjd+cYRzAdRLGLWUeWFu2Aa+oJ4/zeAQNL+DafWboHc567RQ4Vb5KqE5QZoCFskWUUYqJYA== + dependencies: + core-js "^3.0.1" + ts-dedent "^1.1.1" + util-deprecate "^1.0.2" + "@storybook/client-api@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.0.21.tgz#6a652dea67d219a31d18af0e05b9f17ba6c7c316" @@ -3976,6 +4040,29 @@ ts-dedent "^1.1.1" util-deprecate "^1.0.2" +"@storybook/client-api@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.0.26.tgz#ac9334ba86834e5cb23fc4fb577de60bda66164d" + integrity sha512-Qd5wR5b5lio/EchuJMhAmmJAE1pfvnEyu+JnyFGwMZLV9mN9NSspz+YsqbSCCDZsYcP5ewvPEnumIWqmj/wagQ== + dependencies: + "@storybook/addons" "6.0.26" + "@storybook/channel-postmessage" "6.0.26" + "@storybook/channels" "6.0.26" + "@storybook/client-logger" "6.0.26" + "@storybook/core-events" "6.0.26" + "@storybook/csf" "0.0.1" + "@types/qs" "^6.9.0" + "@types/webpack-env" "^1.15.2" + core-js "^3.0.1" + global "^4.3.2" + lodash "^4.17.15" + memoizerific "^1.11.3" + qs "^6.6.0" + stable "^0.1.8" + store2 "^2.7.1" + ts-dedent "^1.1.1" + util-deprecate "^1.0.2" + "@storybook/client-logger@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.0.21.tgz#20369addf9eb79fc0c85a2e0dcb48f5a1a544532" @@ -3984,6 +4071,14 @@ core-js "^3.0.1" global "^4.3.2" +"@storybook/client-logger@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.0.26.tgz#e3d28bd8dc02ec2c53a9d69773a68189590b746f" + integrity sha512-VNoL6/oehVhn3hZi9vrTNT+C/3oAZKV+smfZFnPtsCR/Fq7CKbmsBd0pGPL57f81RU8e8WygwrIlAGJTDSNIjw== + dependencies: + core-js "^3.0.1" + global "^4.3.2" + "@storybook/components@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/components/-/components-6.0.21.tgz#2f355370f993e0b7b9062094a03dffc2cdda91db" @@ -4012,6 +4107,34 @@ react-textarea-autosize "^8.1.1" ts-dedent "^1.1.1" +"@storybook/components@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/components/-/components-6.0.26.tgz#e1f6e16aae850a71c9ac7bdd1d44a068ec9cfdc1" + integrity sha512-8wigI1pDFJO1m1IQWPguOK+nOsaAVRWkVdu+2te/rDcIR9QNvMzzou0+Lhfp3zKSVT4E6mEoGB/TWXXF5Iq0sQ== + dependencies: + "@storybook/client-logger" "6.0.26" + "@storybook/csf" "0.0.1" + "@storybook/theming" "6.0.26" + "@types/overlayscrollbars" "^1.9.0" + "@types/react-color" "^3.0.1" + "@types/react-syntax-highlighter" "11.0.4" + core-js "^3.0.1" + fast-deep-equal "^3.1.1" + global "^4.3.2" + lodash "^4.17.15" + markdown-to-jsx "^6.11.4" + memoizerific "^1.11.3" + overlayscrollbars "^1.10.2" + polished "^3.4.4" + popper.js "^1.14.7" + react "^16.8.3" + react-color "^2.17.0" + react-dom "^16.8.3" + react-popper-tooltip "^2.11.0" + react-syntax-highlighter "^12.2.1" + react-textarea-autosize "^8.1.1" + ts-dedent "^1.1.1" + "@storybook/core-events@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.0.21.tgz#2ce51e6d7524e7543dbb29571beac1dbeb4e5f40" @@ -4019,6 +4142,13 @@ dependencies: core-js "^3.0.1" +"@storybook/core-events@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.0.26.tgz#61181c9a8610d26cc85d47f133a563879044ca2d" + integrity sha512-nWjS/+kMiw31OPgeJQaiFsJk9ZJJo3/d4c+kc6GOl2iC1H3Q4/5cm3NvJBn/7bUtKHmSFwfbDouj+XjUk5rZbQ== + dependencies: + core-js "^3.0.1" + "@storybook/core@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/core/-/core-6.0.21.tgz#105c2b90ab27e7b478cb1b7d10e9fe5aba5e0708" @@ -4178,6 +4308,18 @@ memoizerific "^1.11.3" qs "^6.6.0" +"@storybook/router@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/router/-/router-6.0.26.tgz#5b991001afa7d7eb5e40c53cd4c58266b6f9edfd" + integrity sha512-kQ1LF/2gX3IkjS1wX7CsoeBc9ptHQzOsyax16rUyJa769DT5vMNtFtQxjNXMqSiSapPg2yrXJFKQNaoWvKgQEQ== + dependencies: + "@reach/router" "^1.3.3" + "@types/reach__router" "^1.3.5" + core-js "^3.0.1" + global "^4.3.2" + memoizerific "^1.11.3" + qs "^6.6.0" + "@storybook/semver@^7.3.2": version "7.3.2" resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0" @@ -4220,6 +4362,24 @@ resolve-from "^5.0.0" ts-dedent "^1.1.1" +"@storybook/theming@6.0.26": + version "6.0.26" + resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.0.26.tgz#e5b545fb2653dfd1b043b567197d490b1c3c0da3" + integrity sha512-9yon2ofb9a+RT1pdvn8Njydy7XRw0qXcIsMqGsJRKoZecmRRozqB6DxH9Gbdf1vRSbM9gYUUDjbiMDFz7+4RiQ== + dependencies: + "@emotion/core" "^10.0.20" + "@emotion/is-prop-valid" "^0.8.6" + "@emotion/styled" "^10.0.17" + "@storybook/client-logger" "6.0.26" + core-js "^3.0.1" + deep-object-diff "^1.1.0" + emotion-theming "^10.0.19" + global "^4.3.2" + memoizerific "^1.11.3" + polished "^3.4.4" + resolve-from "^5.0.0" + ts-dedent "^1.1.1" + "@storybook/ui@6.0.21": version "6.0.21" resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.0.21.tgz#5dac2b68a30f5dba5457e0315f58977e07138968" @@ -4536,7 +4696,7 @@ "@babel/runtime" "^7.5.4" "@types/testing-library__react-hooks" "^3.3.0" -"@testing-library/react-hooks@^3.4.1", "@testing-library/react-hooks@^3.4.2": +"@testing-library/react-hooks@^3.4.2": version "3.4.2" resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.4.2.tgz#8deb94f7684e0d896edd84a4c90e5b79a0810bc2" integrity sha512-RfPG0ckOzUIVeIqlOc1YztKgFW+ON8Y5xaSPbiBkfj9nMkkiLhLeBXT5icfPX65oJV/zCZu4z8EVnUc6GY9C5A== @@ -4776,11 +4936,6 @@ dependencies: "@types/express" "*" -"@types/cookie@^0.3.3": - version "0.3.3" - resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" - integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== - "@types/cookie@^0.4.0": version "0.4.0" resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz#14f854c0f93d326e39da6e3b6f34f7d37513d108" @@ -4872,11 +5027,6 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/estree@0.0.45": - version "0.0.45" - resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.45.tgz#e9387572998e5ecdac221950dab3e8c3b16af884" - integrity sha512-jnqIUKDUqJbDIUxm0Uj7bnlMnRm1T/eZ9N+AVMqhPgzrba2GhGG5o/jCTwmdPK709nEZsGoMzXEDUjcXHa3W0g== - "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": version "4.17.9" resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.9.tgz#2d7b34dcfd25ec663c25c85d76608f8b249667f1" @@ -4967,11 +5117,6 @@ resolved "https://registry.npmjs.org/@types/history/-/history-4.7.5.tgz#527d20ef68571a4af02ed74350164e7a67544860" integrity sha512-wLD/Aq2VggCJXSjxEwrMafIP51Z+13H78nXIX0ABEuIGhmB5sNGbR113MOKo+yfw+RDo1ZU3DM6yfnnRF/+ouw== -"@types/history@^4.7.7": - version "4.7.8" - resolved "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" - integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== - "@types/html-minifier-terser@^5.0.0": version "5.1.0" resolved "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz#551a4589b6ee2cc9c1dff08056128aec29b94880" @@ -5081,14 +5226,6 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" -"@types/jest@^25.2.2": - version "25.2.3" - resolved "https://registry.npmjs.org/@types/jest/-/jest-25.2.3.tgz#33d27e4c4716caae4eced355097a47ad363fdcaf" - integrity sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw== - dependencies: - jest-diff "^25.2.1" - pretty-format "^25.2.1" - "@types/jquery@^3.3.34": version "3.5.1" resolved "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.1.tgz#cebb057acf5071c40e439f30e840c57a30d406c3" @@ -5213,6 +5350,11 @@ resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/minimist@^1.2.0": + version "1.2.0" + resolved "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" + integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY= + "@types/minipass@*": version "2.2.0" resolved "https://registry.npmjs.org/@types/minipass/-/minipass-2.2.0.tgz#51ad404e8eb1fa961f75ec61205796807b6f9651" @@ -5227,6 +5369,13 @@ dependencies: "@types/node" "*" +"@types/mock-fs@^4.13.0": + version "4.13.0" + resolved "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.0.tgz#b8b01cd2db588668b2532ecd21b1babd3fffb2c0" + integrity sha512-FUqxhURwqFtFBCuUj3uQMp7rPSQs//b3O9XecAVxhqS9y4/W8SIJEZFq2mmpnFVZBXwR/2OyPLE97CpyYiB8Mw== + dependencies: + "@types/node" "*" + "@types/morgan@^1.9.0": version "1.9.1" resolved "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.1.tgz#6457872df95647c1dbc6b3741e8146b71ece74bf" @@ -5234,7 +5383,7 @@ dependencies: "@types/node" "*" -"@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.4", "@types/node-fetch@^2.5.7": +"@types/node-fetch@2.5.7", "@types/node-fetch@^2.5.4": version "2.5.7" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== @@ -5262,10 +5411,10 @@ resolved "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" integrity sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw== -"@types/nodegit@0.26.8": - version "0.26.8" - resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.8.tgz#81a79307fbdd1cf028a8ec7cbc94020bac54be6f" - integrity sha512-CwcynPazar6cJxjn8PG2RIujNgFvH2pAoA/bBBvPTUrONVzc9hj0DRMYzioy7zI/+e8TfJJLP+DK26mEU0eErg== +"@types/nodegit@0.26.11": + version "0.26.11" + resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.11.tgz#0cbc5e929f23e5ffc536920e3a887e0b3f46d1a4" + integrity sha512-BGrY9F8lBtfU+Ne1Pjb9k/PUsfSCUAqPXgKkTkM6mc5213H5VAoM12zG/sz/cr3jI3AXcngNbAYWlqSrXsWJug== dependencies: "@types/node" "*" @@ -5821,7 +5970,7 @@ resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== -"@typescript-eslint/eslint-plugin@^3.8.0", "@typescript-eslint/eslint-plugin@^v3.10.1": +"@typescript-eslint/eslint-plugin@^v3.10.1": version "3.10.1" resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz#7e061338a1383f59edc204c605899f93dc2e2c8f" integrity sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ== @@ -5856,7 +6005,7 @@ eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^3.8.0", "@typescript-eslint/parser@^v3.10.1": +"@typescript-eslint/parser@^v3.10.1": version "3.10.1" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz#1883858e83e8b442627e1ac6f408925211155467" integrity sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw== @@ -6193,11 +6342,6 @@ acorn@^7.1.1, acorn@^7.2.0: resolved "https://registry.npmjs.org/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== -acorn@^7.4.0: - version "7.4.1" - resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" - integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== - address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -9087,7 +9231,7 @@ cross-env@^7.0.0: dependencies: cross-spawn "^7.0.1" -cross-fetch@3.0.6, cross-fetch@^3.0.4, cross-fetch@^3.0.5: +cross-fetch@3.0.6, cross-fetch@^3.0.5, cross-fetch@^3.0.6: version "3.0.6" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== @@ -9453,7 +9597,7 @@ cyclist@^1.0.1: resolved "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -cypress@*, cypress@^4.11.0, cypress@^4.2.0: +cypress@*, cypress@^4.2.0: version "4.12.1" resolved "https://registry.npmjs.org/cypress/-/cypress-4.12.1.tgz#0ead1b9f4c0917d69d8b57f996b6e01fe693b6ec" integrity sha512-9SGIPEmqU8vuRA6xst2CMTYd9sCFCxKSzrHt0wr+w2iAQMCIIsXsQ5Gplns1sT6LDbZcmLv6uehabAOl3fhc9Q== @@ -9786,11 +9930,6 @@ deep-extend@0.6.0, deep-extend@^0.6.0, deep-extend@~0.6.0: resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== -deep-freeze@^0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz#3a0b0005de18672819dfd38cd31f91179c893e84" - integrity sha1-OgsABd4YZygZ39OM0x+RF5yJPoQ= - deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.3" resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" @@ -9806,11 +9945,6 @@ deepmerge@4.2.2, deepmerge@^4.2.2: resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== -default-branch@^1.0.8: - version "1.0.8" - resolved "https://registry.npmjs.org/default-branch/-/default-branch-1.0.8.tgz#0e2f36a90e3b0d9f73cdf8e02841364ed35b8b54" - integrity sha512-pViUZEnaxd/Hbu880MEXF7XqV8RJ1ssDlkvzx+woQhcKW8px3BrVDvwBGn09zRiKZ+gOLipK7ft5x3no+3vb8A== - default-gateway@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" @@ -10549,41 +10683,6 @@ es-abstract@^1.17.0, es-abstract@^1.17.0-next.0, es-abstract@^1.17.0-next.1, es- string.prototype.trimleft "^2.1.1" string.prototype.trimright "^2.1.1" -es-abstract@^1.17.5: - version "1.17.7" - resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" - integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== - dependencies: - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.1" - is-callable "^1.2.2" - is-regex "^1.1.1" - object-inspect "^1.8.0" - object-keys "^1.1.1" - object.assign "^4.1.1" - string.prototype.trimend "^1.0.1" - string.prototype.trimstart "^1.0.1" - -es-abstract@^1.18.0-next.0: - version "1.18.0-next.1" - resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" - integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== - dependencies: - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.1" - is-callable "^1.2.2" - is-negative-zero "^2.0.0" - is-regex "^1.1.1" - object-inspect "^1.8.0" - object-keys "^1.1.1" - object.assign "^4.1.1" - string.prototype.trimend "^1.0.1" - string.prototype.trimstart "^1.0.1" - es-array-method-boxes-properly@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" @@ -10674,11 +10773,6 @@ es6-weak-map@^2.0.2: es6-iterator "^2.0.3" es6-symbol "^3.1.1" -esbuild@^0.6.18: - version "0.6.34" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.6.34.tgz#76565a60e006f45d5f273b6e59e61ed0816551f5" - integrity sha512-InRdL/Q96pUucPqovJzvuLhquZr6jOn81FDVwFjCKz1rYKIm9OdOC+7Fs4vr6x48vKBl5LzKgtjU39BUpO636A== - esbuild@^0.7.7: version "0.7.7" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.7.7.tgz#fd86332d1c0a047231bd6da930666028c40c14bf" @@ -10727,9 +10821,9 @@ escodegen@^1.14.1, escodegen@^1.9.1: source-map "~0.6.1" eslint-config-prettier@^6.0.0: - version "6.10.0" - resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.10.0.tgz#7b15e303bf9c956875c948f6b21500e48ded6a7f" - integrity sha512-AtndijGte1rPILInUdHjvKEGbIV06NuvPrqlIEaEaWtbtvJh464mDeyGMdZEQMsGvC0ZVkiex1fSNcC4HAbRGg== + version "6.14.0" + resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.14.0.tgz#390e7863a8ae99970981933826476169285b3a27" + integrity sha512-DbVwh0qZhAC7CNDWcq8cBdK6FcVHiMTKmCypOPWeZkp9hJ8xYwTaWSa6bb6cjfi8KOeJy0e9a8Izxyx+O4+gCQ== dependencies: get-stdin "^6.0.0" @@ -10767,13 +10861,6 @@ eslint-plugin-cypress@^2.10.3: dependencies: globals "^11.12.0" -eslint-plugin-cypress@^2.11.1: - version "2.11.2" - resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.2.tgz#a8f3fe7ec840f55e4cea37671f93293e6c3e76a0" - integrity sha512-1SergF1sGbVhsf7MYfOLiBhdOg6wqyeV9pXUAIDIffYTGMN3dTBQS9nFAzhLsHhO+Bn0GaVM1Ecm71XUidQ7VA== - dependencies: - globals "^11.12.0" - eslint-plugin-graphql@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/eslint-plugin-graphql/-/eslint-plugin-graphql-4.0.0.tgz#d238ff2baee4d632cfcbe787a7a70a1f50428358" @@ -10870,23 +10957,6 @@ eslint-plugin-react@^7.12.4: string.prototype.matchall "^4.0.2" xregexp "^4.3.0" -eslint-plugin-react@^7.20.5: - version "7.21.5" - resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.21.5.tgz#50b21a412b9574bfe05b21db176e8b7b3b15bff3" - integrity sha512-8MaEggC2et0wSF6bUeywF7qQ46ER81irOdWS4QWxnnlAEsnzeBevk1sWh7fhpCghPpXb+8Ks7hvaft6L/xsR6g== - dependencies: - array-includes "^3.1.1" - array.prototype.flatmap "^1.2.3" - doctrine "^2.1.0" - has "^1.0.3" - jsx-ast-utils "^2.4.1 || ^3.0.0" - object.entries "^1.1.2" - object.fromentries "^2.0.2" - object.values "^1.1.1" - prop-types "^15.7.2" - resolve "^1.18.1" - string.prototype.matchall "^4.0.2" - eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" @@ -10903,14 +10973,6 @@ eslint-scope@^5.0.0, eslint-scope@^5.1.0: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-scope@^5.1.1: - version "5.1.1" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" - integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== - dependencies: - esrecurse "^4.3.0" - estraverse "^4.1.1" - eslint-utils@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.0.0.tgz#7be1cc70f27a72a76cd14aa698bcabed6890e1cd" @@ -10918,23 +10980,11 @@ eslint-utils@^2.0.0: dependencies: eslint-visitor-keys "^1.1.0" -eslint-utils@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" - integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== - dependencies: - eslint-visitor-keys "^1.1.0" - eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.2.0.tgz#74415ac884874495f78ec2a97349525344c981fa" integrity sha512-WFb4ihckKil6hu3Dp798xdzSfddwKKU3+nGniKF6HfeW6OLd2OUDEPP7TcHtB5+QXOKg2s6B2DaMPE1Nn/kxKQ== -eslint-visitor-keys@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" - integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== - eslint-visitor-keys@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8" @@ -10982,49 +11032,6 @@ eslint@^7.1.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -eslint@^7.6.0: - version "7.11.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-7.11.0.tgz#aaf2d23a0b5f1d652a08edacea0c19f7fadc0b3b" - integrity sha512-G9+qtYVCHaDi1ZuWzBsOWo2wSwd70TXnU6UHA3cTYHp7gCTXZcpggWFoUVAMRarg68qtPoNfFbzPh+VdOgmwmw== - dependencies: - "@babel/code-frame" "^7.0.0" - "@eslint/eslintrc" "^0.1.3" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.0.1" - doctrine "^3.0.0" - enquirer "^2.3.5" - eslint-scope "^5.1.1" - eslint-utils "^2.1.0" - eslint-visitor-keys "^2.0.0" - espree "^7.3.0" - esquery "^1.2.0" - esutils "^2.0.2" - file-entry-cache "^5.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^5.0.0" - globals "^12.1.0" - ignore "^4.0.6" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - js-yaml "^3.13.1" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash "^4.17.19" - minimatch "^3.0.4" - natural-compare "^1.4.0" - optionator "^0.9.1" - progress "^2.0.0" - regexpp "^3.1.0" - semver "^7.2.1" - strip-ansi "^6.0.0" - strip-json-comments "^3.1.0" - table "^5.2.3" - text-table "^0.2.0" - v8-compile-cache "^2.0.3" - esm@^3.2.25: version "3.2.25" resolved "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" @@ -11039,15 +11046,6 @@ espree@^7.1.0: acorn-jsx "^5.2.0" eslint-visitor-keys "^1.2.0" -espree@^7.3.0: - version "7.3.0" - resolved "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz#dc30437cf67947cf576121ebd780f15eeac72348" - integrity sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw== - dependencies: - acorn "^7.4.0" - acorn-jsx "^5.2.0" - eslint-visitor-keys "^1.3.0" - esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" @@ -11067,13 +11065,6 @@ esrecurse@^4.1.0: dependencies: estraverse "^4.1.0" -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.3.0" resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" @@ -11084,11 +11075,6 @@ estraverse@^5.1.0: resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642" integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== -estraverse@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" - integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== - estree-walker@^0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" @@ -11099,11 +11085,6 @@ estree-walker@^1.0.1: resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg== -estree-walker@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.1.tgz#f8e030fb21cefa183b44b7ad516b747434e7a3e0" - integrity sha512-tF0hv+Yi2Ot1cwj9eYHtxC0jB9bmjacjQs6ZBTj82H8JwUywFuc+7E83NWfNMwHXZc11mjfFcVXPe9gEP4B8dg== - esutils@^2.0.2: version "2.0.3" resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -12724,7 +12705,7 @@ graphql-upload@^8.0.2: http-errors "^1.7.3" object-path "^0.11.4" -graphql@15.3.0, graphql@^15.0.0, graphql@^15.3.0: +graphql@15.3.0, graphql@^15.3.0: version "15.3.0" resolved "https://registry.npmjs.org/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278" integrity sha512-GTCJtzJmkFLWRfFJuoo9RWWa/FfamUHgiFosxi/X1Ani4AVWbeyBenZTNX6dM+7WSbbFfTo/25eh0LLkwHMw2w== @@ -12900,7 +12881,7 @@ he@^1.1.0, he@^1.2.0: resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -headers-utils@^1.1.9, headers-utils@^1.2.0: +headers-utils@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/headers-utils/-/headers-utils-1.2.0.tgz#5e10d1bc9d2bccf789547afca5b991a3167241e8" integrity sha512-4/BMXcWrJErw7JpM87gF8MNEXcIMLzepYZjNRv/P9ctgupl2Ywa3u1PgHtNhSRq84bHH9Ndlkdy7bSi+bZ9I9A== @@ -13703,11 +13684,6 @@ is-callable@^1.1.4, is-callable@^1.1.5: resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== -is-callable@^1.2.2: - version "1.2.2" - resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" - integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== - is-ci@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" @@ -13727,13 +13703,6 @@ is-color-stop@^1.0.0: rgb-regex "^1.0.1" rgba-regex "^1.0.0" -is-core-module@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.0.0.tgz#58531b70aed1db7c0e8d4eb1a0a2d1ddd64bd12d" - integrity sha512-jq1AH6C8MuteOoBPwkxHafmByhL9j5q4OaPGdbuD+ZtQJVzH+i6E3BJDQcBA09k57i2Hh2yQbEG8yObZ0jdlWw== - dependencies: - has "^1.0.3" - is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -13902,11 +13871,6 @@ is-module@^1.0.0: resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= -is-negative-zero@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461" - integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= - is-npm@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" @@ -14425,14 +14389,6 @@ jest-esm-transformer@^1.0.0: "@babel/core" "^7.4.4" "@babel/plugin-transform-modules-commonjs" "^7.4.4" -jest-fetch-mock@^3.0.3: - version "3.0.3" - resolved "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" - integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== - dependencies: - cross-fetch "^3.0.4" - promise-polyfill "^8.1.3" - jest-get-type@^25.2.6: version "25.2.6" resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" @@ -14727,11 +14683,6 @@ jose@^2.0.2: dependencies: "@panva/asn1.js" "^1.0.0" -joycon@^2.2.5: - version "2.2.5" - resolved "https://registry.npmjs.org/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615" - integrity sha512-YqvUxoOcVPnCp0VU1/56f+iKSdvIRJYPznH22BdXV3xMk75SFXhWeJkZ8C9XxUWt1b5x2X1SxuFygW1U0FmkEQ== - js-cookie@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" @@ -15137,14 +15088,6 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: array-includes "^3.0.3" object.assign "^4.1.0" -"jsx-ast-utils@^2.4.1 || ^3.0.0": - version "3.1.0" - resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.1.0.tgz#642f1d7b88aa6d7eb9d8f2210e166478444fa891" - integrity sha512-d4/UOjg+mxAWxCiF0c5UTSwyqbchkbqCvK87aBovhnh8GtysTjWmgC63tY0cJx/HzGgm9qnA147jVBdpOiQ2RA== - dependencies: - array-includes "^3.1.1" - object.assign "^4.1.1" - jwa@^1.4.1: version "1.4.1" resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" @@ -16573,22 +16516,6 @@ ms@2.1.2, ms@^2.0.0, ms@^2.1.1: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -msw@^0.19.5: - version "0.19.5" - resolved "https://registry.npmjs.org/msw/-/msw-0.19.5.tgz#7d2a1a852ccf1644d3db6735d69fff6777aac33f" - integrity sha512-J5eQ++gDVZoHPC8gVXtWcakLjgmPipvFj/sEnlRV/WViXuiq2CamSqO3Wbh6H8bAmj+k2vUWCfcVT1HjMdKB2Q== - dependencies: - "@open-draft/until" "^1.0.0" - "@types/cookie" "^0.3.3" - chalk "^4.0.0" - cookie "^0.4.1" - graphql "^15.0.0" - headers-utils "^1.1.9" - node-match-path "^0.4.2" - node-request-interceptor "^0.2.5" - statuses "^2.0.0" - yargs "^15.3.1" - msw@^0.20.5: version "0.20.5" resolved "https://registry.npmjs.org/msw/-/msw-0.20.5.tgz#b6141080c0d8b17c451d9ca36c28cc47b4ac487a" @@ -16606,6 +16533,42 @@ msw@^0.20.5: statuses "^2.0.0" yargs "^15.4.1" +msw@^0.21.2: + version "0.21.2" + resolved "https://registry.npmjs.org/msw/-/msw-0.21.2.tgz#74ed10b8eb224325652a3c3812b5460dac297bd8" + integrity sha512-XOJehxtJThNFdMJdVjxDAbZ8KuC3UltOlO5nQDks0Q1yzSUqqKcVUjbKrH7T+K2hckBr0KEY2fwJHv21R4BV2A== + dependencies: + "@open-draft/until" "^1.0.3" + "@types/cookie" "^0.4.0" + chalk "^4.1.0" + chokidar "^3.4.2" + cookie "^0.4.1" + graphql "^15.3.0" + headers-utils "^1.2.0" + node-fetch "^2.6.1" + node-match-path "^0.4.4" + node-request-interceptor "^0.5.1" + statuses "^2.0.0" + yargs "^16.0.3" + +msw@^0.21.3: + version "0.21.3" + resolved "https://registry.npmjs.org/msw/-/msw-0.21.3.tgz#d073842f9570a08f4041806a2c7303a9b8494602" + integrity sha512-voPc/EJsjarvi454vSEuozZQQqLG4AUHT6qQL5Ah47lq7sGCpc7icByeUlfvEj5+MvaugN0c7JwXyCa2rxu8cA== + dependencies: + "@open-draft/until" "^1.0.3" + "@types/cookie" "^0.4.0" + chalk "^4.1.0" + chokidar "^3.4.2" + cookie "^0.4.1" + graphql "^15.3.0" + headers-utils "^1.2.0" + node-fetch "^2.6.1" + node-match-path "^0.4.4" + node-request-interceptor "^0.5.1" + statuses "^2.0.0" + yargs "^16.0.3" + multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" @@ -16866,7 +16829,7 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-match-path@^0.4.2, node-match-path@^0.4.4: +node-match-path@^0.4.4: version "0.4.4" resolved "https://registry.npmjs.org/node-match-path/-/node-match-path-0.4.4.tgz#516a10926093c0cc6f237d020685b593b19baebb" integrity sha512-pBq9gp7TG0r0VXuy/oeZmQsjBSnYQo7G886Ly/B3azRwZuEtHCY155dzmfoKWcDPGgyfIGD8WKVC7h3+6y7yTg== @@ -16925,14 +16888,6 @@ node-releases@^1.1.52, node-releases@^1.1.58: resolved "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084" integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA== -node-request-interceptor@^0.2.5: - version "0.2.6" - resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.2.6.tgz#541278d7033bb6a8befb5dd793f83428cf6446a2" - integrity sha512-aJW1tPSM7nzuZFRe+C/KSz22GJO3CVFMxHHmMGX8Z+tjP7TCIVbzeckLFVfJG68BdVgrdOOP7Ejc57ag820eyA== - dependencies: - debug "^4.1.1" - headers-utils "^1.2.0" - node-request-interceptor@^0.3.5: version "0.3.5" resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.3.5.tgz#4b26159617829c9a70643012c0fdc3ae4c78ae43" @@ -16942,6 +16897,15 @@ node-request-interceptor@^0.3.5: debug "^4.1.1" headers-utils "^1.2.0" +node-request-interceptor@^0.5.1: + version "0.5.1" + resolved "https://registry.npmjs.org/node-request-interceptor/-/node-request-interceptor-0.5.1.tgz#b4757a033bde4412d9ffc4503804abb28ed962d2" + integrity sha512-ex5mlI5nGokxocomS2Rj2r1aspmt7qZoI8OvKLt24ylp1bYCzGQ+0XD911guCNDb/kKLMIGC67HHyeFrJCz7jA== + dependencies: + "@open-draft/until" "^1.0.3" + debug "^4.1.1" + headers-utils "^1.2.0" + nodegit@0.27.0, nodegit@^0.27.0: version "0.27.0" resolved "https://registry.npmjs.org/nodegit/-/nodegit-0.27.0.tgz#4e8cc236f60e1c97324a5acff99056fe116a6ebe" @@ -17186,11 +17150,6 @@ object-inspect@^1.7.0: resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== -object-inspect@^1.8.0: - version "1.8.0" - resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" - integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== - object-is@^1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4" @@ -17223,16 +17182,6 @@ object.assign@^4.1.0: has-symbols "^1.0.0" object-keys "^1.0.11" -object.assign@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.1.tgz#303867a666cdd41936ecdedfb1f8f3e32a478cdd" - integrity sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.18.0-next.0" - has-symbols "^1.0.1" - object-keys "^1.1.1" - object.defaults@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz#3a7f868334b407dea06da16d88d5cd29e435fecf" @@ -17253,15 +17202,6 @@ object.entries@^1.1.0, object.entries@^1.1.1: function-bind "^1.1.1" has "^1.0.3" -object.entries@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add" - integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.5" - has "^1.0.3" - "object.fromentries@^2.0.0 || ^1.0.0", object.fromentries@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9" @@ -18922,11 +18862,6 @@ promise-inflight@^1.0.1: resolved "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= -promise-polyfill@^8.1.3: - version "8.1.3" - resolved "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.3.tgz#8c99b3cf53f3a91c68226ffde7bde81d7f904116" - integrity sha512-MG5r82wBzh7pSKDRa9y+vllNHz3e3d4CNj1PQE4BQYxLme0gKYYBm9YENq+UkEikyZ0XbiGWxYlVw3Rl9O/U8g== - promise-retry@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/promise-retry/-/promise-retry-1.1.1.tgz#6739e968e3051da20ce6497fb2b50f6911df3d6d" @@ -20514,14 +20449,6 @@ resolve@1.17.0, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.11.0 dependencies: path-parse "^1.0.6" -resolve@^1.18.1: - version "1.18.1" - resolved "https://registry.npmjs.org/resolve/-/resolve-1.18.1.tgz#018fcb2c5b207d2a6424aee361c5a266da8f4130" - integrity sha512-lDfCPaMKfOJXjy0dPayzPdF1phampNWr3qFCjAu+rw/qbQmr5jWH5xN2hwh9QKfw9E5v4hwV7A+jrCmL8yjjqA== - dependencies: - is-core-module "^2.0.0" - path-parse "^1.0.6" - responselike@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -20636,20 +20563,13 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -rollup-plugin-dts@1.4.13, rollup-plugin-dts@^1.4.10: +rollup-plugin-dts@1.4.13: version "1.4.13" resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.13.tgz#4f086e84f4fdcc1f49160799ebc66f6b09db292b" integrity sha512-7mxoQ6PcmCkBE5ZhrjGDL4k42XLy8BkSqpiRi1MipwiGs+7lwi4mQkp2afX+OzzLjJp/TGM8llfe8uayIUhPEw== optionalDependencies: "@babel/code-frame" "^7.10.4" -rollup-plugin-dts@1.4.8: - version "1.4.8" - resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.8.tgz#3b0eadc285aede11c3f5ced72b56b5f7735df95c" - integrity sha512-2qHB4B3oaTyi1mDmqDzsRGKlev32v3EhMUAmc45Cn9eB22R7FsYDiigvT9XdM/oQzfd0qv5MkeZju8DP0nauiA== - optionalDependencies: - "@babel/code-frame" "^7.10.4" - rollup-plugin-esbuild@^2.0.0: version "2.3.0" resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.3.0.tgz#7c107d4508af80d30966a148b306cb7d5b36b258" @@ -20657,16 +20577,7 @@ rollup-plugin-esbuild@^2.0.0: dependencies: "@rollup/pluginutils" "^3.1.0" -rollup-plugin-esbuild@^2.4.2: - version "2.5.2" - resolved "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-2.5.2.tgz#fd7d4a88518898012a9d07e4c3ef8b4009c244d3" - integrity sha512-E4q3ac1AlMd0m0ZRYffdiorOt2eZcxfbdPaqBLs7JLnPE8krgIAihOD6cTUc54UJjoOMA9WcY63TR+JKWLzYNw== - dependencies: - "@rollup/pluginutils" "^4.0.0" - joycon "^2.2.5" - strip-json-comments "^3.1.1" - -rollup-plugin-peer-deps-external@^2.2.2, rollup-plugin-peer-deps-external@^2.2.3: +rollup-plugin-peer-deps-external@^2.2.2: version "2.2.3" resolved "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.3.tgz#059a8aec1eefb48a475e9fcedc3b9e3deb521213" integrity sha512-W6IePXTExGXVDAlfZbNUUrx3GxUOZP248u5n4a4ID1XZMrbQ+uGeNiEfapvdzwx0qZi5DNH/hDLiPUP+pzFIxg== @@ -20709,13 +20620,6 @@ rollup-pluginutils@^2.8.2: dependencies: estree-walker "^0.6.1" -rollup@2.23.1: - version "2.23.1" - resolved "https://registry.npmjs.org/rollup/-/rollup-2.23.1.tgz#d458d28386dc7660c2e8a4978bea6f9494046c20" - integrity sha512-Heyl885+lyN/giQwxA8AYT2GY3U+gOlTqVLrMQYno8Z1X9lAOpfXPiKiZCyPc25e9BLJM3Zlh957dpTlO4pa8A== - optionalDependencies: - fsevents "~2.1.2" - rollup@2.23.x: version "2.23.0" resolved "https://registry.npmjs.org/rollup/-/rollup-2.23.0.tgz#b7ab1fee0c0e60132fd0553c4df1e9cdacfada9d" @@ -20731,13 +20635,6 @@ rollup@^0.63.4: "@types/estree" "0.0.39" "@types/node" "*" -rollup@^2.23.1: - version "2.32.0" - resolved "https://registry.npmjs.org/rollup/-/rollup-2.32.0.tgz#ac58c8e85782bea8aa2d440fc05aba345013582a" - integrity sha512-0FIG1jY88uhCP2yP4CfvtKEqPDRmsUwfY1kEOOM+DH/KOGATgaIFd/is1+fQOxsvh62ELzcFfKonwKWnHhrqmw== - optionalDependencies: - fsevents "~2.1.2" - rsvp@^4.8.4: version "4.8.5" resolved "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" @@ -21859,14 +21756,6 @@ string.prototype.padstart@^3.0.0: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" -string.prototype.trimend@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" - integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.5" - string.prototype.trimleft@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74" @@ -21883,14 +21772,6 @@ string.prototype.trimright@^2.1.1: define-properties "^1.1.3" function-bind "^1.1.1" -string.prototype.trimstart@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" - integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.5" - string_decoder@^1.0.0, string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -21993,11 +21874,6 @@ strip-json-comments@^3.1.0: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz#7638d31422129ecf4457440009fba03f9f9ac180" integrity sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w== -strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" @@ -23891,7 +23767,7 @@ whatwg-fetch@^2.0.4: resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== -whatwg-fetch@^3.0.0, whatwg-fetch@^3.4.0, whatwg-fetch@^3.4.1: +whatwg-fetch@^3.0.0, whatwg-fetch@^3.4.1: version "3.4.1" resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz#e5f871572d6879663fa5674c8f833f15a8425ab3" integrity sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ==