conflict fixed

Signed-off-by: lukzerom <lukzerom@gmail.com>
This commit is contained in:
lukzerom
2021-12-21 10:13:41 +01:00
227 changed files with 3370 additions and 2148 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Add Optional Props to Override Icon for SidebarSearch and SidebarSearchModal Component
+22
View File
@@ -0,0 +1,22 @@
---
'@backstage/backend-common': patch
---
Fixed bug in backend-common to allow passing of remote option in order to enable passing remote url in --config option. The remote option should be passed along with reloadIntervalSeconds from packages/backend/src/index.ts (Updated the file as well)
These changes are needed in `packages/backend/src/index.ts` if remote URLs are desired to be passed in --config option and read and watch remote files for config.
```diff
@@ -86,7 +86,11 @@ async function main() {
const config = await loadBackendConfig({
argv: process.argv,
logger,
+ remote: {
+ reloadIntervalSeconds: 60 * 10 // Check remote config changes every 10 minutes. Change to your desired interval in seconds
+ }
});
+
const createEnv = makeCreateEnv(config);
const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck'));
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': patch
---
In case remote.reloadIntervalSeconds is passed, it must be a valid positive value
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-user-settings': patch
---
Fix undefined identity bug in UserSettingsProfileCard caused by using deprecated methods of the IdentityApi
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added peerPluginDependencies option to experimentalInstallationRecipe for install command to install plugins it depends on.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-tech-radar': minor
---
Add new property to enable open links in a new window/tab
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-org': patch
---
Fixed bug in OwnershipCard component where text wasn't correctly pluralized
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-rails': patch
---
Add new options to rails new (force and skipTests)
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-common': minor
---
Create catalog-common and add catalog permissions.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-bazaar': patch
'@backstage/plugin-bazaar-backend': patch
---
made the linkage between a Bazaar project to a catalog Entity optional
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': minor
---
Removed deprecated option `env` from `LoadConfigOptions` and associated tests
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/test-utils': minor
---
Removed deprecated `Keyboard` class which has been superseded by `@testing-library/user-event#userEvent`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-rollbar-backend': patch
---
Bump `axios`
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/core-app-api': minor
---
- Removed deprecated definition `createApp` from `@backstage/core-app-api` which has been replaced by `@backstage/app-defaults#createApp`
- Removed deprecated type `BackstagePluginWithAnyOutput`
- Removed deprecated constructors for `GithubAuth`, `OAuth2`, and `SamlAuth` as the `create` method should be used instead
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/core-plugin-api': minor
---
- Removed deprecated option `description` from `ApiRefConfig`
- Removed descriptions from all plugin API refs
- Removed deprecated parameters `path`, `icon`, and `title` in `createRouteRef`
- Removed deprecated types `Error` and `ErrorContext` from `ErrorApi`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/test-utils': minor
---
Removed deprecated `msw` definition which was replaced by calling `setupRequestMockHandlers` directly
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': minor
---
Removed deprecated option `configPaths` as it has been superseded by `configTargets`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Fixed the fallback identity population to correctly generate an entity reference for `userEntityRef` if no token is provided.
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/app-defaults': patch
'@backstage/core-app-api': minor
'@backstage/core-plugin-api': minor
---
- Removed deprecation configuration option `theme` from `AppTheme` of the `AppThemeApi`
- Removed reference to `theme` in the `app-defaults` default `AppTheme`
- Removed logic in `AppThemeProvider` that creates `ThemeProvider` from `appTheme.theme`
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-search-backend': minor
---
Search result location filtering
This change introduces a filter for search results based on their location protocol. The intention is to filter out unsafe or
malicious values before they can be consumed by the frontend. By default locations must be http/https URLs (or paths).
+17
View File
@@ -0,0 +1,17 @@
---
'@backstage/core-components': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-circleci': patch
'@backstage/plugin-cloudbuild': patch
'@backstage/plugin-github-actions': patch
'@backstage/plugin-gitops-profiles': patch
'@backstage/plugin-jenkins': patch
'@backstage/plugin-lighthouse': patch
'@backstage/plugin-org': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-search': patch
'@backstage/plugin-sentry': patch
'@backstage/plugin-sonarqube': patch
---
Prefer using `Link` from `@backstage/core-components` rather than material-UI.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-jenkins-backend': patch
---
feature: add crumbIssuer option to Jenkins (optional) configuration, improve the UI to show a notification after executing the action re-build
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-jenkins': patch
---
feature: add crumbIssuer option to Jenkins (optional) configuration, improve the UI to show a notification after executing the action re-build
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Schema-validate local storage cached session info on load
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': minor
---
Removed deprecated types `ApiRefType` and `ApiRefsToTypes`
+1
View File
@@ -42,3 +42,4 @@
/.changeset/search-* @backstage/techdocs-core
/.changeset/techdocs-* @backstage/techdocs-core
/cypress/src/integration/plugins/techdocs.spec.ts @backstage/techdocs-core
/plugins/apache-airflow @backstage/reviewers @cmpadden
+13
View File
@@ -9,3 +9,16 @@ search:
- plugins/search/**/*
- plugins/search-*/**/*
- packages/search-*/**/*
docs-like-code:
- plugins/techdocs/**/*
- plugins/techdocs-*/**/*
- packages/techdocs-*/**/*
documentation:
- docs/**/*
microsite:
- microsite/**/*
auth:
- plugins/auth-backend/**/*
- packages/core-app-api/src/apis/implementations/auth/**/*
- packages/core-app-api/src/lib/Auth*/**/*
- packages/core-plugin-api/src/apis/definitions/auth.ts
+7 -1
View File
@@ -92,10 +92,12 @@ Figma
firehydrant
FireHydrant
Firekube
Firestore
Fiverr
gitbeaker
GitHub
GitLab
Gource
Grafana
graphql
GraphQL
@@ -189,6 +191,7 @@ OAuth
octokit
oidc
Okta
Oldsberg
onboarding
Onboarding
OpenShift
@@ -196,6 +199,8 @@ orgs
pagerduty
pageview
parallelization
Patrik
Peloton
plantuml
Platformize
Podman
@@ -295,6 +300,7 @@ truthy
typeahead
ui
unbreak
Unconference
unmanaged
unregister
unregistration
@@ -306,6 +312,7 @@ utils
validator
validators
varchar
Wayfair
Weaveworks
Webpack
winston
@@ -318,4 +325,3 @@ Zalando
Zhou
zoomable
zsh
Firestore
+4
View File
@@ -2,6 +2,10 @@
# [Backstage](https://backstage.io)
> 🎄 The maintainers will be taking a break over the holidays from week beginning 20th Dec. The Repository and Discord may be quieter than usual. We will be back next year, rested and restored, on Jan. 3. 🎄
> 🎅 Happy holidays and a Happy New Year to all, and especially those that have made this year special for Backstage! 🎅
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![CNCF Status](https://img.shields.io/badge/cncf%20status-sandbox-blue.svg)](https://www.cncf.io/projects)
[![Main CI Build](https://github.com/backstage/backstage/workflows/Main%20Master%20Build/badge.svg)](https://github.com/backstage/backstage/actions?query=workflow%3A%22Main+Master+Build%22)
+4 -4
View File
@@ -128,8 +128,8 @@ the core APIs. The core APIs are the ones exported by
[configApiRef](../reference/core-plugin-api.configapiref.md).
The core APIs are loaded for any app created with
[createApp](../reference/core-app-api.createapp.md) from
[@backstage/core-plugin-api](../reference/core-plugin-api.md), which means that
[createApp](../reference/app-defaults.createapp.md) from
[@backstage/core-plugin-api](../reference/app-defaults.md), which means that
there is no step that needs to be taken to include these APIs in an app.
### Plugin APIs
@@ -168,7 +168,7 @@ Lastly, the app itself is the final point where APIs can be added, and what has
the final say in what APIs will be loaded at runtime. The app may override the
factories for any of the core or plugin APIs, with the exception of the config,
app theme, and identity APIs. These are static APIs that are tied into the
[createApp](../reference/core-app-api.createapp.md) implementation, and
[createApp](../reference/app-defaults.createapp.md) implementation, and
therefore not possible to override.
Overriding APIs is useful for apps that want to switch out behavior to tailor it
@@ -313,7 +313,7 @@ The common development environment for plugins is included in
[createDevApp](../reference/dev-utils.createdevapp.md) function creates an
application with implementations for all core APIs already present. Contrary to
the method for wiring up Utility API implementations in an app created with
[createApp](../reference/core-app-api.createapp.md),
[createApp](../reference/app-defaults.createapp.md),
[createDevApp](../reference/dev-utils.createdevapp.md) uses automatic dependency
injection. This is to make it possible to replace any API implementation, and
having that be reflected in dependents of that API.
+1 -1
View File
@@ -48,6 +48,6 @@ The Microsoft provider is a structure with three configuration keys:
## Adding the provider to the Backstage frontend
To add the provider to the frontend, add the `microsoftAuthApi` reference and
To add the provider to the frontend, add the `microsoftAuthApiRef` reference and
`SignInPage` component as shown in
[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page).
+4 -1
View File
@@ -67,7 +67,7 @@ production build.
## Configuration Files
It is possible to have multiple configuration files (bundled and/or remote),
It is possible to have multiple configuration files (bundled and/or remote\*),
both to support different environments, but also to define configuration that is
local to specific packages. The configuration files to load are selected using a
`--config <local-path|url>` flag, and it is possible to load any number of
@@ -77,6 +77,9 @@ root when running the backend, you would use `--config ../../my-config.yaml`,
and for config file on a config server you would use
`--config https://some.domain.io/app-config.yaml`
**Note**: In case URLs are passed, it is also needed to set the remote option in
the loadBackendConfig call.
If no `config` flags are specified, the default behavior is to load
`app-config.yaml` and, if it exists, `app-config.local.yaml` from the repo root.
In the provided project setup, `app-config.local.yaml` is `.gitignore`'d, making
@@ -26,7 +26,7 @@ You can create your own Field Extension by using the
`API` like below:
```tsx
//packages/app/scaffolder/MyCustomExtension/MyCustomExtension.tsx
//packages/app/src/scaffolder/MyCustomExtension/MyCustomExtension.tsx
import React from 'react';
import { FieldProps, FieldValidation } from '@rjsf/core';
import FormControl from '@material-ui/core/FormControl';
@@ -68,7 +68,7 @@ export const myCustomValidation = (
```
```tsx
// packages/app/scaffolder/MyCustomExtension/extensions.ts
// packages/app/src/scaffolder/MyCustomExtension/extensions.ts
/*
This is where the magic happens and creates the custom field extension.
@@ -94,7 +94,7 @@ export const MyCustomFieldExtension = plugin.provide(
```
```tsx
// packages/app/scaffolder/MyCustomExtension/index.ts
// packages/app/src/scaffolder/MyCustomExtension/index.ts
export { MyCustomFieldExtension } from './extension';
```
@@ -102,7 +102,7 @@ export { MyCustomFieldExtension } from './extension';
Once all these files are in place, you then need to provide your custom
extension to the `scaffolder` plugin.
You do this in `packages/app/App.tsx`. You need to provide the
You do this in `packages/app/src/App.tsx`. You need to provide the
`customFieldExtensions` as children to the `ScaffolderPage`.
```tsx
@@ -118,7 +118,7 @@ const routes = (
Should look something like this instead:
```tsx
import { MyCustomFieldExtension } from './scafffolder/MyCustomExtension';
import { MyCustomFieldExtension } from './scaffolder/MyCustomExtension';
const routes = (
<FlatRoutes>
...
+1 -1
View File
@@ -54,7 +54,7 @@ For example, adding the theme that we created in the previous section can be
done like this:
```ts
import { createApp } from '@backstage/core-app-api';
import { createApp } from '@backstage/app-defaults';
const app = createApp({
apis: ...,
+1 -1
View File
@@ -368,7 +368,7 @@ The following is an example of creation and usage of a parameterized route:
```tsx
// Creation of a parameterized route
const myRouteRef = createRouteRef({
title: 'My Named Route',
id: 'myroute',
params: ['name']
})
-1
View File
@@ -48,7 +48,6 @@ that implements the `OAuthApi` type, it's now working in the frontend too.
```ts
const spotifyAuthApiRef = createApiRef<OAuthApi>({
id: 'core.auth.spotify',
description: 'Provides authentication towards Spotify APIs',
});
```
@@ -99,7 +99,6 @@ export interface MyAwesomeApi {
export const myAwesomeApiRef = createApiRef<MyAwesomeApi>({
id: 'plugin.my-awesome-api.service',
description: 'Example API definition',
});
```
@@ -0,0 +1,145 @@
---
title: Backstage Wrapped 2021
author: Lee Mills, Spotify
authorURL: https://github.com/leemills83
authorImageURL: https://avatars1.githubusercontent.com/u/1236238?s=460&v=4
---
![Backstage in 2021: 75+ public adopters, 500+ contributors, almost 2,000 forks, 9,000+ contributions, 4,000+ Discord members](assets/21-12-20/backstage-wrapped-2021.png)
What a year 2021 has been for the Backstage project and community. As the year comes to a close, we can look back at some of the happenings, the successes, and welcome all the newcomers to our growing community.
In a year that has had its fair share of ups and downs, this community has been something that has brought me so much in the support you provide each other, your eagerness, passion, and willingness to share. Thank you to all of you for creating such a great community, and welcome to everyone joining us. I cant wait to meet you all and see what great things we can create together in the new year.
In 2021 weve seen the project and community grow in every dimension, reaching over 75 public adopters, over 500 contributors from around the world all bringing new ideas and perspectives. Weve had so many of you sharing and demoing your hard work in our monthly community sessions, added new plugins, built new features and really started to see Backstage have an impact for engineering teams across so many of our users.
Now lets hear from the maintainers and look at the stats!
<!--truncate-->
## The community grew…and grew
This year, Netflix, [Expedia][metrics], Wayfair, Peloton, [Box][10], [DAZN][dazn], TELUS, and Unity all officially joined the community — along with more than 50 other public [adopters][md]. Contributors grew by leaps and bounds, too.
Patrik Oldsberg ([@Rugvip][rugvip]):
> Its been awesome to see the engagement by the community grow even more in 2021, especially the way people within the community support each other. Thanks to our growing number of stable, experienced, and knowledgeable contributors its now possible to get help in places like Discord anytime, day or night.
### More adopters, more contributors
![Accelerated growth of Public Backstage Adopters, from July 2020December 2021.](assets/21-12-20/public-backstage-adopters-2021.png)
In January, Backstage had 17 public adopters. By September, [we had 50][50]. And the curve only got steeper from there.
- **75+ public adopters**
- **500+ contributors**
- **4,000+ Discord members**
- **9,000+ PRs & issues**
### A very busy repo
Along with the growth in contributors, PRs, and issues, our active, thriving community has crossed (or is just about to cross) other big milestones on GitHub, too.
- **Nearly 2,000 forks**
- **20,000+ commits**
- **14,000+ GitHub stars**
### Where in the world is the Backstage community?
Watch the visualization below to see where in the world all those contributions came from this past year.
<iframe width="780" height="440" src="https://www.youtube.com/embed/O1xtDv_XDt4" frameBorder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowFullScreen></iframe>
### What does 9,000 contributions look like?
<iframe width="780" height="440" src="https://www.youtube.com/embed/bCqhoK7it2g" frameBorder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowFullScreen></iframe>
Can you spot your name in the video above?
Picking up from the end of 2020, the video shows every single contribution to the Backstage source code over this past year. The visualization was built using [Gource.io][gource]:
> Software projects are displayed by Gource as an animated tree with the root directory of the project at its centre. Directories appear as branches with files as leaves. Developers can be seen working on the tree at the times they contributed to the project.
And if your name isnt in there, its never too late to get started [contributing][contributing] — so that you too can become a bright, sparkling, laser-beam-shooting spider-node thing in 2022!
## Other 2021 milestones
As the community grew, so did the product — with the addition of new features as well as important performance and stability improvements to the core.
Ben Lambert ([@benjdlambert][blam]):
> Weve had a lot of priorities this year. Our first priority was rebuilding the catalog to make it more efficient. Then, toward the later part of the year, weve been stabilizing the core with deprecations and API changes ahead of our 1.0 release of the Core Framework next year.
>
> Its been a super productive year for us all as Core Maintainers, and the product has come on leaps and bounds with the help of everyone involved in the project!
### New features
These new features were built with the community, and made with customization and extensibility in mind.
- **Kubernetes Service Monitoring ([RFC][k8s])**
- **Search Platform ([Alpha][search])**
- **Composable Homepage ([README][home])**
- **Authorization Framework ([RFC][auth])**
### Major stability updates
As we get the ball rolling for [CNCF Incubation][cncf], weve been busy updating, refining, documenting, deprecating, and stabilizing the core.
- **Software Templates ([Beta][scaffolder])**
- **TechDocs ([Beta][techdocs])**
- **API Reference ([docs][api])**
### 50+ open source plugins
We reached [50+ plugins][plugins] built by the community this year, including official plugins (**Snyk**, **FireHydrant**), highly requested plugins (**PagerDuty**, **Prometheus**), and plugins based on new ideas and functionality (**XCMetrics**, **[Tech Insights][techrfc]**) — all tailor-made for Backstage.
### In the spotlight
This year Backstage was center stage at both community-organized events and at major conferences.
- **New (and [expanded][09]) Community Sessions**
- **Backstage Users Unconference + [Open Mic][openmic]**
- **KubeCon + CloudNativeCon [Europe][europe] & [North America][na]**
- **GitHub Universe 2021 [Keynote][day2]**
Find more talks, podcasts, blog posts, and Backstage in the news [here][news].
## Goodbye, 2021. Hello, 2022!
Lastly, thanks to everyone in the global Backstage community who contributed to the open source project in 2021.
This was the first year Spotify hosted [Backstage Community Sessions][meetups], and its been a great source of inspiration, motivation, and joy getting to know all the people behind the contributions. We look forward to seeing all your faces again in 2022 — and hopefully many new ones as well.
Until then, [grab this Backstage Zoom background][zoom] to use at the next Community Session.
![Backstage Zoom Background](assets/21-12-20/Backstage-background-02-2021.png)
See you then! Happy holidays from everyone on the Backstage team at Spotify!
[metrics]: https://backstage.spotify.com/blog/measuring-backstage-proof-of-value-at-expedia/
[10]: https://backstage.spotify.com/blog/community-session/10-box-and-spotify-paid-plugins/
[dazn]: https://backstage.spotify.com/blog/adopter-spotlight/dazn/
[md]: https://github.com/backstage/backstage/blob/master/ADOPTERS.md
[rugvip]: https://github.com/Rugvip
[50]: https://backstage.io/blog/2021/09/30/50-public-adopters
[gource]: https://gource.io
[contributing]: https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md
[blam]: https://github.com/benjdlambert
[k8s]: https://github.com/backstage/backstage/issues/2857
[search]: https://backstage.io/blog/2021/06/24/announcing-backstage-search-platform
[home]: https://github.com/backstage/backstage/blob/master/plugins/home/README.md
[auth]: https://backstage.spotify.com/blog/authorization-framework-rfc/
[scaffolder]: https://backstage.io/blog/2021/07/26/software-templates-are-now-in-beta
[techdocs]: https://backstage.io/blog/2021/09/16/the-techdocs-beta-has-landed
[api]: https://backstage.io/docs/reference/
[cncf]: https://github.com/cncf/toc/pull/717
[plugins]: https://backstage.io/plugins
[techrfc]: https://github.com/backstage/backstage/issues/7576
[09]: https://backstage.spotify.com/blog/community-session/09-prometheus-homepage-templates/
[openmic]: https://backstage-openmic.com
[europe]: https://www.youtube.com/watch?v=aIURaocR5D8
[na]: https://backstage.spotify.com/blog/kubecon-na-2021/
[day2]: https://www.youtube.com/watch?v=7yQJlYvpoYs&t=269s
[news]: https://github.com/backstage/community#newsletters
[meetups]: https://github.com/backstage/community#meetups
[zoom]: https://drive.google.com/file/d/14wOPXEBZ5Qmi29ms4Sqp1m56dM0AIHbB/view?usp=sharing
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -73,7 +73,7 @@ const DefaultErrorBoundaryFallback = ({
};
/**
* Creates a set of default components to pass along to {@link @backstage/core-app-api#createApp}.
* Creates a set of default components to pass along to {@link @backstage/core-app-api#createSpecializedApp}.
*
* @public
*/
@@ -28,7 +28,6 @@ export const themes: AppTheme[] = [
title: 'Light Theme',
variant: 'light',
icon: <LightIcon />,
theme: lightTheme,
Provider: ({ children }) => (
<ThemeProvider theme={lightTheme}>
<CssBaseline>{children}</CssBaseline>
@@ -40,7 +39,6 @@ export const themes: AppTheme[] = [
title: 'Dark Theme',
variant: 'dark',
icon: <DarkIcon />,
theme: darkTheme,
Provider: ({ children }) => (
<ThemeProvider theme={darkTheme}>
<CssBaseline>{children}</CssBaseline>
+2
View File
@@ -21,6 +21,7 @@ import { GitLabIntegration } from '@backstage/integration';
import { isChildPath } from '@backstage/cli-common';
import { JsonValue } from '@backstage/types';
import { Knex } from 'knex';
import { LoadConfigOptionsRemote } from '@backstage/config-loader';
import { Logger as Logger_2 } from 'winston';
import { MergeResult } from 'isomorphic-git';
import { PushResult } from 'isomorphic-git';
@@ -338,6 +339,7 @@ export function isDatabaseConflictError(e: unknown): boolean;
// @public
export function loadBackendConfig(options: {
logger: Logger_2;
remote?: LoadConfigOptionsRemote;
argv: string[];
}): Promise<Config>;
+3 -1
View File
@@ -23,6 +23,7 @@ import {
loadConfig,
ConfigSchema,
ConfigTarget,
LoadConfigOptionsRemote,
} from '@backstage/config-loader';
import { AppConfig, Config, ConfigReader } from '@backstage/config';
import { JsonValue } from '@backstage/types';
@@ -178,6 +179,7 @@ let currentCancelFunc: () => void;
export async function loadBackendConfig(options: {
logger: Logger;
// process.argv or any other overrides
remote?: LoadConfigOptionsRemote;
argv: string[];
}): Promise<Config> {
const args = parseArgs(options.argv);
@@ -202,8 +204,8 @@ export async function loadBackendConfig(options: {
const config = new ObservableConfigProxy(options.logger);
const { appConfigs } = await loadConfig({
configRoot: paths.targetRoot,
configPaths: [],
configTargets: configTargets,
remote: options.remote,
watch: {
onChange(newConfigs) {
options.logger.info(
+1
View File
@@ -89,6 +89,7 @@ async function main() {
argv: process.argv,
logger,
});
const createEnv = makeCreateEnv(config);
const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck'));
+65 -16
View File
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { Step, PackageWithInstallRecipe } from './types';
import {
Step,
PackageWithInstallRecipe,
PeerPluginDependencies,
} from './types';
import { fetchPackageInfo } from '../../lib/versioning';
import { NotFoundError } from '../../lib/errors';
import * as stepDefinitionMap from './steps';
@@ -52,7 +56,10 @@ type Steps = Array<{
}>;
class PluginInstaller {
static async resolveSteps(pkg: PackageWithInstallRecipe) {
static async resolveSteps(
pkg: PackageWithInstallRecipe,
versionToInstall?: string,
) {
const steps: Steps = [];
// collectDependencies
@@ -62,7 +69,7 @@ class PluginInstaller {
target: 'packages/app',
type: 'dependencies' as const,
name: pkg.name,
query: `^${pkg.version}`,
query: versionToInstall || `^${pkg.version}`,
});
steps.push({
type: 'dependencies',
@@ -97,22 +104,64 @@ class PluginInstaller {
}
}
export default async (pluginId?: string, cmd?: Command) => {
// TODO(himanshu): If no plugin id is provided, it should list all plugins available. Maybe in some other command?
async function installPluginAndPeerPlugins(pkg: PackageWithInstallRecipe) {
const pluginDeps: PeerPluginDependencies = new Map();
pluginDeps.set(pkg.name, { pkg });
await loadPeerPluginDeps(pkg, pluginDeps);
let pkg: PackageWithInstallRecipe;
if (pluginId) {
pkg = await fetchPluginPackage(pluginId);
} else if (cmd?.from) {
// TODO(himanshu): Also support reading directly from url
pkg = await fs.readJson(cmd.from);
} else {
console.log(`Installing ${pkg.name} AND any peer plugin dependencies.`);
for (const [_pluginDepName, pluginDep] of pluginDeps.entries()) {
const { pkg: pluginDepPkg, versionToInstall } = pluginDep;
console.log(
`Installing plugin: ${pluginDepPkg.name}: ${
versionToInstall || pluginDepPkg.version
}`,
);
const steps = await PluginInstaller.resolveSteps(
pluginDepPkg,
versionToInstall,
);
const installer = new PluginInstaller(steps);
await installer.run();
}
}
async function loadPackageJson(
plugin: string,
): Promise<PackageWithInstallRecipe> {
if (plugin.endsWith('package.json')) {
// Install from local package if pluginId is a package.json file - needs to be absolute path
return await fs.readJson(plugin);
}
return await fetchPluginPackage(plugin);
}
async function loadPeerPluginDeps(
pkg: PackageWithInstallRecipe,
pluginMap: PeerPluginDependencies,
) {
for (const [pluginId, pluginVersion] of Object.entries(
pkg.experimentalInstallationRecipe?.peerPluginDependencies ?? {},
)) {
const depPkg = await loadPackageJson(pluginId);
if (!pluginMap.get(depPkg.name)) {
pluginMap.set(depPkg.name, {
pkg: depPkg,
versionToInstall: pluginVersion,
});
await loadPeerPluginDeps(depPkg, pluginMap);
}
}
}
export default async (pluginId?: string, cmd?: Command) => {
const from = pluginId || cmd?.from;
// TODO(himanshu): If no plugin id is provided, it should list all plugins available. Maybe in some other command?
if (!from) {
throw new Error(
'Missing both <plugin-id> or a package.json file path in the --from flag.',
);
}
const steps = await PluginInstaller.resolveSteps(pkg);
const installer = new PluginInstaller(steps);
await installer.run();
const pkg = await loadPackageJson(from);
await installPluginAndPeerPlugins(pkg);
};
@@ -38,6 +38,7 @@ export type SerializedStep = {
export type InstallationRecipe = {
type?: 'frontend' | 'backend';
steps: SerializedStep[];
peerPluginDependencies: { [pluginId: string]: string };
};
/** package.json data */
@@ -46,6 +47,14 @@ export type PackageWithInstallRecipe = YarnInfoInspectData & {
experimentalInstallationRecipe?: InstallationRecipe;
};
export type PeerPluginDependencies = Map<
string,
{
pkg: PackageWithInstallRecipe;
versionToInstall?: string;
}
>;
export interface Step {
run(): Promise<void>;
}
-1
View File
@@ -59,7 +59,6 @@ export async function loadCliConfig(options: Options) {
? async name => process.env[name] || 'x'
: undefined,
configRoot: paths.targetRoot,
configPaths: [],
configTargets: configTargets,
});
@@ -45,9 +45,9 @@ return something()
it('should refactor imports', () => {
const input = `
/* COPYRIGHT: ME */
import { Button as MyButton, createApiRef, createApp } from '@backstage/core';
import { Button as MyButton, createApiRef, createSpecializedApp } from '@backstage/core';
const app = createApp();
const app = createSpecializedApp();
const apiRef = createApiRef();
const button = <MyButton />
`;
@@ -57,9 +57,9 @@ const button = <MyButton />
import { Button as MyButton } from '@backstage/core-components';
import { createApiRef } from '@backstage/core-plugin-api';
import { createApp } from '@backstage/core-app-api';
import { createSpecializedApp } from '@backstage/core-app-api';
const app = createApp();
const app = createSpecializedApp();
const apiRef = createApiRef();
const button = <MyButton />
`;
-6
View File
@@ -45,9 +45,7 @@ export function loadConfig(
// @public
export type LoadConfigOptions = {
configRoot: string;
configPaths: string[];
configTargets: ConfigTarget[];
env?: string;
experimentalEnvFunc?: (name: string) => Promise<string | undefined>;
remote?: LoadConfigOptionsRemote;
watch?: LoadConfigOptionsWatch;
@@ -103,8 +101,4 @@ export type TransformFunc<T extends number | string | boolean> = (
visibility: ConfigVisibility;
},
) => T | undefined;
// Warnings were encountered during analysis:
//
// src/loader.d.ts:33:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "@backstage/config-loader" does not have an export "configTargets"
```
+4 -16
View File
@@ -118,9 +118,7 @@ describe('loadConfig', () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: [],
configTargets: [],
env: 'production',
}),
).resolves.toEqual({
appConfigs: [
@@ -146,9 +144,7 @@ describe('loadConfig', () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: [],
configTargets: [{ url: configUrl }],
env: 'production',
remote: {
reloadIntervalSeconds: 30,
},
@@ -173,9 +169,10 @@ describe('loadConfig', () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: ['/root/app-config2.yaml'],
configTargets: [{ path: '/root/app-config.yaml' }],
env: 'production',
configTargets: [
{ path: '/root/app-config.yaml' },
{ path: '/root/app-config2.yaml' },
],
}),
).resolves.toEqual({
appConfigs: [
@@ -207,9 +204,7 @@ describe('loadConfig', () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: ['/root/app-config.yaml'],
configTargets: [{ path: '/root/app-config.yaml' }],
env: 'production',
}),
).resolves.toEqual({
appConfigs: [
@@ -231,12 +226,10 @@ describe('loadConfig', () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: [],
configTargets: [
{ path: '/root/app-config.yaml' },
{ path: '/root/app-config.development.yaml' },
],
env: 'development',
}),
).resolves.toEqual({
appConfigs: [
@@ -274,9 +267,7 @@ describe('loadConfig', () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: [],
configTargets: [{ path: '/root/app-config.substitute.yaml' }],
env: 'development',
}),
).resolves.toEqual({
appConfigs: [
@@ -302,7 +293,6 @@ describe('loadConfig', () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: [],
configTargets: [],
watch: {
onChange: onChange.resolve,
@@ -353,7 +343,6 @@ describe('loadConfig', () => {
await expect(
loadConfig({
configRoot: '/root',
configPaths: [],
configTargets: [{ url: configUrl }],
watch: {
onChange: onChange.resolve,
@@ -401,7 +390,6 @@ describe('loadConfig', () => {
await loadConfig({
configRoot: '/root',
configPaths: [],
configTargets: [],
watch: {
onChange: () => {
+11 -18
View File
@@ -45,7 +45,7 @@ export type LoadConfigOptionsWatch = {
export type LoadConfigOptionsRemote = {
/**
* An optional remote config reloading period, in seconds
* A remote config reloading period, in seconds
*/
reloadIntervalSeconds: number;
};
@@ -59,17 +59,9 @@ export type LoadConfigOptions = {
// The root directory of the config loading context. Used to find default configs.
configRoot: string;
/** Absolute paths to load config files from. Configs from earlier paths have lower priority.
* @deprecated Use {@link configTargets} instead.
*/
configPaths: string[];
// Paths to load config files from. Configs from earlier paths have lower priority.
configTargets: ConfigTarget[];
/** @deprecated This option has been removed */
env?: string;
/**
* Custom environment variable loading function
*
@@ -114,20 +106,21 @@ export async function loadConfig(
.filter((e): e is { path: string } => e.hasOwnProperty('path'))
.map(configTarget => configTarget.path);
// Append deprecated configPaths to the absolute config paths received via configTargets.
options.configPaths.forEach(cp => {
if (!configPaths.includes(cp)) {
configPaths.push(cp);
}
});
const configUrls: string[] = options.configTargets
.slice()
.filter((e): e is { url: string } => e.hasOwnProperty('url'))
.map(configTarget => configTarget.url);
if (remote === undefined && configUrls.length > 0) {
throw new Error(`Remote config detected but this feature is turned off`);
if (remote === undefined) {
if (configUrls.length > 0) {
throw new Error(
`Please make sure you are passing the remote option when loading remote configurations. See https://backstage.io/docs/conf/writing#configuration-files for detailed info.`,
);
}
} else if (remote.reloadIntervalSeconds <= 0) {
throw new Error(
`Remote config must be contain a non zero reloadIntervalSeconds: <seconds> value`,
);
}
// If no paths are provided, we default to reading
-30
View File
@@ -26,7 +26,6 @@ import { bitbucketAuthApiRef } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { Config } from '@backstage/config';
import { ConfigReader } from '@backstage/config';
import { createApp as createApp_2 } from '@backstage/app-defaults';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { ErrorApi } from '@backstage/core-plugin-api';
import { ErrorApiError } from '@backstage/core-plugin-api';
@@ -277,19 +276,6 @@ export type BackstageApp = {
getRouter(): ComponentType<{}>;
};
// @public @deprecated
export type BackstagePluginWithAnyOutput = Omit<
BackstagePlugin<any, any>,
'output'
> & {
output(): (
| PluginOutput
| {
type: string;
}
)[];
};
// @public
export class BitbucketAuth {
// (undocumented)
@@ -315,11 +301,6 @@ export type BootErrorPageProps = {
export { ConfigReader };
// @public @deprecated
export function createApp(
options?: Parameters<typeof createApp_2>[0],
): BackstageApp & AppContext;
// @public
export function createFetchApi(options: {
baseImplementation?: typeof fetch | undefined;
@@ -408,10 +389,6 @@ export type FlatRoutesProps = {
// @public
export class GithubAuth implements OAuthApi, SessionApi {
// Warning: (ae-forgotten-export) The symbol "SessionManager" needs to be exported by the entry point index.d.ts
//
// @deprecated
constructor(sessionManager: SessionManager<GithubSession>);
// (undocumented)
static create(options: OAuthApiCreateOptions): GithubAuth;
// (undocumented)
@@ -488,11 +465,6 @@ export class OAuth2
BackstageIdentityApi,
SessionApi
{
// @deprecated
constructor(options: {
sessionManager: SessionManager<OAuth2Session>;
scopeTransform: (scopes: string[]) => string[];
});
// (undocumented)
static create(options: OAuth2CreateOptions): OAuth2;
// (undocumented)
@@ -573,8 +545,6 @@ export type OneLoginAuthCreateOptions = {
export class SamlAuth
implements ProfileInfoApi, BackstageIdentityApi, SessionApi
{
// @deprecated
constructor(sessionManager: SessionManager<SamlSession>);
// (undocumented)
static create(options: AuthApiCreateOptions): SamlAuth;
// (undocumented)
+2 -1
View File
@@ -42,7 +42,8 @@
"prop-types": "^15.7.2",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"zen-observable": "^0.8.15"
"zen-observable": "^0.8.15",
"zod": "^3.11.6"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
@@ -14,25 +14,25 @@
* limitations under the License.
*/
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import {
AuthRequestOptions,
BackstageIdentity,
OAuthApi,
ProfileInfo,
SessionApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
} from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import {
AuthSessionStore,
RefreshingAuthSessionManager,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { OAuthApiCreateOptions } from '../types';
import { OptionalRefreshSessionManagerMux } from '../../../../lib/AuthSessionManager/OptionalRefreshSessionManagerMux';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { OAuthApiCreateOptions } from '../types';
import { GithubSession, githubSessionSchema } from './types';
export type GithubAuthResponse = {
providerInfo: {
@@ -105,6 +105,7 @@ export default class GithubAuth implements OAuthApi, SessionApi {
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
}),
storageKey: `${provider.id}Session`,
schema: githubSessionSchema,
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
@@ -118,10 +119,9 @@ export default class GithubAuth implements OAuthApi, SessionApi {
return new GithubAuth(sessionManagerMux);
}
/**
* @deprecated will be made private in the future. Use create method instead.
*/
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
private constructor(
private readonly sessionManager: SessionManager<GithubSession>,
) {}
async signIn() {
await this.getAccessToken();
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export * from './types';
export type { GithubSession } from './types';
export { default as GithubAuth } from './GithubAuth';
@@ -15,6 +15,7 @@
*/
import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
import { z } from 'zod';
/**
* Session information for GitHub auth.
@@ -30,3 +31,25 @@ export type GithubSession = {
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
export const githubSessionSchema: z.ZodSchema<GithubSession> = z.object({
providerInfo: z.object({
accessToken: z.string(),
scopes: z.set(z.string()),
expiresAt: z.date().optional(),
}),
profile: z.object({
email: z.string().optional(),
displayName: z.string().optional(),
picture: z.string().optional(),
}),
backstageIdentity: z.object({
id: z.string(),
token: z.string(),
identity: z.object({
type: z.literal('user'),
userEntityRef: z.string(),
ownershipEntityRefs: z.array(z.string()),
}),
}),
});
@@ -15,6 +15,8 @@
*/
import OAuth2 from './OAuth2';
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
@@ -23,14 +25,24 @@ const PREFIX = 'https://www.googleapis.com/auth/';
const scopeTransform = (x: string[]) => x;
let getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('OAuth2', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
const oauth2 = OAuth2.create({
scopeTransform: scopeTransform,
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe(
@@ -43,12 +55,13 @@ describe('OAuth2', () => {
});
it('should transform scopes', async () => {
const getSession = jest.fn().mockResolvedValue({
getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
const oauth2 = OAuth2.create({
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
expect(await oauth2.getAccessToken('my-scope')).toBe('access-token');
@@ -59,12 +72,13 @@ describe('OAuth2', () => {
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
const oauth2 = OAuth2.create({
scopeTransform: scopeTransform,
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
expect(await oauth2.getIdToken()).toBe('id-token');
@@ -72,12 +86,13 @@ describe('OAuth2', () => {
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
const oauth2 = OAuth2.create({
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
expect(await oauth2.getIdToken({ optional: true })).toBe('id-token');
@@ -87,7 +102,7 @@ describe('OAuth2', () => {
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
@@ -97,9 +112,10 @@ describe('OAuth2', () => {
},
})
.mockRejectedValue(error);
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
const oauth2 = OAuth2.create({
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
@@ -120,7 +136,7 @@ describe('OAuth2', () => {
scopes: new Set(),
},
};
const getSession = jest
getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
@@ -130,9 +146,10 @@ describe('OAuth2', () => {
scopes: new Set(),
},
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
const oauth2 = OAuth2.create({
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
// Grab the expired session first
@@ -120,10 +120,7 @@ export default class OAuth2
private readonly sessionManager: SessionManager<OAuth2Session>;
private readonly scopeTransform: (scopes: string[]) => string[];
/**
* @deprecated will be made private in the future. Use create method instead.
*/
constructor(options: {
private constructor(options: {
sessionManager: SessionManager<OAuth2Session>;
scopeTransform: (scopes: string[]) => string[];
}) {
@@ -14,24 +14,24 @@
* limitations under the License.
*/
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
ProfileInfo,
BackstageIdentity,
SessionState,
AuthRequestOptions,
ProfileInfoApi,
BackstageIdentity,
BackstageIdentityApi,
ProfileInfo,
ProfileInfoApi,
SessionApi,
SessionState,
} from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
import { SamlSession } from './types';
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { AuthApiCreateOptions } from '../types';
import { SamlSession, samlSessionSchema } from './types';
export type SamlAuthResponse = {
profile: ProfileInfo;
@@ -72,6 +72,7 @@ export default class SamlAuth
const authSessionStore = new AuthSessionStore<SamlSession>({
manager: sessionManager,
storageKey: `${provider.id}Session`,
schema: samlSessionSchema,
});
return new SamlAuth(authSessionStore);
@@ -81,10 +82,9 @@ export default class SamlAuth
return this.sessionManager.sessionState$();
}
/**
* @deprecated will be made private in the future. Use create method instead.
*/
constructor(private readonly sessionManager: SessionManager<SamlSession>) {}
private constructor(
private readonly sessionManager: SessionManager<SamlSession>,
) {}
async signIn() {
await this.getBackstageIdentity({});
@@ -13,7 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ProfileInfo, BackstageIdentity } from '@backstage/core-plugin-api';
import { BackstageIdentity, ProfileInfo } from '@backstage/core-plugin-api';
import { z } from 'zod';
/**
* Session information for SAML auth.
@@ -25,3 +27,21 @@ export type SamlSession = {
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
export const samlSessionSchema: z.ZodSchema<SamlSession> = z.object({
userId: z.string(),
profile: z.object({
email: z.string().optional(),
displayName: z.string().optional(),
picture: z.string().optional(),
}),
backstageIdentity: z.object({
id: z.string(),
token: z.string(),
identity: z.object({
type: z.literal('user'),
userEntityRef: z.string(),
ownershipEntityRefs: z.array(z.string()),
}),
}),
});
@@ -15,8 +15,6 @@
*/
import React, { useMemo, useEffect, useState, PropsWithChildren } from 'react';
import { ThemeProvider } from '@material-ui/core/styles';
import CssBaseline from '@material-ui/core/CssBaseline';
import { useApi, appThemeApiRef, AppTheme } from '@backstage/core-plugin-api';
import { useObservable } from 'react-use';
@@ -90,20 +88,5 @@ export function AppThemeProvider({ children }: PropsWithChildren<{}>) {
throw new Error('App has no themes');
}
if (appTheme.Provider) {
return <appTheme.Provider children={children} />;
}
// eslint-disable-next-line no-console
console.warn(
"DEPRECATION WARNING: A provided app theme is using the deprecated 'theme' property " +
'and should be migrated to use a Provider instead. ' +
'See https://backstage.io/docs/api/deprecations#app-theme for more info.',
);
return (
<ThemeProvider theme={appTheme.theme}>
<CssBaseline>{children}</CssBaseline>
</ThemeProvider>
);
return <appTheme.Provider children={children} />;
}
@@ -1,38 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApp as createDefaultApp } from '@backstage/app-defaults';
import { AppContext, BackstageApp } from './types';
/**
* Creates a new Backstage App.
*
* @deprecated Use {@link @backstage/app-defaults#createApp} from `@backstage/app-defaults` instead
* @param options - A set of options for creating the app
* @public
*/
export function createApp(
options?: Parameters<typeof createDefaultApp>[0],
): BackstageApp & AppContext {
// eslint-disable-next-line no-console
console.warn(
'DEPRECATION WARNING: The createApp function from @backstage/core-app-api will soon be removed, ' +
'migrate to importing createApp from the @backstage/app-defaults package instead. ' +
'If you do not wish to use a standard app configuration but instead supply all options yourself ' +
' you can use createSpecializedApp from @backstage/core-app-api instead.',
);
return createDefaultApp(options) as BackstageApp & AppContext;
}
@@ -23,6 +23,13 @@ import { AppOptions, BackstageApp } from './types';
* @public
* @param options - A set of options for creating the app
* @returns
* @remarks
*
* You will most likely want to use {@link @backstage/app-defaults#createApp},
* however, this low-level API allows you to provide a full set of options,
* including your own `components`, `icons`, `defaultApis`, and `themes`. This
* is particularly useful if you are not using `@backstage/core-components` or
* MUI, as it allows you to avoid those dependencies completely.
*/
export function createSpecializedApp(options: AppOptions): BackstageApp {
return new AppManager(options);
-1
View File
@@ -14,7 +14,6 @@
* limitations under the License.
*/
export { createApp } from './createApp';
export { createSpecializedApp } from './createSpecializedApp';
export { defaultConfigLoader } from './defaultConfigLoader';
export * from './types';
+3 -26
View File
@@ -189,7 +189,7 @@ type TargetRouteMap<
/**
* A function that can bind from external routes of a given plugin, to concrete
* routes of other plugins. See {@link createApp}.
* routes of other plugins. See {@link createSpecializedApp}.
*
* @public
*/
@@ -204,30 +204,7 @@ export type AppRouteBinder = <
) => void;
/**
* Internal helper type that represents a plugin with any type of output.
*
* @public
* @remarks
* @deprecated Will be removed
*
* The `type: string` type is there to handle output from newer or older plugin
* API versions that might not be supported by this version of the app API, but
* we don't want to break at the type checking level. We only use this more
* permissive type for the `createApp` options, as we otherwise want to stick
* to using the type for the outputs that we know about in this version of the
* app api.
*
* TODO(freben): This should be marked internal but that's not supported by the api report generation tools yet
*/
export type BackstagePluginWithAnyOutput = Omit<
BackstagePlugin<any, any>,
'output'
> & {
output(): (PluginOutput | { type: string })[];
};
/**
* The options accepted by {@link createApp}.
* The options accepted by {@link createSpecializedApp}.
*
* @public
*/
@@ -329,7 +306,7 @@ export type AppOptions = {
};
/**
* The public API of the output of {@link createApp}.
* The public API of the output of {@link createSpecializedApp}.
*
* @public
*/
@@ -13,11 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { withLogCollector } from '@backstage/test-utils';
import { z } from 'zod';
import { AuthSessionStore } from './AuthSessionStore';
import { SessionManager } from './types';
const defaultOptions = {
storageKey: 'my-key',
schema: z.any(),
sessionScopes: (session: string) => new Set(session.split(' ')),
};
@@ -147,4 +151,48 @@ describe('GheAuth AuthSessionStore', () => {
store.sessionState$();
expect(manager.sessionState$).toHaveBeenCalled();
});
it('should schema-validate stored data', async () => {
const manager = new MockManager();
const firstStore = new AuthSessionStore<boolean>({
manager,
storageKey: 'a',
schema: z.boolean(),
sessionScopes: () => new Set(),
});
const secondStore = new AuthSessionStore<number>({
manager,
storageKey: 'a',
schema: z.number(),
sessionScopes: () => new Set(),
});
firstStore.setSession(true);
await expect(firstStore.getSession({})).resolves.toBe(true);
await expect(
withLogCollector(async () => {
await expect(secondStore.getSession({})).resolves.toBeUndefined();
}),
).resolves.toMatchObject({
log: [
expect.stringContaining(
'Failed to load session from local storage because it did not conform to the expected schema',
),
],
});
await expect(
withLogCollector(async () => {
await secondStore.setSession('no' as any);
}),
).resolves.toMatchObject({
warn: [
expect.stringContaining(
'Failed to save session to local storage because it did not conform to the expected schema',
),
],
});
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ZodSchema } from 'zod';
import {
MutableSessionManager,
SessionScopesFunc,
@@ -27,6 +28,8 @@ type Options<T> = {
manager: MutableSessionManager<T>;
/** Storage key to use to store sessions */
storageKey: string;
/** The schema used to validate the stored data */
schema: ZodSchema<T>;
/** Used to get the scope of the session */
sessionScopes?: SessionScopesFunc<T>;
/** Used to check if the session needs to be refreshed, defaults to never refresh */
@@ -42,6 +45,7 @@ type Options<T> = {
export class AuthSessionStore<T> implements MutableSessionManager<T> {
private readonly manager: MutableSessionManager<T>;
private readonly storageKey: string;
private readonly schema: ZodSchema<T>;
private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc<T>;
private readonly helper: SessionScopeHelper<T>;
@@ -49,12 +53,14 @@ export class AuthSessionStore<T> implements MutableSessionManager<T> {
const {
manager,
storageKey,
schema,
sessionScopes,
sessionShouldRefresh = () => false,
} = options;
this.manager = manager;
this.storageKey = storageKey;
this.schema = schema;
this.sessionShouldRefreshFunc = sessionShouldRefresh;
this.helper = new SessionScopeHelper({
sessionScopes,
@@ -104,7 +110,16 @@ export class AuthSessionStore<T> implements MutableSessionManager<T> {
}
return value;
});
return session;
try {
return this.schema.parse(session);
} catch (e) {
// eslint-disable-next-line no-console
console.log(
`Failed to load session from local storage because it did not conform to the expected schema, ${e}`,
);
throw e;
}
}
return undefined;
@@ -117,19 +132,30 @@ export class AuthSessionStore<T> implements MutableSessionManager<T> {
private saveSession(session: T | undefined) {
if (session === undefined) {
localStorage.removeItem(this.storageKey);
} else {
localStorage.setItem(
this.storageKey,
JSON.stringify(session, (_key, value) => {
if (value instanceof Set) {
return {
__type: 'Set',
__value: Array.from(value),
};
}
return value;
}),
);
return;
}
try {
this.schema.parse(session);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(
`Failed to save session to local storage because it did not conform to the expected schema, ${e}`,
);
return;
}
localStorage.setItem(
this.storageKey,
JSON.stringify(session, (_key, value) => {
if (value instanceof Set) {
return {
__type: 'Set',
__value: Array.from(value),
};
}
return value;
}),
);
}
}
@@ -18,13 +18,13 @@ import { Button } from './Button';
import { useLocation } from 'react-router-dom';
import { createRouteRef, useRouteRef } from '@backstage/core-plugin-api';
import Divider from '@material-ui/core/Divider';
import Link from '@material-ui/core/Link';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import Typography from '@material-ui/core/Typography';
import MaterialButton from '@material-ui/core/Button';
import { wrapInTestApp } from '@backstage/test-utils';
import { Link } from '../Link';
const routeRef = createRouteRef({
id: 'storybook.test-route',
@@ -45,7 +45,7 @@ export default {
<Typography>
A collection of buttons that should be used in the Backstage
interface. These leverage the properties inherited from{' '}
<Link href="https://material-ui.com/components/buttons/">
<Link to="https://material-ui.com/components/buttons/">
Material-UI Button
</Link>
, but include an opinionated set that align to the Backstage design.
@@ -16,7 +16,6 @@
import React from 'react';
import { DismissableBanner } from './DismissableBanner';
import Link from '@material-ui/core/Link';
import Typography from '@material-ui/core/Typography';
import { WebStorage } from '@backstage/core-app-api';
import {
@@ -25,6 +24,7 @@ import {
StorageApi,
} from '@backstage/core-plugin-api';
import { TestApiProvider } from '@backstage/test-utils';
import { Link } from '../Link';
export default {
title: 'Feedback/DismissableBanner',
@@ -83,7 +83,7 @@ export const WithLink = () => (
message={
<Typography>
This is a dismissable banner with a link:{' '}
<Link href="http://example.com" color="textPrimary">
<Link to="http://example.com" color="textPrimary">
example.com
</Link>
</Typography>
@@ -94,6 +94,7 @@ export const WithLink = () => (
</TestApiProvider>
</div>
);
export const Fixed = () => (
<div style={containerStyle}>
<TestApiProvider apis={apis}>
@@ -106,6 +107,7 @@ export const Fixed = () => (
</TestApiProvider>
</div>
);
export const Warning = () => (
<div style={containerStyle}>
<TestApiProvider apis={apis}>
@@ -17,8 +17,8 @@
import React from 'react';
import { WarningPanel } from './WarningPanel';
import Button from '@material-ui/core/Button';
import Link from '@material-ui/core/Link';
import Typography from '@material-ui/core/Typography';
import { Link } from '../Link';
export default {
title: 'Feedback/Warning Panel',
@@ -32,7 +32,7 @@ export const Default = () => (
<>
This example entity is missing an annotation. If this is unexpected,
please make sure you have set up everything correctly by following{' '}
<Link href="http://example.com">this guide</Link>.
<Link to="http://example.com">this guide</Link>.
</>
}
/>
@@ -16,11 +16,11 @@
import { BackstageTheme } from '@backstage/theme';
import Grid from '@material-ui/core/Grid';
import Link from '@material-ui/core/Link';
import { makeStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import React from 'react';
import { useNavigate } from 'react-router';
import { Link } from '../../components/Link';
import { useSupportConfig } from '../../hooks';
import { MicDrop } from './MicDrop';
@@ -86,14 +86,11 @@ export function ErrorPage(props: IErrorPageProps) {
Looks like someone dropped the mic!
</Typography>
<Typography variant="h6">
<Link data-testid="go-back-link" onClick={() => navigate(-1)}>
<Link to="#" data-testid="go-back-link" onClick={() => navigate(-1)}>
Go back
</Link>
... or please{' '}
<Link href={support.url} rel="noopener noreferrer">
contact support
</Link>{' '}
if you think this is a bug.
... or please <Link to={support.url}>contact support</Link> if you
think this is a bug.
</Typography>
</Grid>
</Grid>
@@ -15,10 +15,10 @@
*/
import Grid from '@material-ui/core/Grid';
import Link from '@material-ui/core/Link';
import { makeStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import React from 'react';
import { Link } from '../../components/Link';
/** @public */
export type HeaderLabelClassKey = 'root' | 'label' | 'value';
@@ -80,7 +80,7 @@ export function HeaderLabel(props: HeaderLabelProps) {
<Grid item>
<span className={classes.root}>
<Typography className={classes.label}>{label}</Typography>
{url ? <Link href={url}>{content}</Link> : content}
{url ? <Link to={url}>{content}</Link> : content}
</span>
</Grid>
);
+9 -48
View File
@@ -6,24 +6,21 @@
/// <reference types="react" />
import { BackstagePlugin as BackstagePlugin_2 } from '@backstage/core-plugin-api';
import { BackstageTheme } from '@backstage/theme';
import { ComponentType } from 'react';
import { Config } from '@backstage/config';
import { IconComponent as IconComponent_2 } from '@backstage/core-plugin-api';
import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api';
import { Observable as Observable_2 } from '@backstage/types';
import { Observer as Observer_2 } from '@backstage/types';
import { Observable } from '@backstage/types';
import { ProfileInfo as ProfileInfo_2 } from '@backstage/core-plugin-api';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { Subscription as Subscription_2 } from '@backstage/types';
import { SvgIconProps } from '@material-ui/core';
// @public
export type AlertApi = {
post(alert: AlertMessage): void;
alert$(): Observable_2<AlertMessage>;
alert$(): Observable<AlertMessage>;
};
// @public
@@ -141,21 +138,8 @@ export type ApiRef<T> = {
// @public
export type ApiRefConfig = {
id: string;
description?: string;
};
// @public @deprecated
export type ApiRefsToTypes<
T extends {
[key in string]: ApiRef<unknown>;
},
> = {
[key in keyof T]: ApiRefType<T[key]>;
};
// @public @deprecated
export type ApiRefType<T> = T extends ApiRef<infer U> ? U : never;
// @public
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
@@ -179,15 +163,14 @@ export type AppTheme = {
id: string;
title: string;
variant: 'light' | 'dark';
theme: BackstageTheme;
icon?: React.ReactElement;
Provider?(props: { children: ReactNode }): JSX.Element | null;
Provider(props: { children: ReactNode }): JSX.Element | null;
};
// @public
export type AppThemeApi = {
getInstalledThemes(): AppTheme[];
activeThemeId$(): Observable_2<string | undefined>;
activeThemeId$(): Observable<string | undefined>;
getActiveThemeId(): string | undefined;
setActiveThemeId(themeId?: string): void;
};
@@ -374,11 +357,8 @@ export function createRouteRef<
},
ParamKey extends string = never,
>(config: {
id?: string;
id: string;
params?: ParamKey[];
path?: string;
icon?: OldIconComponent;
title?: string;
}): RouteRef<OptionalParams<Params>>;
// @public
@@ -413,14 +393,10 @@ export interface ElementCollection {
}): ElementCollection;
}
// @public @deprecated (undocumented)
type Error_2 = ErrorApiError;
export { Error_2 as Error };
// @public
export type ErrorApi = {
post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
error$(): Observable_2<{
error$(): Observable<{
error: ErrorApiError;
context?: ErrorApiErrorContext;
}>;
@@ -448,9 +424,6 @@ export type ErrorBoundaryFallbackProps = {
resetError: () => void;
};
// @public @deprecated (undocumented)
export type ErrorContext = ErrorApiErrorContext;
// @public
export type Extension<T> = {
expose(plugin: BackstagePlugin<any, any>): T;
@@ -609,7 +582,7 @@ export type OAuthRequestApi = {
createAuthRequester<OAuthResponse>(
options: OAuthRequesterOptions<OAuthResponse>,
): OAuthRequester<OAuthResponse>;
authRequest$(): Observable_2<PendingOAuthRequest[]>;
authRequest$(): Observable<PendingOAuthRequest[]>;
};
// @public
@@ -631,12 +604,6 @@ export type OAuthRequesterOptions<TOAuthResponse> = {
// @public
export type OAuthScope = string | string[];
// @public @deprecated
export type Observable<T> = Observable_2<T>;
// @public @deprecated
export type Observer<T> = Observer_2<T>;
// @public @deprecated
export const oidcAuthApiRef: ApiRef<
OAuthApi &
@@ -759,9 +726,6 @@ export type RouteFunc<Params extends AnyParams> = (
export type RouteRef<Params extends AnyParams = any> = {
$$routeRefType: 'absolute';
params: ParamKeys<Params>;
path: string;
icon?: OldIconComponent;
title?: string;
};
// @public @deprecated
@@ -773,7 +737,7 @@ export const samlAuthApiRef: ApiRef<
export type SessionApi = {
signIn(): Promise<void>;
signOut(): Promise<void>;
sessionState$(): Observable_2<SessionState>;
sessionState$(): Observable<SessionState>;
};
// @public
@@ -799,7 +763,7 @@ export type SignInResult = {
export interface StorageApi {
forBucket(name: string): StorageApi;
get<T>(key: string): T | undefined;
observe$<T>(key: string): Observable_2<StorageValueChange<T>>;
observe$<T>(key: string): Observable<StorageValueChange<T>>;
remove(key: string): Promise<void>;
set(key: string, data: any): Promise<void>;
}
@@ -821,9 +785,6 @@ export type SubRouteRef<Params extends AnyParams = any> = {
params: ParamKeys<Params>;
};
// @public @deprecated
export type Subscription = Subscription_2;
// @public
export type TypesToApiRefs<T> = {
[key in keyof T]: ApiRef<T[key]>;
@@ -16,7 +16,6 @@
import { ReactNode } from 'react';
import { ApiRef, createApiRef } from '../system';
import { BackstageTheme } from '@backstage/theme';
import { Observable } from '@backstage/types';
/**
@@ -40,18 +39,12 @@ export type AppTheme = {
*/
variant: 'light' | 'dark';
/**
* The specialized MaterialUI theme instance.
* @deprecated use Provider instead, see https://backstage.io/docs/api/deprecations#app-theme
*/
theme: BackstageTheme;
/**
* An Icon for the theme mode setting.
*/
icon?: React.ReactElement;
Provider?(props: { children: ReactNode }): JSX.Element | null;
Provider(props: { children: ReactNode }): JSX.Element | null;
};
/**
@@ -29,12 +29,6 @@ export type ErrorApiError = {
stack?: string;
};
/**
* @public
* @deprecated Use ErrorApiError instead
*/
export type Error = ErrorApiError;
/**
* Provides additional information about an error that was posted to the application.
*
@@ -53,12 +47,6 @@ export type ErrorApiErrorContext = {
hidden?: boolean;
};
/**
* @public
* @deprecated Use ErrorApiErrorContext instead
*/
export type ErrorContext = ErrorApiErrorContext;
/**
* The error API is used to report errors to the app, and display them to the user.
*
@@ -23,10 +23,6 @@ import type { ApiRef } from './types';
*/
export type ApiRefConfig = {
id: string;
/**
* @deprecated Will be removed in the future
*/
description?: string;
};
class ApiRefImpl<T> implements ApiRef<T> {
@@ -46,12 +42,6 @@ class ApiRefImpl<T> implements ApiRef<T> {
return this.config.id;
}
get description() {
// eslint-disable-next-line no-console
console.warn('Deprecated use of ApiRef.description');
return this.config.description;
}
// Utility for getting type of an api, using `typeof apiRef.T`
get T(): T {
throw new Error(`tried to read ApiRef.T of ${this}`);
@@ -31,33 +31,13 @@ export type ApiRef<T> = {
*/
export type AnyApiRef = ApiRef<unknown>;
/**
* Transforms ApiRef type into its inner API type.
*
* @public
* @deprecated unused type.
*/
export type ApiRefType<T> = T extends ApiRef<infer U> ? U : never;
/**
* Wraps a type with API properties into a type holding their respective {@link ApiRef}s.
* Reverse type transform of {@link ApiRefsToTypes}.
*
* @public
*/
export type TypesToApiRefs<T> = { [key in keyof T]: ApiRef<T[key]> };
/**
* Unwraps type with {@link ApiRef} properties into a type holding their respective API types.
* Reverse type transform of {@link TypesToApiRefs}.
*
* @public
* @deprecated unused type.
*/
export type ApiRefsToTypes<T extends { [key in string]: ApiRef<unknown> }> = {
[key in keyof T]: ApiRefType<T[key]>;
};
/**
* Provides lookup of APIs through their {@link ApiRef}s.
*
@@ -1,51 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Observer as CoreObserver,
Subscription as CoreSubscription,
Observable as CoreObservable,
} from '@backstage/types';
/**
* Observer interface for consuming an Observer, see TC39.
*
* @public
* @deprecated Please use the same type from `@backstage/types` instead
*/
export type Observer<T> = CoreObserver<T>;
/**
* Subscription returned when subscribing to an Observable, see TC39.
*
* @public
* @deprecated Please use the same type from `@backstage/types` instead
*/
export type Subscription = CoreSubscription;
/**
* Observable sequence of values and errors, see TC39.
*
* @remarks
*
* {@link https://github.com/tc39/proposal-observable}
* This is used as a common return type for observable values and can be created
* using many different observable implementations, such as zen-observable or RxJS 5.
*
* @public
* @deprecated Please use the same type from `@backstage/types` instead
*/
export type Observable<T> = CoreObservable<T>;
-1
View File
@@ -27,4 +27,3 @@ export * from './extensions';
export * from './icons';
export * from './plugin';
export * from './routing';
export * from './deprecatedTypes';
@@ -21,18 +21,6 @@ import {
ParamKeys,
OptionalParams,
} from './types';
import { OldIconComponent } from '../icons/types';
/**
* @deprecated
* @internal
*/
export type RouteRefConfig<Params extends AnyParams> = {
params?: ParamKeys<Params>;
path?: string;
icon?: OldIconComponent;
title: string;
};
/**
* @internal
@@ -47,48 +35,10 @@ export class RouteRefImpl<Params extends AnyParams>
constructor(
private readonly id: string,
readonly params: ParamKeys<Params>,
private readonly config: {
/** @deprecated */
path?: string;
/** @deprecated */
icon?: OldIconComponent;
/** @deprecated */
title?: string;
},
) {
if (config.path) {
// eslint-disable-next-line no-console
console.warn(
`DEPRECATION WARNING: Passing a path to createRouteRef is deprecated, please remove the path for ${this}.`,
);
}
if (config.icon) {
// eslint-disable-next-line no-console
console.warn(
`DEPRECATION WARNING: Passing an icon to createRouteRef is deprecated, please remove the icon for ${this}.`,
);
}
if (config.title) {
// eslint-disable-next-line no-console
console.warn(
`DEPRECATION WARNING: Passing a title to createRouteRef is deprecated, please remove the title for ${this}.`,
);
}
}
/** @deprecated use `useRouteRef` instead */
get path() {
return this.config.path ?? '';
}
get icon() {
return this.config.icon;
}
) {}
get title() {
return this.config.title ?? this.id;
return this.id;
}
toString() {
@@ -112,23 +62,12 @@ export function createRouteRef<
ParamKey extends string = never,
>(config: {
/** The id of the route ref, used to identify it when printed */
id?: string;
id: string;
/** A list of parameter names that the path that this route ref is bound to must contain */
params?: ParamKey[];
/** @deprecated Route refs no longer decide their own path */
path?: string;
/** @deprecated Route refs no longer decide their own icon */
icon?: OldIconComponent;
/** @deprecated Route refs no longer decide their own title */
title?: string;
}): RouteRef<OptionalParams<Params>> {
const id = config.id || config.title;
if (!id) {
throw new Error('RouteRef must be provided a non-empty id');
}
return new RouteRefImpl(
id,
config.id,
(config.params ?? []) as ParamKeys<OptionalParams<Params>>,
config,
);
}
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { OldIconComponent } from '../icons/types';
import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
/**
@@ -85,14 +84,6 @@ export type RouteRef<Params extends AnyParams = any> = {
$$routeRefType: 'absolute'; // See routeRefType above
params: ParamKeys<Params>;
// TODO(Rugvip): Remove all of these once plugins don't rely on the path
/** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */
path: string;
/** @deprecated icons are no longer accessed via RouteRefs */
icon?: OldIconComponent;
/** @deprecated titles are no longer accessed via RouteRefs */
title?: string;
};
/**
@@ -142,23 +133,6 @@ export type AnyRouteRef =
| SubRouteRef<any>
| ExternalRouteRef<any, any>;
// TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release
/**
* @deprecated
* @internal
*/
export type ConcreteRoute = {};
/**
* @deprecated
* @internal
*/
export type AbsoluteRouteRef = RouteRef<{}>;
/**
* @deprecated
* @internal
*/
export type MutableRouteRef = RouteRef<{}>;
/**
* A duplicate of the react-router RouteObject, but with routeRef added
* @internal
@@ -16,7 +16,6 @@
import { AppThemeApi, appThemeApiRef } from '@backstage/core-plugin-api';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { BackstageTheme } from '@backstage/theme';
import userEvent from '@testing-library/user-event';
import React from 'react';
import ObservableImpl from 'zen-observable';
@@ -41,13 +40,13 @@ describe('SidebarThemeSwitcher', () => {
id: 'dark',
title: 'Dark Theme',
variant: 'dark',
theme: {} as unknown as BackstageTheme,
Provider: jest.fn(),
},
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: {} as unknown as BackstageTheme,
Provider: jest.fn(),
},
]);
});
+1 -1
View File
@@ -159,7 +159,7 @@ export class DevAppBuilder {
* Build a DevApp component using the resources registered so far
*/
build(): ComponentType<{}> {
const dummyRouteRef = createRouteRef({ title: 'Page of another plugin' });
const dummyRouteRef = createRouteRef({ id: 'dummy' });
const DummyPage = () => <Box p={3}>Page belonging to another plugin.</Box>;
attachComponentData(DummyPage, 'core.mountPoint', dummyRouteRef);
@@ -45,5 +45,4 @@ export class ScmIntegrationsApi {
export const scmIntegrationsApiRef: ApiRef<ScmIntegrationRegistry> =
createApiRef({
id: 'integration.scmintegrations',
description: 'All of the registered SCM integrations of your config',
});
-55
View File
@@ -34,52 +34,6 @@ export type ErrorWithContext = {
context?: ErrorApiErrorContext;
};
// @public @deprecated (undocumented)
export class Keyboard {
constructor(
target: any,
{
debug,
}?: {
debug?: boolean | undefined;
},
);
// (undocumented)
click(): Promise<void>;
// (undocumented)
debug: boolean;
// (undocumented)
document: any;
// (undocumented)
enter(value: any): Promise<void>;
// (undocumented)
escape(): Promise<void>;
// (undocumented)
get focused(): any;
// (undocumented)
static fromReadableInput(input: any): any;
// (undocumented)
_log(message: any, ...args: any[]): void;
// (undocumented)
_pretty(element: any): string;
// (undocumented)
send(chars: any): Promise<void>;
// (undocumented)
_sendKey(key: any, charCode: any, action: any): Promise<void>;
// (undocumented)
tab(): Promise<void>;
// (undocumented)
static toReadableInput(chars: any): any;
// (undocumented)
toString(): string;
// (undocumented)
static type(target: any, input: any): Promise<void>;
// (undocumented)
type(input: any): Promise<void>;
// (undocumented)
static typeDebug(target: any, input: any): Promise<void>;
}
// @public
export type LogCollector = AsyncLogCollector | SyncLogCollector;
@@ -139,15 +93,6 @@ export type MockStorageBucket = {
[key: string]: any;
};
// @public @deprecated (undocumented)
export const msw: {
setupDefaultHandlers: (worker: {
listen: (t: any) => void;
close: () => void;
resetHandlers: () => void;
}) => void;
};
// @public
export function renderInTestApp(
Component: ComponentType | ReactNode,
@@ -1,225 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, fireEvent } from '@testing-library/react';
const codes = {
Tab: 9,
Enter: 10,
Click: 17 /* This keyboard can click, deal with it */,
Esc: 27,
};
/**
* @public
* @deprecated superseded by {@link @testing-library/user-event#userEvent}
*/
export class Keyboard {
static async type(target, input) {
await new Keyboard(target).type(input);
}
static async typeDebug(target, input) {
await new Keyboard(target, { debug: true }).type(input);
}
static toReadableInput(chars) {
return chars.split('').map(char => {
switch (char.charCodeAt(0)) {
case codes.Tab:
return '<Tab>';
case codes.Enter:
return '<Enter>';
case codes.Click:
return '<Click>';
case codes.Esc:
return '<Esc>';
default:
return char;
}
});
}
static fromReadableInput(input) {
return input.trim().replace(/\s*<([a-zA-Z]+)>\s*/g, (match, name) => {
if (name in codes) {
return String.fromCharCode(codes[name]);
}
throw new Error(`Unknown char name: '${name}'`);
});
}
constructor(target, { debug = false } = {}) {
this.debug = debug;
if (target.ownerDocument) {
this.document = target.ownerDocument;
} else if (target.baseElement) {
this.document = target.baseElement.ownerDocument;
} else {
throw new TypeError(
'Keyboard(target): target must be DOM node or react-testing-library render() output',
);
}
}
toString() {
return `Keyboard{document=${this.document}, debug=${this.debug}}`;
}
_log(message, ...args) {
if (this.debug) {
// eslint-disable-next-line no-console
console.log(`[Keyboard] ${message}`, ...args);
}
}
_pretty(element) {
const attrs = [...element.attributes]
.map(attr => `${attr.name}="${attr.value}"`)
.join(' ');
return `<${element.nodeName.toLocaleLowerCase('en-US')} ${attrs}>`;
}
get focused() {
return this.document.activeElement;
}
async type(input) {
this._log(
`sending sequence '${input}' with initial focus ${this._pretty(
this.focused,
)}`,
);
await this.send(Keyboard.fromReadableInput(input));
}
async send(chars) {
for (const key of chars.split('')) {
const charCode = key.charCodeAt(0);
if (charCode === codes.Tab) {
await this.tab();
continue;
}
const focused = this.focused;
if (!focused || focused === this.document.body) {
throw Error(
`No element focused in document while trying to type '${Keyboard.toReadableInput(
chars,
)}'`,
);
}
const nextValue = (focused.value || '') + key;
if (charCode >= 32) {
await this._sendKey(key, charCode, () => {
this._log(
`sending +${key} = '${nextValue}' to ${this._pretty(focused)}`,
);
fireEvent.change(focused, {
target: { value: nextValue },
bubbles: true,
cancelable: true,
});
});
} else if (charCode === codes.Enter) {
await this.enter(focused.value || '');
} else if (charCode === codes.Esc) {
await this.escape();
} else if (charCode === codes.Click) {
await this.click();
} else {
throw new Error(`Unsupported char code, ${charCode}`);
}
}
}
async click() {
this._log(`clicking ${this._pretty(this.focused)}`);
await act(async () => fireEvent.click(this.focused));
}
async tab() {
await this._sendKey('Tab', codes.Tab, () => {
const focusable = this.document.querySelectorAll(
[
'a[href]',
'area[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'*[tabindex]',
'*[contenteditable]',
].join(','),
);
const tabbable = [...focusable].filter(el => {
return el.tabIndex >= 0;
});
const focused = this.document.activeElement;
const focusedIndex = tabbable.indexOf(focused);
const nextFocus = tabbable[focusedIndex + (1 % tabbable.length)];
this._log(
`tabbing to ${this._pretty(nextFocus)} ${this.focused.textContent}`,
);
nextFocus.focus();
});
}
async enter(value) {
this._log(`submitting '${value}' via ${this._pretty(this.focused)}`);
await act(() =>
this._sendKey('Enter', codes.Enter, () => {
if (this.focused.type === 'button') {
fireEvent.click(this.focused, { target: { value } });
} else {
fireEvent.submit(this.focused, {
target: { value },
bubbles: true,
cancelable: true,
});
}
}),
);
}
async escape() {
this._log(`escape from ${this._pretty(this.focused)}`);
await act(async () => this._sendKey('Escape', codes.Esc));
}
async _sendKey(key, charCode, action) {
const event = { key, charCode, keyCode: charCode, which: charCode };
const focused = this.focused;
if (fireEvent.keyDown(focused, event)) {
if (fireEvent.keyPress(focused, event)) {
if (action) {
action();
}
}
}
fireEvent.keyUp(focused, event);
}
}
@@ -1,108 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Keyboard } from './Keyboard';
import { render } from '@testing-library/react';
describe('testUtils.Keyboard', () => {
it('types into some inputs with focus and submits a form', async () => {
const typed1 = [];
const typed2 = [];
const typed3 = [];
let submitted = false;
const handleSubmit = event => {
event.preventDefault();
submitted = true;
};
const rendered = render(
<form onSubmit={handleSubmit}>
<input onChange={({ target: { value } }) => typed1.push(value)} />
<input
onChange={({ target: { value } }) => typed2.push(value)}
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus
/>
<input onChange={({ target: { value } }) => typed3.push(value)} />
</form>,
);
const keyboard = new Keyboard(rendered);
await keyboard.send('xy');
await keyboard.tab();
await keyboard.send('abc');
await keyboard.enter();
expect(typed1).toEqual([]);
expect(typed2).toEqual(['x', 'xy']);
expect(typed3).toEqual(['a', 'ab', 'abc']);
expect(submitted).toBe(true);
});
it('can use Keyboard.type to send readable input', async () => {
const typed1 = [];
const typed2 = [];
const typed3 = [];
let submitted = false;
const handleSubmit = event => {
event.preventDefault();
submitted = true;
};
const rendered = render(
<form onSubmit={handleSubmit}>
<input
defaultValue="1"
onChange={({ target: { value } }) => typed1.push(value)}
/>
<input
defaultValue="2"
onChange={({ target: { value } }) => typed2.push(value)}
/>
<input
defaultValue="3"
onChange={({ target: { value } }) => typed3.push(value)}
/>
</form>,
);
await Keyboard.type(rendered, '<Tab> a <Tab> b <Tab> c <Enter>');
expect(typed1).toEqual(['1a']);
expect(typed2).toEqual(['2b']);
expect(typed3).toEqual(['3c']);
expect(submitted).toBe(true);
});
it('should be able to navigate a radio input with click', async () => {
const selections = [];
const rendered = render(
<div onChange={({ target: { value } }) => selections.push(value)}>
<input type="radio" name="group" value="a" />
<input type="radio" name="group" value="b" />
<input type="radio" name="group" value="c" />
</div>,
);
await Keyboard.type(rendered, '<Tab> <Click> <Tab> <Tab> <Click>');
expect(selections).toEqual(['a', 'c']);
});
});
@@ -19,7 +19,6 @@ export { default as mockBreakpoint } from './mockBreakpoint';
export { wrapInTestApp, renderInTestApp } from './appWrappers';
export type { TestAppOptions } from './appWrappers';
export * from './msw';
export * from './Keyboard';
export * from './logCollector';
export * from './testingLibrary';
export { TestApiProvider, TestApiRegistry } from './TestApiProvider';
@@ -73,7 +73,6 @@ export function withLogCollector<T extends LogFuncs>(
/**
* Log collector that collect logs either from a sync or async collector.
* @public
* @deprecated import from test-utils instead
* */
export function withLogCollector(
logsToCollect: LogFuncs[] | LogCollector,
@@ -14,20 +14,6 @@
* limitations under the License.
*/
/**
* @deprecated use {@link setupRequestMockHandlers} instead which can be called directly with the worker.
* @public
*/
export const msw = {
setupDefaultHandlers: (worker: {
listen: (t: any) => void;
close: () => void;
resetHandlers: () => void;
}) => {
setupRequestMockHandlers(worker);
},
};
/**
* Sets up handlers for request mocking
* @public
@@ -19,7 +19,6 @@ import { Dag, InstanceStatus, InstanceVersion } from './types';
export const apacheAirflowApiRef = createApiRef<ApacheAirflowApi>({
id: 'plugin.apacheairflow.service',
description: 'Used by the Apache Airflow plugin to make requests',
});
export type ApacheAirflowApi = {
-1
View File
@@ -20,7 +20,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
export const apiDocsConfigRef = createApiRef<ApiDocsConfig>({
id: 'plugin.api-docs.config',
description: 'Used to configure api-docs widgets',
});
export interface ApiDocsConfig {
@@ -17,7 +17,7 @@
import express from 'express';
import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter';
import { encodeState } from './helpers';
import { OAuthHandlers } from './types';
import { OAuthHandlers, OAuthResponse } from './types';
const mockResponseData = {
providerInfo: {
@@ -36,6 +36,12 @@ const mockResponseData = {
},
};
function mkTokenBody(payload: unknown): string {
return Buffer.from(JSON.stringify(payload), 'utf8')
.toString('base64')
.replace(/=/g, '');
}
describe('OAuthAdapter', () => {
class MyAuthProvider implements OAuthHandlers {
async start() {
@@ -249,4 +255,86 @@ describe('OAuthAdapter', () => {
'Refresh token is not supported for provider test-provider',
);
});
it('correctly populates incomplete identities', async () => {
const mockRefresh = jest.fn<Promise<OAuthResponse>, [express.Request]>();
const oauthProvider = new OAuthAdapter(
{
refresh: mockRefresh,
start: jest.fn(),
handler: jest.fn(),
} as OAuthHandlers,
{
...oAuthProviderOptions,
tokenIssuer: {
issueToken: async ({ claims }) => `a.${mkTokenBody(claims)}.a`,
listPublicKeys: async () => ({ keys: [] }),
},
disableRefresh: false,
isOriginAllowed: () => false,
},
);
const mockRequest = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown as express.Request;
const mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown as express.Response;
// Without a token
mockRefresh.mockResolvedValueOnce({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: '',
},
});
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledTimes(1);
expect(mockResponse.json).toHaveBeenLastCalledWith({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`,
idToken: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`,
identity: {
type: 'user',
userEntityRef: 'user:default/foo',
ownershipEntityRefs: [],
},
},
});
// With a token
mockRefresh.mockResolvedValueOnce({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
},
});
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledTimes(2);
expect(mockResponse.json).toHaveBeenLastCalledWith({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
idToken: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
identity: {
type: 'user',
userEntityRef: 'user:my-ns/foo',
ownershipEntityRefs: [],
},
},
});
});
});
@@ -17,6 +17,11 @@
import express from 'express';
import crypto from 'crypto';
import { URL } from 'url';
import {
ENTITY_DEFAULT_NAMESPACE,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
AuthProviderRouteHandlers,
AuthProviderConfig,
@@ -243,8 +248,14 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
return prepareBackstageIdentityResponse(identity);
}
const userEntityRef = stringifyEntityRef(
parseEntityRef(identity.id, {
defaultKind: 'user',
defaultNamespace: ENTITY_DEFAULT_NAMESPACE,
}),
);
const token = await this.options.tokenIssuer.issueToken({
claims: { sub: identity.id },
claims: { sub: userEntityRef },
});
return prepareBackstageIdentityResponse({ ...identity, token });
@@ -27,8 +27,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
export const azureDevOpsApiRef = createApiRef<AzureDevOpsApi>({
id: 'plugin.azure-devops.service',
description:
'Used by the Azure DevOps plugin to make requests to accompanying backend',
});
export interface AzureDevOpsApi {
-1
View File
@@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
export const badgesApiRef = createApiRef<BadgesApi>({
id: 'plugin.badges.client',
description: 'Used to make requests to the badges backend',
});
export type BadgeStyle =
@@ -0,0 +1,171 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
exports.up = async function up(knex) {
if (knex.client.config.client === 'sqlite3') {
await knex.schema.dropTable('metadata');
await knex.schema.createTable('metadata', table => {
table.increments('id').comment('Automatically generated unique ID');
table.text('entity_ref').unique().comment('The ref of the entity');
table.text('name').notNullable().comment('The name of the entity');
table
.text('community')
.comment('Link to where the community can discuss ideas');
table
.text('description')
.notNullable()
.comment('The description of the Bazaar project');
table
.text('status')
.defaultTo('proposed')
.notNullable()
.comment('The status of the Bazaar project');
table
.text('updated_at')
.notNullable()
.comment('Timestamp on ISO 8601 format when entity was last updated');
table
.text('size')
.defaultTo('medium')
.notNullable()
.comment('The estimated magnitude of the project');
table
.text('start_date')
.comment('Optional start date of the project (ISO 8601 format)');
table
.text('end_date')
.comment('Optional end date of the project (ISO 8601 format)');
table
.text('responsible')
.notNullable()
.comment('Contact person of the project');
});
await knex.schema.dropTable('members');
await knex.schema.createTable('members', table => {
table
.integer('item_id')
.references('metadata.id')
.onDelete('CASCADE')
.comment('Id of the associated item');
table
.text('entity_ref')
.references('metadata.entity_ref')
.onDelete('CASCADE')
.comment('The ref of the entity');
table.text('user_id').notNullable().comment('The user id of the member');
table
.dateTime('join_date')
.defaultTo(knex.fn.now())
.notNullable()
.comment('The timestamp when this member joined');
table.text('picture').comment('Link to profile picture');
});
} else {
await knex.schema.alterTable('metadata', table => {
table.renameColumn('announcement', 'description');
table.increments('id').comment('Automatically generated unique ID');
table.string('entity_ref').nullable().alter();
});
await knex.schema.alterTable('members', table => {
table
.integer('item_id')
.references('metadata.id')
.onDelete('CASCADE')
.comment('Id of the associated item');
table.dropColumn('entity_ref');
});
}
};
exports.down = async function down(knex) {
if (knex.client.config.client === 'sqlite3') {
await knex.schema.dropTable('metadata');
await knex.schema.createTable('metadata', table => {
table
.text('entity_ref')
.notNullable()
.unique()
.comment('The ref of the entity');
table.text('name').notNullable().comment('The name of the entity');
table
.text('community')
.comment('Link to where the community can discuss ideas');
table
.text('announcement')
.notNullable()
.comment('The announcement of the Bazaar project');
table
.text('status')
.defaultTo('proposed')
.notNullable()
.comment('The status of the Bazaar project');
table
.text('updated_at')
.notNullable()
.comment('Timestamp on ISO 8601 format when entity was last updated');
table
.text('size')
.defaultTo('medium')
.notNullable()
.comment('The estimated magnitude of the project');
table
.text('start_date')
.comment('Optional start date of the project (ISO 8601 format)');
table
.text('end_date')
.comment('Optional end date of the project (ISO 8601 format)');
table
.text('responsible')
.notNullable()
.comment('Contact person of the project');
});
await knex.schema.dropTable('members');
await knex.schema.createTable('members', table => {
table
.text('entity_ref')
.notNullable()
.references('metadata.entity_ref')
.onDelete('CASCADE')
.comment('The ref of the entity');
table.text('user_id').notNullable().comment('The user id of the member');
table
.dateTime('join_date')
.defaultTo(knex.fn.now())
.notNullable()
.comment('The timestamp when this member joined');
table.text('picture').comment('Link to profile picture');
});
} else {
await knex.schema.alterTable('metadata', table => {
table.renameColumn('description', 'announcement');
table.string('entity_ref').notNullable().alter();
table.dropColumn('id');
});
await knex.schema.alterTable('members', table => {
table.dropColumn('item_id');
table
.text('entity_ref')
.notNullable()
.references('metadata.entity_ref')
.onDelete('CASCADE')
.comment('The ref of the entity');
});
}
};
@@ -22,13 +22,14 @@ const bazaarProject: any = {
entityRef: 'ref1',
community: '',
status: 'proposed',
announcement: 'a',
description: 'a',
membersCount: 0,
startDate: '2021-11-07T13:27:00.000Z',
endDate: null,
size: 'small',
responsible: 'r',
};
describe('DatabaseHandler', () => {
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
@@ -50,7 +51,7 @@ describe('DatabaseHandler', () => {
await knex('metadata').insert({
entity_ref: bazaarProject.entityRef,
name: bazaarProject.name,
announcement: bazaarProject.announcement,
description: bazaarProject.description,
community: bazaarProject.community,
status: bazaarProject.status,
updated_at: new Date().toISOString(),
@@ -60,10 +61,10 @@ describe('DatabaseHandler', () => {
responsible: bazaarProject.responsible,
});
const res = await dbHandler.getMetadata('ref1');
const res = await dbHandler.getMetadataByRef('ref1');
expect(res).toHaveLength(1);
expect(res[0].announcement).toEqual('a');
expect(res[0].description).toEqual('a');
expect(res[0].community).toEqual('');
expect(res[0].status).toEqual('proposed');
expect(res[0].start_date).toEqual('2021-11-07T13:27:00.000Z');

Some files were not shown because too many files have changed in this diff Show More