Merge branch 'master' into mobile-sidebar

Signed-off-by: Philipp Hugenroth <philipph@spotify.com>
This commit is contained in:
Philipp Hugenroth
2021-12-09 14:57:04 +01:00
362 changed files with 7499 additions and 2600 deletions
-7
View File
@@ -1,7 +0,0 @@
---
'@backstage/core-components': patch
'@backstage/create-app': patch
'@backstage/plugin-catalog': patch
---
Addressed some peer dependency warnings
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-scaffolder': patch
---
Can specify allowedOwners to the RepoUrlPicker picker in a template definition
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Bump esbuild to ^0.14.1
-14
View File
@@ -1,14 +0,0 @@
---
'@backstage/plugin-kubernetes': minor
'@backstage/plugin-kubernetes-backend': minor
'@backstage/plugin-kubernetes-common': minor
---
Add pod metrics lookup and display in pod table.
## Backwards incompatible changes
If your Kubernetes distribution does not have the [metrics server](https://github.com/kubernetes-sigs/metrics-server) installed,
you will need to set the `skipMetricsLookup` config flag to `false`.
See the [configuration docs](https://backstage.io/docs/features/kubernetes/configuration) for more details.
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-catalog-backend': patch
---
Reject catalog entities that have duplicate fields that vary only in casing.
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-scaffolder': patch
---
Fix bug with setting owner in RepoUrlPicker causing validation failure
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/core-components': patch
---
Use ellipsis style for overflowed text in sidebar menu
-10
View File
@@ -1,10 +0,0 @@
---
'@backstage/cli': patch
---
Add cli option to minify the generated code of a plugin or backend package
```
backstage-cli plugin:build --minify
backstage-cli backend:build --minify
```
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-api-docs': patch
---
Update AsyncAPI component to 1.0.0-x releases
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-search': patch
---
Export SearchApi interface from plugin
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-analytics-module-ga': patch
---
Support self hosted analytics.js script via `scriptSrc` config option
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/backend-common': patch
---
Add knexConfig config section
@@ -1,7 +0,0 @@
---
'@backstage/techdocs-common': minor
'@backstage/plugin-techdocs-backend': minor
---
Added the ability for the TechDocs Backend to (optionally) leverage a cache
store to improve performance when reading files from a cloud storage provider.
-42
View File
@@ -1,42 +0,0 @@
---
'@backstage/create-app': patch
---
TechDocs Backend may now (optionally) leverage a cache store to improve
performance when reading content from a cloud storage provider.
To apply this change to an existing app, pass the cache manager from the plugin
environment to the `createRouter` function in your backend:
```diff
// packages/backend/src/plugins/techdocs.ts
export default async function createPlugin({
logger,
config,
discovery,
reader,
+ cache,
}: PluginEnvironment): Promise<Router> {
// ...
return await createRouter({
preparers,
generators,
publisher,
logger,
config,
discovery,
+ cache,
});
```
If your `PluginEnvironment` does not include a cache manager, be sure you've
applied [the cache management change][cm-change] to your backend as well.
[Additional configuration][td-rec-arch] is required if you wish to enable
caching in TechDocs.
[cm-change]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md#patch-changes-6
[td-rec-arch]: https://backstage.io/docs/features/techdocs/architecture#recommended-deployment
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/create-app': patch
---
Bump @spotify/prettier-config
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-xcmetrics': patch
---
Handle a case where XCode data from backend (before 0.0.8) could be missing
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/plugin-bazaar-backend': patch
---
Handle migration error when old data is present in the database
-5
View File
@@ -1,5 +0,0 @@
---
'@backstage/create-app': patch
---
Removed the `scaffolder.github.visibility` configuration that is no longer used from the default app template.
+1 -1
View File
@@ -5,7 +5,7 @@
| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
| [SDA SE](https://sda.se) | [@dschwank](https://github.com/dschwank), [@iammnils](https://github.com/iammnils) | Central place for developing and sharing services in our insurance ecosystem. |
| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
@@ -11,13 +11,13 @@ would be good to also have some files in there that can be templated in.
A simple `template.yaml` definition might look something like this:
```yaml
apiVersion: backstage.io/v1beta2
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
# some metadata about the template itself
metadata:
name: v1beta2-demo
name: v1beta3-demo
title: Test Action template
description: scaffolder v1beta2 template demo
description: scaffolder v1beta3 template demo
spec:
owner: backstage/techdocs-core
type: service
@@ -55,7 +55,7 @@ spec:
input:
url: ./template
values:
name: '{{ parameters.name }}'
name: ${{ parameters.name }}
- id: fetch-docs
name: Fetch Docs
@@ -69,14 +69,14 @@ spec:
action: publish:github
input:
allowedHosts: ['github.com']
description: 'This is {{ parameters.name }}'
repoUrl: '{{ parameters.repoUrl }}'
description: This is ${{ parameters.name }}
repoUrl: ${{ parameters.repoUrl }}
- id: register
name: Register
action: catalog:register
input:
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: '/catalog-info.yaml'
```
@@ -6,21 +6,21 @@ description: Details around creating your own custom Software Templates
Templates are stored in the **Software Catalog** under a kind `Template`. You
can create your own templates with a small `yaml` definition which describes the
template and it's metadata, along with some input variables that your template
template and its metadata, along with some input variables that your template
will need, and then a list of actions which are then executed by the scaffolding
service.
Let's take a look at a simple example:
```yaml
# Notice the v1beta2 version
apiVersion: backstage.io/v1beta2
# Notice the v1beta3 version
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
# some metadata about the template itself
metadata:
name: v1beta2-demo
name: v1beta3-demo
title: Test Action template
description: scaffolder v1beta2 template demo
description: scaffolder v1beta3 template demo
spec:
owner: backstage/techdocs-core
type: service
@@ -66,8 +66,8 @@ spec:
input:
url: ./template
values:
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
name: ${{ parameters.name }}
owner: ${{ parameters.owner }}
- id: fetch-docs
name: Fetch Docs
@@ -81,20 +81,20 @@ spec:
action: publish:github
input:
allowedHosts: ['github.com']
description: 'This is {{ parameters.name }}'
repoUrl: '{{ parameters.repoUrl }}'
description: This is ${{ parameters.name }}
repoUrl: ${{ parameters.repoUrl }}
- id: register
name: Register
action: catalog:register
input:
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: '/catalog-info.yaml'
# some outputs which are saved along with the job for use in the frontend
output:
remoteUrl: '{{ steps.publish.output.remoteUrl }}'
entityRef: '{{ steps.register.output.entityRef }}'
remoteUrl: ${{ steps.publish.output.remoteUrl }}
entityRef: ${{ steps.register.output.entityRef }}
```
Let's dive in and pick apart what each of these sections do and what they are.
@@ -183,12 +183,12 @@ this:
It would look something like the following in a template:
```yaml
apiVersion: backstage.io/v1beta2
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: v1beta2-demo
name: v1beta3-demo
title: Test Action template
description: scaffolder v1beta2 template demo
description: scaffolder v1beta3 template demo
spec:
owner: backstage/techdocs-core
type: service
@@ -315,12 +315,12 @@ template. These follow the same standard format:
```yaml
- id: fetch-base # A unique id for the step
name: Fetch Base # A title displayed in the frontend
if: '{{ parameters.name }}' # Optional condition, skip the step if not truthy
if: ${{ parameters.name }} # Optional condition, skip the step if not truthy
action: fetch:template # An action to call
input: # Input that is passed as arguments to the action handler
url: ./template
values:
name: '{{ parameters.name }}'
name: ${{ parameters.name }}
```
By default we ship some [built in actions](./builtin-actions.md) that you can
@@ -338,22 +338,19 @@ The main two that are used are the following:
```yaml
output:
remoteUrl: '{{ steps.publish.output.remoteUrl }}' # link to the remote repository
entityRef: '{{ steps.register.output.entityRef }}' # link to the entity that has been ingested to the catalog
remoteUrl: ${{ steps.publish.output.remoteUrl }} # link to the remote repository
entityRef: ${{ steps.register.output.entityRef }} # link to the entity that has been ingested to the catalog
```
### The templating syntax
You might have noticed variables wrapped in `{{ }}` in the examples. These are
`handlebars` template strings for linking and gluing the different parts of the
template together. All the form inputs from the `parameters` section will be
available by using this template syntax (for example,
`{{ parameters.firstName }}` inserts the value of `firstName` from the
parameters). This is great for passing the values from the form into different
steps and reusing these input variables. To pass arrays or objects use the
`json` custom [helper](https://handlebarsjs.com/guide/expressions.html#helpers).
For example, `{{ json parameters.nicknames }}` will insert the result of calling
`JSON.stringify` on the value of the `nicknames` parameter.
You might have noticed variables wrapped in `${{ }}` in the examples. These are
template strings for linking and gluing the different parts of the template
together. All the form inputs from the `parameters` section will be available by
using this template syntax (for example, `${{ parameters.firstName }}` inserts
the value of `firstName` from the parameters). This is great for passing the
values from the form into different steps and reusing these input variables.
These template strings preserve the type of the parameter.
As you can see above in the `Outputs` section, `actions` and `steps` can also
output things. You can grab that output using `steps.$stepId.output.$property`.
+10
View File
@@ -1,5 +1,15 @@
# @backstage/app-defaults
## 0.1.2
### Patch Changes
- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used.
- Updated dependencies
- @backstage/core-components@0.8.0
- @backstage/core-plugin-api@0.3.0
- @backstage/core-app-api@0.2.0
## 0.1.1
### Patch Changes
+10 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/app-defaults",
"description": "Provides the default wiring of a Backstage App",
"version": "0.1.1",
"version": "0.1.2",
"private": false,
"publishConfig": {
"access": "public",
@@ -29,23 +29,25 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core-components": "^0.7.6",
"@backstage/core-app-api": "^0.1.24",
"@backstage/core-plugin-api": "^0.2.2",
"@backstage/core-components": "^0.8.0",
"@backstage/core-app-api": "^0.2.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"react": "^16.12.0",
"react-router-dom": "6.0.0-beta.0"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.0",
"@backstage/test-utils": "^0.1.22",
"@backstage/cli": "^0.10.1",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"@types/react": "*"
"@types/react": "^16.13.1 || ^17.0.0"
},
"files": [
"dist"
+45
View File
@@ -1,5 +1,50 @@
# example-app
## 0.2.56
### Patch Changes
- Updated dependencies
- @backstage/core-components@0.8.0
- @backstage/core-plugin-api@0.3.0
- @backstage/plugin-cloudbuild@0.2.29
- @backstage/plugin-catalog@0.7.4
- @backstage/core-app-api@0.2.0
- @backstage/plugin-scaffolder@0.11.14
- @backstage/plugin-kubernetes@0.5.0
- @backstage/app-defaults@0.1.2
- @backstage/integration-react@0.1.15
- @backstage/plugin-api-docs@0.6.18
- @backstage/plugin-azure-devops@0.1.6
- @backstage/plugin-badges@0.2.16
- @backstage/plugin-catalog-graph@0.2.3
- @backstage/plugin-catalog-import@0.7.5
- @backstage/plugin-catalog-react@0.6.5
- @backstage/plugin-circleci@0.2.31
- @backstage/plugin-code-coverage@0.1.19
- @backstage/plugin-cost-insights@0.11.13
- @backstage/plugin-explore@0.3.22
- @backstage/plugin-gcp-projects@0.3.10
- @backstage/plugin-github-actions@0.4.26
- @backstage/plugin-graphiql@0.2.24
- @backstage/plugin-home@0.4.7
- @backstage/plugin-jenkins@0.5.14
- @backstage/plugin-kafka@0.2.22
- @backstage/plugin-lighthouse@0.2.31
- @backstage/plugin-newrelic@0.3.10
- @backstage/plugin-org@0.3.30
- @backstage/plugin-pagerduty@0.3.19
- @backstage/plugin-rollbar@0.3.20
- @backstage/plugin-search@0.5.1
- @backstage/plugin-sentry@0.3.30
- @backstage/plugin-shortcuts@0.1.15
- @backstage/plugin-tech-insights@0.1.1
- @backstage/plugin-tech-radar@0.4.13
- @backstage/plugin-techdocs@0.12.9
- @backstage/plugin-todo@0.1.16
- @backstage/plugin-user-settings@0.3.13
- @backstage/cli@0.10.1
## 0.2.55
### Patch Changes
+43 -44
View File
@@ -1,63 +1,62 @@
{
"name": "example-app",
"version": "0.2.55",
"version": "0.2.56",
"private": true,
"bundled": true,
"dependencies": {
"@backstage/app-defaults": "^0.1.1",
"@backstage/app-defaults": "^0.1.2",
"@backstage/catalog-model": "^0.9.7",
"@backstage/cli": "^0.10.0",
"@backstage/core-app-api": "^0.1.24",
"@backstage/core-components": "^0.7.6",
"@backstage/core-plugin-api": "^0.2.2",
"@backstage/integration-react": "^0.1.14",
"@backstage/plugin-api-docs": "^0.6.16",
"@backstage/plugin-azure-devops": "^0.1.5",
"@backstage/plugin-badges": "^0.2.14",
"@backstage/plugin-catalog": "^0.7.3",
"@backstage/plugin-catalog-graph": "^0.2.2",
"@backstage/plugin-catalog-import": "^0.7.4",
"@backstage/plugin-catalog-react": "^0.6.4",
"@backstage/plugin-circleci": "^0.2.30",
"@backstage/plugin-cloudbuild": "^0.2.28",
"@backstage/plugin-code-coverage": "^0.1.18",
"@backstage/plugin-cost-insights": "^0.11.11",
"@backstage/plugin-explore": "^0.3.21",
"@backstage/plugin-gcp-projects": "^0.3.9",
"@backstage/plugin-github-actions": "^0.4.24",
"@backstage/plugin-graphiql": "^0.2.21",
"@backstage/plugin-home": "^0.4.6",
"@backstage/plugin-jenkins": "^0.5.12",
"@backstage/plugin-kafka": "^0.2.21",
"@backstage/plugin-kubernetes": "^0.4.22",
"@backstage/plugin-lighthouse": "^0.2.30",
"@backstage/plugin-newrelic": "^0.3.9",
"@backstage/plugin-org": "^0.3.28",
"@backstage/plugin-pagerduty": "0.3.18",
"@backstage/plugin-rollbar": "^0.3.19",
"@backstage/plugin-scaffolder": "^0.11.13",
"@backstage/plugin-search": "^0.5.0",
"@backstage/plugin-sentry": "^0.3.29",
"@backstage/plugin-shortcuts": "^0.1.14",
"@backstage/plugin-tech-radar": "^0.4.12",
"@backstage/plugin-techdocs": "^0.12.8",
"@backstage/plugin-todo": "^0.1.15",
"@backstage/plugin-user-settings": "^0.3.12",
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/integration-react": "^0.1.15",
"@backstage/plugin-api-docs": "^0.6.18",
"@backstage/plugin-azure-devops": "^0.1.6",
"@backstage/plugin-badges": "^0.2.16",
"@backstage/plugin-catalog": "^0.7.4",
"@backstage/plugin-catalog-graph": "^0.2.3",
"@backstage/plugin-catalog-import": "^0.7.5",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/plugin-circleci": "^0.2.31",
"@backstage/plugin-cloudbuild": "^0.2.29",
"@backstage/plugin-code-coverage": "^0.1.19",
"@backstage/plugin-cost-insights": "^0.11.13",
"@backstage/plugin-explore": "^0.3.22",
"@backstage/plugin-gcp-projects": "^0.3.10",
"@backstage/plugin-github-actions": "^0.4.26",
"@backstage/plugin-graphiql": "^0.2.24",
"@backstage/plugin-home": "^0.4.7",
"@backstage/plugin-jenkins": "^0.5.14",
"@backstage/plugin-kafka": "^0.2.22",
"@backstage/plugin-kubernetes": "^0.5.0",
"@backstage/plugin-lighthouse": "^0.2.31",
"@backstage/plugin-newrelic": "^0.3.10",
"@backstage/plugin-org": "^0.3.30",
"@backstage/plugin-pagerduty": "0.3.19",
"@backstage/plugin-rollbar": "^0.3.20",
"@backstage/plugin-scaffolder": "^0.11.14",
"@backstage/plugin-search": "^0.5.1",
"@backstage/plugin-sentry": "^0.3.30",
"@backstage/plugin-shortcuts": "^0.1.15",
"@backstage/plugin-tech-radar": "^0.4.13",
"@backstage/plugin-techdocs": "^0.12.9",
"@backstage/plugin-todo": "^0.1.16",
"@backstage/plugin-user-settings": "^0.3.13",
"@backstage/search-common": "^0.2.0",
"@backstage/plugin-tech-insights": "^0.1.0",
"@backstage/plugin-tech-insights": "^0.1.1",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@octokit/rest": "^18.5.3",
"@roadiehq/backstage-plugin-buildkite": "^1.0.8",
"@roadiehq/backstage-plugin-github-insights": "^1.1.23",
"@roadiehq/backstage-plugin-github-pull-requests": "^1.0.13",
"@roadiehq/backstage-plugin-travis-ci": "^1.0.11",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hot-loader": "^4.12.21",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
@@ -65,7 +64,7 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/test-utils": "^0.1.22",
"@backstage/test-utils": "^0.1.24",
"@rjsf/core": "^3.2.1",
"@testing-library/cypress": "^8.0.2",
"@testing-library/jest-dom": "^5.10.1",
@@ -106,10 +106,7 @@ import { EntityTechdocsContent } from '@backstage/plugin-techdocs';
import { EntityTodoContent } from '@backstage/plugin-todo';
import { Button, Grid } from '@material-ui/core';
import BadgeIcon from '@material-ui/icons/CallToAction';
import {
EntityBuildkiteContent,
isBuildkiteAvailable,
} from '@roadiehq/backstage-plugin-buildkite';
import {
EntityGithubInsightsContent,
EntityGithubInsightsLanguagesCard,
@@ -168,10 +165,6 @@ export const cicdContent = (
<EntityJenkinsContent />
</EntitySwitch.Case>
<EntitySwitch.Case if={isBuildkiteAvailable}>
<EntityBuildkiteContent />
</EntitySwitch.Case>
<EntitySwitch.Case if={isCircleCIAvailable}>
<EntityCircleCIContent />
</EntitySwitch.Case>
+9
View File
@@ -1,5 +1,14 @@
# @backstage/backend-common
## 0.9.13
### Patch Changes
- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly
- 5a008576c4: Add possibility to use custom error handler
- 98a9c35f0c: Add options argument to support additional database migrations configuration
- 6298de32dd: Add knexConfig config section
## 0.9.12
### Patch Changes
+37 -71
View File
@@ -175,23 +175,22 @@ export function createStatusCheckRouter(options: {
// @public (undocumented)
export class DatabaseManager {
forPlugin(pluginId: string): PluginDatabaseManager;
static fromConfig(config: Config): DatabaseManager;
static fromConfig(
config: Config,
options?: DatabaseManagerOptions,
): DatabaseManager;
}
// @public
export type DatabaseManagerOptions = {
migrations?: PluginDatabaseManager['migrations'];
};
// @public (undocumented)
export class DockerContainerRunner implements ContainerRunner {
constructor({ dockerClient }: { dockerClient: Docker });
constructor(options: { dockerClient: Docker });
// (undocumented)
runContainer({
imageName,
command,
args,
logStream,
mountDirs,
workingDir,
envVars,
pullImage,
}: RunContainerOptions): Promise<void>;
runContainer(options: RunContainerOptions): Promise<void>;
}
// @public
@@ -227,34 +226,17 @@ export function getVoidLogger(): winston.Logger;
// @public (undocumented)
export class Git {
// (undocumented)
add({ dir, filepath }: { dir: string; filepath: string }): Promise<void>;
add(options: { dir: string; filepath: string }): Promise<void>;
// (undocumented)
addRemote({
dir,
url,
remote,
}: {
addRemote(options: {
dir: string;
remote: string;
url: string;
}): Promise<void>;
// (undocumented)
clone({
url,
dir,
ref,
}: {
url: string;
dir: string;
ref?: string;
}): Promise<void>;
clone(options: { url: string; dir: string; ref?: string }): Promise<void>;
// (undocumented)
commit({
dir,
message,
author,
committer,
}: {
commit(options: {
dir: string;
message: string;
author: {
@@ -267,41 +249,22 @@ export class Git {
};
}): Promise<string>;
// (undocumented)
currentBranch({
dir,
fullName,
}: {
currentBranch(options: {
dir: string;
fullName?: boolean;
}): Promise<string | undefined>;
// (undocumented)
fetch({ dir, remote }: { dir: string; remote?: string }): Promise<void>;
fetch(options: { dir: string; remote?: string }): Promise<void>;
// (undocumented)
static fromAuth: ({
username,
password,
logger,
}: {
username?: string | undefined;
password?: string | undefined;
logger?: Logger_2 | undefined;
static fromAuth: (options: {
username?: string;
password?: string;
logger?: Logger_2;
}) => Git;
// (undocumented)
init({
dir,
defaultBranch,
}: {
dir: string;
defaultBranch?: string;
}): Promise<void>;
init(options: { dir: string; defaultBranch?: string }): Promise<void>;
// (undocumented)
merge({
dir,
theirs,
ours,
author,
committer,
}: {
merge(options: {
dir: string;
theirs: string;
ours?: string;
@@ -315,17 +278,11 @@ export class Git {
};
}): Promise<MergeResult>;
// (undocumented)
push({ dir, remote }: { dir: string; remote: string }): Promise<PushResult>;
push(options: { dir: string; remote: string }): Promise<PushResult>;
// (undocumented)
readCommit({
dir,
sha,
}: {
dir: string;
sha: string;
}): Promise<ReadCommitResult>;
readCommit(options: { dir: string; sha: string }): Promise<ReadCommitResult>;
// (undocumented)
resolveRef({ dir, ref }: { dir: string; ref: string }): Promise<string>;
resolveRef(options: { dir: string; ref: string }): Promise<string>;
}
// @public
@@ -395,6 +352,9 @@ export type PluginCacheManager = {
// @public
export interface PluginDatabaseManager {
getClient(): Promise<Knex>;
migrations?: {
skip?: boolean;
};
}
// @public
@@ -561,6 +521,8 @@ export type ServiceBuilder = {
setRequestLoggingHandler(
requestLoggingHandler: RequestLoggingHandlerFactory,
): ServiceBuilder;
setErrorHandler(errorHandler: ErrorRequestHandler): ServiceBuilder;
disableDefaultErrorHandler(): ServiceBuilder;
start(): Promise<Server>;
};
@@ -623,8 +585,8 @@ export type UrlReaderPredicateTuple = {
// @public
export class UrlReaders {
static create({ logger, config, factories }: UrlReadersOptions): UrlReader;
static default({ logger, config, factories }: UrlReadersOptions): UrlReader;
static create(options: UrlReadersOptions): UrlReader;
static default(options: UrlReadersOptions): UrlReader;
}
// @public (undocumented)
@@ -642,4 +604,8 @@ export function useHotCleanup(
// @public
export function useHotMemoize<T>(_module: NodeModule, valueFactory: () => T): T;
// Warnings were encountered during analysis:
//
// src/database/types.d.ts:23:12 - (tsdoc-undefined-tag) The TSDoc tag "@default" is not defined in this configuration
```
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
"version": "0.9.12",
"version": "0.9.13",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
@@ -81,8 +81,8 @@
}
},
"devDependencies": {
"@backstage/cli": "^0.10.0",
"@backstage/test-utils": "^0.1.23",
"@backstage/cli": "^0.10.1",
"@backstage/test-utils": "^0.1.24",
"@types/archiver": "^5.1.0",
"@types/compression": "^1.7.0",
"@types/concat-stream": "^1.6.0",
@@ -36,25 +36,45 @@ describe('DatabaseManager', () => {
afterEach(() => jest.resetAllMocks());
describe('DatabaseManager.fromConfig', () => {
it('accesses the backend.database key', () => {
const config = new ConfigReader({
backend: {
database: {
client: 'pg',
connection: {
host: 'localhost',
user: 'foo',
password: 'bar',
database: 'foodb',
},
const backendConfig = {
backend: {
database: {
client: 'pg',
connection: {
host: 'localhost',
user: 'foo',
password: 'bar',
database: 'foodb',
},
},
});
},
};
it('accesses the backend.database key', () => {
const config = new ConfigReader(backendConfig);
const getConfigSpy = jest.spyOn(config, 'getConfig');
DatabaseManager.fromConfig(config);
expect(getConfigSpy).toHaveBeenCalledWith('backend.database');
});
it('handles default options', () => {
const config = new ConfigReader(backendConfig);
const database = DatabaseManager.fromConfig(config);
const client = database.forPlugin('test');
expect(client.migrations?.skip).toBe(false);
});
it('handles migrations options', () => {
const config = new ConfigReader(backendConfig);
const database = DatabaseManager.fromConfig(config, {
migrations: { skip: true },
});
const client = database.forPlugin('test');
expect(client.migrations?.skip).toBe(true);
});
});
describe('DatabaseManager.forPlugin', () => {
@@ -36,6 +36,15 @@ function pluginPath(pluginId: string): string {
return `plugin.${pluginId}`;
}
/**
* Configuration options object.
*
* @public
*/
export type DatabaseManagerOptions = {
migrations?: PluginDatabaseManager['migrations'];
};
/** @public */
export class DatabaseManager {
/**
@@ -47,19 +56,25 @@ export class DatabaseManager {
* names if config is not provided.
*
* @param config - The loaded application configuration.
* @param options - An optional configuration object.
*/
static fromConfig(config: Config): DatabaseManager {
static fromConfig(
config: Config,
options?: DatabaseManagerOptions,
): DatabaseManager {
const databaseConfig = config.getConfig('backend.database');
return new DatabaseManager(
databaseConfig,
databaseConfig.getOptionalString('prefix'),
options,
);
}
private constructor(
private readonly config: Config,
private readonly prefix: string = 'backstage_plugin_',
private readonly options?: DatabaseManagerOptions,
) {}
/**
@@ -76,6 +91,10 @@ export class DatabaseManager {
getClient(): Promise<Knex> {
return _this.getDatabase(pluginId);
},
migrations: {
skip: false,
..._this.options?.migrations,
},
};
}
@@ -30,6 +30,18 @@ export interface PluginDatabaseManager {
* stores so that plugins are discouraged from database integration.
*/
getClient(): Promise<Knex>;
/**
* This property is used to control the behavior of database migrations.
*/
migrations?: {
/**
* skip database migrations. Useful if connecting to a read-only database.
*
* @default false
*/
skip?: boolean;
};
}
/**
@@ -46,7 +46,8 @@ export class UrlReaders {
/**
* Creates a UrlReader without any known types.
*/
static create({ logger, config, factories }: UrlReadersOptions): UrlReader {
static create(options: UrlReadersOptions): UrlReader {
const { logger, config, factories } = options;
const mux = new UrlReaderPredicateMux(logger);
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config,
@@ -68,7 +69,8 @@ export class UrlReaders {
*
* Any additional factories passed will be loaded before the default ones.
*/
static default({ logger, config, factories = [] }: UrlReadersOptions) {
static default(options: UrlReadersOptions) {
const { logger, config, factories = [] } = options;
return UrlReaders.create({
logger,
config,
+32 -74
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import git, {
ProgressCallback,
MergeResult,
@@ -42,44 +43,32 @@ export class Git {
},
) {}
async add({
dir,
filepath,
}: {
dir: string;
filepath: string;
}): Promise<void> {
async add(options: { dir: string; filepath: string }): Promise<void> {
const { dir, filepath } = options;
this.config.logger?.info(`Adding file {dir=${dir},filepath=${filepath}}`);
return git.add({ fs, dir, filepath });
}
async addRemote({
dir,
url,
remote,
}: {
async addRemote(options: {
dir: string;
remote: string;
url: string;
}): Promise<void> {
const { dir, url, remote } = options;
this.config.logger?.info(
`Creating new remote {dir=${dir},remote=${remote},url=${url}}`,
);
return git.addRemote({ fs, dir, remote, url });
}
async commit({
dir,
message,
author,
committer,
}: {
async commit(options: {
dir: string;
message: string;
author: { name: string; email: string };
committer: { name: string; email: string };
}): Promise<string> {
const { dir, message, author, committer } = options;
this.config.logger?.info(
`Committing file to repo {dir=${dir},message=${message}}`,
);
@@ -87,15 +76,12 @@ export class Git {
return git.commit({ fs, dir, message, author, committer });
}
async clone({
url,
dir,
ref,
}: {
async clone(options: {
url: string;
dir: string;
ref?: string;
}): Promise<void> {
const { url, dir, ref } = options;
this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`);
return git.clone({
fs,
@@ -114,51 +100,35 @@ export class Git {
}
// https://isomorphic-git.org/docs/en/currentBranch
async currentBranch({
dir,
fullName,
}: {
async currentBranch(options: {
dir: string;
fullName?: boolean;
}): Promise<string | undefined> {
const fullname = fullName ?? false;
return git.currentBranch({ fs, dir, fullname }) as Promise<
const { dir, fullName = false } = options;
return git.currentBranch({ fs, dir, fullname: fullName }) as Promise<
string | undefined
>;
}
// https://isomorphic-git.org/docs/en/fetch
async fetch({
dir,
remote,
}: {
dir: string;
remote?: string;
}): Promise<void> {
const remoteValue = remote ?? 'origin';
async fetch(options: { dir: string; remote?: string }): Promise<void> {
const { dir, remote = 'origin' } = options;
this.config.logger?.info(
`Fetching remote=${remoteValue} for repository {dir=${dir}}`,
`Fetching remote=${remote} for repository {dir=${dir}}`,
);
await git.fetch({
fs,
http,
dir,
remote: remoteValue,
remote,
onProgress: this.onProgressHandler(),
headers: {
'user-agent': 'git/@isomorphic-git',
},
headers: { 'user-agent': 'git/@isomorphic-git' },
onAuth: this.onAuth,
});
}
async init({
dir,
defaultBranch = 'master',
}: {
dir: string;
defaultBranch?: string;
}): Promise<void> {
async init(options: { dir: string; defaultBranch?: string }): Promise<void> {
const { dir, defaultBranch = 'master' } = options;
this.config.logger?.info(`Init git repository {dir=${dir}}`);
return git.init({
@@ -169,19 +139,14 @@ export class Git {
}
// https://isomorphic-git.org/docs/en/merge
async merge({
dir,
theirs,
ours,
author,
committer,
}: {
async merge(options: {
dir: string;
theirs: string;
ours?: string;
author: { name: string; email: string };
committer: { name: string; email: string };
}): Promise<MergeResult> {
const { dir, theirs, ours, author, committer } = options;
this.config.logger?.info(
`Merging branch '${theirs}' into '${ours}' for repository {dir=${dir}}`,
);
@@ -197,7 +162,8 @@ export class Git {
});
}
async push({ dir, remote }: { dir: string; remote: string }) {
async push(options: { dir: string; remote: string }) {
const { dir, remote } = options;
this.config.logger?.info(
`Pushing directory to remote {dir=${dir},remote=${remote}}`,
);
@@ -215,24 +181,17 @@ export class Git {
}
// https://isomorphic-git.org/docs/en/readCommit
async readCommit({
dir,
sha,
}: {
async readCommit(options: {
dir: string;
sha: string;
}): Promise<ReadCommitResult> {
const { dir, sha } = options;
return git.readCommit({ fs, dir, oid: sha });
}
// https://isomorphic-git.org/docs/en/resolveRef
async resolveRef({
dir,
ref,
}: {
dir: string;
ref: string;
}): Promise<string> {
async resolveRef(options: { dir: string; ref: string }): Promise<string> {
const { dir, ref } = options;
return git.resolveRef({ fs, dir, ref });
}
@@ -256,13 +215,12 @@ export class Git {
};
};
static fromAuth = ({
username,
password,
logger,
}: {
static fromAuth = (options: {
username?: string;
password?: string;
logger?: Logger;
}) => new Git({ username, password, logger });
}) => {
const { username, password, logger } = options;
return new Git({ username, password, logger });
};
}
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { applyCspDirectives } from './ServiceBuilderImpl';
import { NextFunction, Request, Response } from 'express';
import { applyCspDirectives, ServiceBuilderImpl } from './ServiceBuilderImpl';
describe('ServiceBuilderImpl', () => {
describe('applyCspDirectives', () => {
@@ -33,4 +34,27 @@ describe('ServiceBuilderImpl', () => {
expect(result!['upgrade-insecure-requests']).toBeUndefined();
});
});
describe('setCustomErrorHandler', () => {
it('check if custom error handler is undefined', () => {
const serviceBuilder = new ServiceBuilderImpl(module);
const serviceBuilderProto = Object.getPrototypeOf(serviceBuilder);
expect(serviceBuilderProto.errorHandler).toBeUndefined();
});
it('adds custom error handler', () => {
const serviceBuilder = new ServiceBuilderImpl(module);
const serviceBuilderProto = Object.getPrototypeOf(serviceBuilder);
const customErrorHandler = (
error: Error,
_req: Request,
_res: Response,
next: NextFunction,
) => {
next(error);
};
serviceBuilderProto.setErrorHandler(customErrorHandler);
expect(serviceBuilderProto.errorHandler).toEqual(customErrorHandler);
});
});
});
@@ -17,7 +17,7 @@
import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express, { Router } from 'express';
import express, { Router, ErrorRequestHandler } from 'express';
import helmet from 'helmet';
import * as http from 'http';
import stoppable from 'stoppable';
@@ -25,7 +25,7 @@ import { Logger } from 'winston';
import { useHotCleanup } from '../../hot';
import { getRootLogger } from '../../logging';
import {
errorHandler,
errorHandler as defaultErrorHandler,
notFoundHandler,
requestLoggingHandler as defaultRequestLoggingHandler,
} from '../../middleware';
@@ -66,6 +66,8 @@ export class ServiceBuilderImpl implements ServiceBuilder {
private httpsSettings: HttpsSettings | undefined;
private routers: [string, Router][];
private requestLoggingHandler: RequestLoggingHandlerFactory | undefined;
private errorHandler: ErrorRequestHandler | undefined;
private useDefaultErrorHandler: boolean;
// Reference to the module where builder is created - needed for hot module
// reloading
private module: NodeModule;
@@ -73,6 +75,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
constructor(moduleRef: NodeModule) {
this.routers = [];
this.module = moduleRef;
this.useDefaultErrorHandler = true;
}
loadConfig(config: Config): ServiceBuilder {
@@ -152,6 +155,16 @@ export class ServiceBuilderImpl implements ServiceBuilder {
return this;
}
setErrorHandler(errorHandler: ErrorRequestHandler) {
this.errorHandler = errorHandler;
return this;
}
disableDefaultErrorHandler() {
this.useDefaultErrorHandler = false;
return this;
}
async start(): Promise<http.Server> {
const app = express();
const { port, host, logger, corsOptions, httpsSettings, helmetOptions } =
@@ -169,7 +182,14 @@ export class ServiceBuilderImpl implements ServiceBuilder {
app.use(root, route);
}
app.use(notFoundHandler());
app.use(errorHandler());
if (this.errorHandler) {
app.use(this.errorHandler);
}
if (this.useDefaultErrorHandler) {
app.use(defaultErrorHandler());
}
const server: http.Server = httpsSettings
? await createHttpsServer(app, httpsSettings, logger)
+16 -1
View File
@@ -16,7 +16,7 @@
import { Config } from '@backstage/config';
import cors from 'cors';
import { Router, RequestHandler } from 'express';
import { Router, RequestHandler, ErrorRequestHandler } from 'express';
import { Server } from 'http';
import { Logger } from 'winston';
@@ -98,6 +98,21 @@ export type ServiceBuilder = {
requestLoggingHandler: RequestLoggingHandlerFactory,
): ServiceBuilder;
/**
* Sets an additional errorHandler to run before the defaultErrorHandler.
*
* For execution of only the custom error handler make sure to also invoke disableDefaultErrorHandler()
* otherwise the defaultErrorHandler is executed at the end of the error middleware chain.
*
* @param errorHandler - an error handler
*/
setErrorHandler(errorHandler: ErrorRequestHandler): ServiceBuilder;
/**
* Disables the default error handler
*/
disableDefaultErrorHandler(): ServiceBuilder;
/**
* Starts the server using the given settings.
*/
@@ -28,20 +28,22 @@ export type UserOptions = {
export class DockerContainerRunner implements ContainerRunner {
private readonly dockerClient: Docker;
constructor({ dockerClient }: { dockerClient: Docker }) {
this.dockerClient = dockerClient;
constructor(options: { dockerClient: Docker }) {
this.dockerClient = options.dockerClient;
}
async runContainer({
imageName,
command,
args,
logStream = new PassThrough(),
mountDirs = {},
workingDir,
envVars = {},
pullImage = true,
}: RunContainerOptions) {
async runContainer(options: RunContainerOptions) {
const {
imageName,
command,
args,
logStream = new PassThrough(),
mountDirs = {},
workingDir,
envVars = {},
pullImage = true,
} = options;
// Show a better error message when Docker is unavailable.
try {
await this.dockerClient.ping();
+19
View File
@@ -1,5 +1,24 @@
# example-backend
## 0.2.56
### Patch Changes
- Updated dependencies
- @backstage/plugin-auth-backend@0.5.0
- @backstage/plugin-scaffolder-backend@0.15.16
- @backstage/plugin-kubernetes-backend@0.4.0
- @backstage/backend-common@0.9.13
- @backstage/plugin-catalog-backend@0.19.1
- @backstage/plugin-search-backend@0.2.8
- @backstage/plugin-search-backend-module-elasticsearch@0.0.6
- @backstage/plugin-search-backend-module-pg@0.2.2
- @backstage/plugin-techdocs-backend@0.12.0
- @backstage/plugin-todo-backend@0.1.15
- @backstage/plugin-scaffolder-backend-module-rails@0.2.0
- @backstage/plugin-azure-devops-backend@0.2.4
- example-app@0.2.56
## 0.2.55
### Patch Changes
+15 -15
View File
@@ -1,6 +1,6 @@
{
"name": "example-backend",
"version": "0.2.55",
"version": "0.2.56",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -24,39 +24,39 @@
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
"@backstage/backend-common": "^0.9.12",
"@backstage/backend-common": "^0.9.13",
"@backstage/catalog-client": "^0.5.2",
"@backstage/catalog-model": "^0.9.7",
"@backstage/config": "^0.1.10",
"@backstage/integration": "^0.6.10",
"@backstage/plugin-app-backend": "^0.3.19",
"@backstage/plugin-auth-backend": "^0.4.10",
"@backstage/plugin-azure-devops-backend": "^0.2.3",
"@backstage/plugin-auth-backend": "^0.5.0",
"@backstage/plugin-azure-devops-backend": "^0.2.4",
"@backstage/plugin-badges-backend": "^0.1.13",
"@backstage/plugin-catalog-backend": "^0.19.0",
"@backstage/plugin-catalog-backend": "^0.19.1",
"@backstage/plugin-code-coverage-backend": "^0.1.16",
"@backstage/plugin-graphql-backend": "^0.1.9",
"@backstage/plugin-jenkins-backend": "^0.1.9",
"@backstage/plugin-kubernetes-backend": "^0.3.20",
"@backstage/plugin-kubernetes-backend": "^0.4.0",
"@backstage/plugin-kafka-backend": "^0.2.12",
"@backstage/plugin-proxy-backend": "^0.2.14",
"@backstage/plugin-rollbar-backend": "^0.1.16",
"@backstage/plugin-scaffolder-backend": "^0.15.15",
"@backstage/plugin-scaffolder-backend-module-rails": "^0.1.7",
"@backstage/plugin-search-backend": "^0.2.7",
"@backstage/plugin-scaffolder-backend": "^0.15.16",
"@backstage/plugin-scaffolder-backend-module-rails": "^0.2.0",
"@backstage/plugin-search-backend": "^0.2.8",
"@backstage/plugin-search-backend-node": "^0.4.2",
"@backstage/plugin-search-backend-module-elasticsearch": "^0.0.5",
"@backstage/plugin-search-backend-module-pg": "^0.2.1",
"@backstage/plugin-techdocs-backend": "^0.11.0",
"@backstage/plugin-search-backend-module-elasticsearch": "^0.0.6",
"@backstage/plugin-search-backend-module-pg": "^0.2.2",
"@backstage/plugin-techdocs-backend": "^0.12.0",
"@backstage/plugin-tech-insights-backend": "^0.1.3",
"@backstage/plugin-tech-insights-node": "^0.1.1",
"@backstage/plugin-tech-insights-backend-module-jsonfc": "^0.1.2",
"@backstage/plugin-todo-backend": "^0.1.14",
"@backstage/plugin-todo-backend": "^0.1.15",
"@gitbeaker/node": "^34.6.0",
"@octokit/rest": "^18.5.3",
"azure-devops-node-api": "^11.0.1",
"dockerode": "^3.3.1",
"example-app": "^0.2.55",
"example-app": "^0.2.56",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"express-prom-bundle": "^6.3.6",
@@ -68,7 +68,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.10.0",
"@backstage/cli": "^0.10.1",
"@types/dockerode": "^3.3.0",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5"
+13
View File
@@ -1,5 +1,18 @@
# @backstage/cli
## 0.10.1
### Patch Changes
- 0ebb05eee2: Add cli option to minify the generated code of a plugin or backend package
```
backstage-cli plugin:build --minify
backstage-cli backend:build --minify
```
- cd450844f6: Updated the frontend plugin template to put React dependencies in `peerDependencies` by default, as well as allowing both React v16 and v17. This change can be applied to existing plugins by running `yarn backstage-cli plugin:diff` within the plugin package directory.
## 0.10.0
### Minor Changes
+7 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
"version": "0.10.0",
"version": "0.10.1",
"private": false,
"publishConfig": {
"access": "public"
@@ -89,7 +89,6 @@
"ora": "^5.3.0",
"postcss": "^8.1.0",
"process": "^0.11.10",
"react": "^16.0.0",
"react-dev-utils": "^12.0.0-next.47",
"react-hot-loader": "^4.12.21",
"recursive-readdir": "^2.2.2",
@@ -117,13 +116,13 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/backend-common": "^0.9.12",
"@backstage/backend-common": "^0.9.13",
"@backstage/config": "^0.1.11",
"@backstage/core-components": "^0.7.6",
"@backstage/core-plugin-api": "^0.2.2",
"@backstage/core-app-api": "^0.1.24",
"@backstage/dev-utils": "^0.2.13",
"@backstage/test-utils": "^0.1.23",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-app-api": "^0.2.0",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@backstage/theme": "^0.2.14",
"@types/diff": "^5.0.0",
"@types/express": "^4.17.6",
+21 -3
View File
@@ -75,7 +75,9 @@ class PackageJsonHandler {
await this.syncScripts();
await this.syncPublishConfig();
await this.syncDependencies('dependencies');
await this.syncDependencies('peerDependencies', true);
await this.syncDependencies('devDependencies');
await this.syncReactDeps();
}
// Make sure a field inside package.json is in sync. This mutates the targetObj and writes package.json on change.
@@ -207,12 +209,12 @@ class PackageJsonHandler {
}
}
private async syncDependencies(fieldName: string) {
private async syncDependencies(fieldName: string, required: boolean = false) {
const pkgDeps = this.pkg[fieldName];
const targetDeps = (this.targetPkg[fieldName] =
this.targetPkg[fieldName] || {});
if (!pkgDeps) {
if (!pkgDeps && !required) {
return;
}
@@ -231,10 +233,26 @@ class PackageJsonHandler {
continue;
}
await this.syncField(key, pkgDeps, targetDeps, fieldName, true, true);
await this.syncField(
key,
pkgDeps,
targetDeps,
fieldName,
true,
!required,
);
}
}
private async syncReactDeps() {
const targetDeps = (this.targetPkg.dependencies =
this.targetPkg.dependencies || {});
// Remove these from from deps since they're now in peerDeps
await this.syncField('react', {}, targetDeps, 'dependencies');
await this.syncField('react-dom', {}, targetDeps, 'dependencies');
}
private async write() {
await this.writeFunc(`${JSON.stringify(this.targetPkg, null, 2)}\n`);
}
+2 -2
View File
@@ -66,7 +66,7 @@ export const version = findVersion();
export const isDev = fs.pathExistsSync(paths.resolveOwn('src'));
export function createPackageVersionProvider(lockfile?: Lockfile) {
return (name: string, versionHint?: string) => {
return (name: string, versionHint?: string): string => {
const packageVersion = packageVersions[name];
const targetVersion = versionHint || packageVersion;
if (!targetVersion) {
@@ -94,6 +94,6 @@ export function createPackageVersionProvider(lockfile?: Lockfile) {
if (semver.parse(versionHint)?.prerelease.length) {
return versionHint!;
}
return `^${versionHint}`;
return versionHint?.match(/^[\d\.]+$/) ? `^${versionHint}` : versionHint!;
};
}
@@ -32,10 +32,11 @@
"@material-ui/core": "{{versionQuery '@material-ui/core' '4.12.2'}}",
"@material-ui/icons": "{{versionQuery '@material-ui/icons' '4.9.1'}}",
"@material-ui/lab": "{{versionQuery '@material-ui/lab' '4.0.0-alpha.57'}}",
"react": "{{versionQuery 'react' '16.13.1'}}",
"react-dom": "{{versionQuery 'react-dom' '16.13.1'}}",
"react-use": "{{versionQuery 'react-use' '17.2.4'}}"
},
"peerDependencies": {
"react": "{{versionQuery 'react' '^16.13.1 || ^17.0.0'}}"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}",
"@backstage/core-app-api": "{{versionQuery '@backstage/core-app-api'}}",
+9
View File
@@ -1,5 +1,14 @@
# @backstage/codemods
## 0.1.25
### Patch Changes
- Updated dependencies
- @backstage/core-components@0.8.0
- @backstage/core-plugin-api@0.3.0
- @backstage/core-app-api@0.2.0
## 0.1.24
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/codemods",
"description": "A collection of codemods for Backstage projects",
"version": "0.1.24",
"version": "0.1.25",
"private": false,
"publishConfig": {
"access": "public",
+46
View File
@@ -1,5 +1,51 @@
# @backstage/core-app-api
## 0.2.0
### Minor Changes
- a036b65c2f: **BREAKING CHANGE**
The app `SignInPage` component has been updated to switch out the `onResult` callback for a new `onSignInSuccess` callback. This is an immediate breaking change without any deprecation period, as it was deemed to be the way of making this change that had the lowest impact.
The new `onSignInSuccess` callback directly accepts an implementation of an `IdentityApi`, rather than a `SignInResult`. The `SignInPage` from `@backstage/core-component` has been updated to fit this new API, and as long as you pass on `props` directly you should not see any breakage.
However, if you implement your own custom `SignInPage`, then this will be a breaking change and you need to migrate over to using the new callback. While doing so you can take advantage of the `UserIdentity.fromLegacy` helper from `@backstage/core-components` to make the migration simpler by still using the `SignInResult` type. This helper is also deprecated though and is only provided for immediate migration. Long-term it will be necessary to build the `IdentityApi` using for example `UserIdentity.create` instead.
The following is an example of how you can migrate existing usage immediately using `UserIdentity.fromLegacy`:
```ts
onResult(signInResult);
// becomes
onSignInSuccess(UserIdentity.fromLegacy(signInResult));
```
The following is an example of how implement the new `onSignInSuccess` callback of the `SignInPage` using `UserIdentity.create`:
```ts
const identityResponse = await authApi.getBackstageIdentity();
// Profile is optional and will be removed, but allows the
// synchronous getProfile method of the IdentityApi to be used.
const profile = await authApi.getProfile();
onSignInSuccess(
UserIdentity.create({
identity: identityResponse.identity,
authApi,
profile,
}),
);
```
### Patch Changes
- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used.
- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly
- Updated dependencies
- @backstage/core-components@0.8.0
- @backstage/core-plugin-api@0.3.0
- @backstage/app-defaults@0.1.2
- @backstage/version-bridge@0.1.1
## 0.1.24
### Patch Changes
+16 -76
View File
@@ -39,6 +39,7 @@ import { FeatureFlagsSaveOptions } from '@backstage/core-plugin-api';
import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
import { googleAuthApiRef } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { microsoftAuthApiRef } from '@backstage/core-plugin-api';
import { OAuthApi } from '@backstage/core-plugin-api';
import { OAuthRequestApi } from '@backstage/core-plugin-api';
@@ -250,24 +251,13 @@ export class AppThemeSelector implements AppThemeApi {
// @public
export class AtlassianAuth {
// (undocumented)
static create({
discoveryApi,
environment,
provider,
oauthRequestApi,
}: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T;
static create(options: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T;
}
// @public
export class Auth0Auth {
// (undocumented)
static create({
discoveryApi,
environment,
provider,
oauthRequestApi,
defaultScopes,
}: OAuthApiCreateOptions): typeof auth0AuthApiRef.T;
static create(options: OAuthApiCreateOptions): typeof auth0AuthApiRef.T;
}
// @public
@@ -303,13 +293,7 @@ export type BackstagePluginWithAnyOutput = Omit<
// @public
export class BitbucketAuth {
// (undocumented)
static create({
discoveryApi,
environment,
provider,
oauthRequestApi,
defaultScopes,
}: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T;
static create(options: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T;
}
// @public
@@ -402,13 +386,7 @@ export class GithubAuth implements OAuthApi, SessionApi {
// @deprecated
constructor(sessionManager: SessionManager<GithubSession>);
// (undocumented)
static create({
discoveryApi,
environment,
provider,
oauthRequestApi,
defaultScopes,
}: OAuthApiCreateOptions): GithubAuth;
static create(options: OAuthApiCreateOptions): GithubAuth;
// (undocumented)
getAccessToken(scope?: string, options?: AuthRequestOptions): Promise<string>;
// (undocumented)
@@ -441,25 +419,13 @@ export type GithubSession = {
// @public
export class GitlabAuth {
// (undocumented)
static create({
discoveryApi,
environment,
provider,
oauthRequestApi,
defaultScopes,
}: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T;
static create(options: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T;
}
// @public
export class GoogleAuth {
// (undocumented)
static create({
discoveryApi,
oauthRequestApi,
environment,
provider,
defaultScopes,
}: OAuthApiCreateOptions): typeof googleAuthApiRef.T;
static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T;
}
// @public
@@ -477,13 +443,7 @@ export class LocalStorageFeatureFlags implements FeatureFlagsApi {
// @public
export class MicrosoftAuth {
// (undocumented)
static create({
environment,
provider,
oauthRequestApi,
discoveryApi,
defaultScopes,
}: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T;
static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T;
}
// @public
@@ -507,14 +467,7 @@ export class OAuth2
scopeTransform: (scopes: string[]) => string[];
});
// (undocumented)
static create({
discoveryApi,
environment,
provider,
oauthRequestApi,
defaultScopes,
scopeTransform,
}: OAuth2CreateOptions): OAuth2;
static create(options: OAuth2CreateOptions): OAuth2;
// (undocumented)
getAccessToken(
scope?: string | string[],
@@ -570,24 +523,15 @@ export class OAuthRequestManager implements OAuthRequestApi {
// @public
export class OktaAuth {
// (undocumented)
static create({
discoveryApi,
environment,
provider,
oauthRequestApi,
defaultScopes,
}: OAuthApiCreateOptions): typeof oktaAuthApiRef.T;
static create(options: OAuthApiCreateOptions): typeof oktaAuthApiRef.T;
}
// @public
export class OneLoginAuth {
// (undocumented)
static create({
discoveryApi,
environment,
provider,
oauthRequestApi,
}: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T;
static create(
options: OneLoginAuthCreateOptions,
): typeof oneloginAuthApiRef.T;
}
// @public
@@ -607,11 +551,7 @@ export class SamlAuth
// @deprecated
constructor(sessionManager: SessionManager<SamlSession>);
// (undocumented)
static create({
discoveryApi,
environment,
provider,
}: AuthApiCreateOptions): SamlAuth;
static create(options: AuthApiCreateOptions): SamlAuth;
// (undocumented)
getBackstageIdentity(
options?: AuthRequestOptions,
@@ -635,10 +575,10 @@ export type SamlSession = {
// @public
export type SignInPageProps = {
onResult(result: SignInResult): void;
onSignInSuccess(identityApi: IdentityApi): void;
};
// @public
// @public @deprecated
export type SignInResult = {
userId: string;
profile: ProfileInfo;
+11 -9
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core-app-api",
"description": "Core app API used by Backstage apps",
"version": "0.1.24",
"version": "0.2.0",
"private": false,
"publishConfig": {
"access": "public",
@@ -29,26 +29,28 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/app-defaults": "^0.1.1",
"@backstage/core-components": "^0.7.6",
"@backstage/app-defaults": "^0.1.2",
"@backstage/core-components": "^0.8.0",
"@backstage/config": "^0.1.11",
"@backstage/core-plugin-api": "^0.2.2",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/theme": "^0.2.14",
"@backstage/types": "^0.1.1",
"@backstage/version-bridge": "^0.1.0",
"@backstage/version-bridge": "^0.1.1",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@types/react": "*",
"@types/prop-types": "^15.7.3",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"zen-observable": "^0.8.15"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.0",
"@backstage/test-utils": "^0.1.23",
"@backstage/cli": "^0.10.1",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^7.0.2",
@@ -0,0 +1,90 @@
/*
* 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 {
IdentityApi,
ProfileInfo,
BackstageUserIdentity,
} from '@backstage/core-plugin-api';
function mkError(thing: string) {
return new Error(
`Tried to access IdentityApi ${thing} before app was loaded`,
);
}
/**
* Implementation of the connection between the App-wide IdentityApi
* and sign-in page.
*/
export class AppIdentityProxy implements IdentityApi {
private target?: IdentityApi;
// This is called by the app manager once the sign-in page provides us with an implementation
setTarget(identityApi: IdentityApi) {
this.target = identityApi;
}
getUserId(): string {
if (!this.target) {
throw mkError('getUserId');
}
return this.target.getUserId();
}
getProfile(): ProfileInfo {
if (!this.target) {
throw mkError('getProfile');
}
return this.target.getProfile();
}
async getProfileInfo(): Promise<ProfileInfo> {
if (!this.target) {
throw mkError('getProfileInfo');
}
return this.target.getProfileInfo();
}
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
if (!this.target) {
throw mkError('getBackstageIdentity');
}
return this.target.getBackstageIdentity();
}
async getCredentials(): Promise<{ token?: string | undefined }> {
if (!this.target) {
throw mkError('getCredentials');
}
return this.target.getCredentials();
}
async getIdToken(): Promise<string | undefined> {
if (!this.target) {
throw mkError('getIdToken');
}
return this.target.getIdToken();
}
async signOut(): Promise<void> {
if (!this.target) {
throw mkError('signOut');
}
await this.target.signOut();
location.reload();
}
}
@@ -30,12 +30,14 @@ const DEFAULT_PROVIDER = {
* @public
*/
export default class AtlassianAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T {
static create(options: OAuthApiCreateOptions): typeof atlassianAuthApiRef.T {
const {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
} = options;
return OAuth2.create({
discoveryApi,
oauthRequestApi,
@@ -30,13 +30,15 @@ const DEFAULT_PROVIDER = {
* @public
*/
export default class Auth0Auth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', `email`, `profile`],
}: OAuthApiCreateOptions): typeof auth0AuthApiRef.T {
static create(options: OAuthApiCreateOptions): typeof auth0AuthApiRef.T {
const {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', `email`, `profile`],
} = options;
return OAuth2.create({
discoveryApi,
oauthRequestApi,
@@ -45,13 +45,15 @@ const DEFAULT_PROVIDER = {
* @public
*/
export default class BitbucketAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['team'],
}: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T {
static create(options: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T {
const {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['team'],
} = options;
return OAuth2.create({
discoveryApi,
oauthRequestApi,
@@ -56,13 +56,15 @@ const DEFAULT_PROVIDER = {
* @public
*/
export default class GithubAuth implements OAuthApi, SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read:user'],
}: OAuthApiCreateOptions) {
static create(options: OAuthApiCreateOptions) {
const {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read:user'],
} = options;
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
@@ -30,13 +30,15 @@ const DEFAULT_PROVIDER = {
* @public
*/
export default class GitlabAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read_user'],
}: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T {
static create(options: OAuthApiCreateOptions): typeof gitlabAuthApiRef.T {
const {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['read_user'],
} = options;
return OAuth2.create({
discoveryApi,
oauthRequestApi,
@@ -32,17 +32,19 @@ const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
* @public
*/
export default class GoogleAuth {
static create({
discoveryApi,
oauthRequestApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
defaultScopes = [
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
],
}: OAuthApiCreateOptions): typeof googleAuthApiRef.T {
static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T {
const {
discoveryApi,
oauthRequestApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
defaultScopes = [
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
],
} = options;
return OAuth2.create({
discoveryApi,
oauthRequestApi,
@@ -30,19 +30,21 @@ const DEFAULT_PROVIDER = {
* @public
*/
export default class MicrosoftAuth {
static create({
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
discoveryApi,
defaultScopes = [
'openid',
'offline_access',
'profile',
'email',
'User.Read',
],
}: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T {
static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T {
const {
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
discoveryApi,
defaultScopes = [
'openid',
'offline_access',
'profile',
'email',
'User.Read',
],
} = options;
return OAuth2.create({
discoveryApi,
oauthRequestApi,
@@ -70,14 +70,16 @@ export default class OAuth2
BackstageIdentityApi,
SessionApi
{
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = [],
scopeTransform = x => x,
}: OAuth2CreateOptions) {
static create(options: OAuth2CreateOptions) {
const {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = [],
scopeTransform = x => x,
} = options;
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
@@ -42,13 +42,15 @@ const OKTA_SCOPE_PREFIX: string = 'okta.';
* @public
*/
export default class OktaAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', 'email', 'profile', 'offline_access'],
}: OAuthApiCreateOptions): typeof oktaAuthApiRef.T {
static create(options: OAuthApiCreateOptions): typeof oktaAuthApiRef.T {
const {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['openid', 'email', 'profile', 'offline_access'],
} = options;
return OAuth2.create({
discoveryApi,
oauthRequestApi,
@@ -57,12 +57,16 @@ const SCOPE_PREFIX: string = 'onelogin.';
* @public
*/
export default class OneLoginAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: OneLoginAuthCreateOptions): typeof oneloginAuthApiRef.T {
static create(
options: OneLoginAuthCreateOptions,
): typeof oneloginAuthApiRef.T {
const {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
} = options;
return OAuth2.create({
discoveryApi,
oauthRequestApi,
@@ -52,11 +52,13 @@ const DEFAULT_PROVIDER = {
export default class SamlAuth
implements ProfileInfoApi, BackstageIdentityApi, SessionApi
{
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
}: AuthApiCreateOptions) {
static create(options: AuthApiCreateOptions) {
const {
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
} = options;
const connector = new DirectAuthConnector<SamlSession>({
discoveryApi,
environment,
@@ -1,85 +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 { IdentityApi, ProfileInfo } from '@backstage/core-plugin-api';
import { SignInResult } from './types';
/**
* Implementation of the connection between the App-wide IdentityApi
* and sign-in page.
*/
export class AppIdentity implements IdentityApi {
private hasIdentity = false;
private userId?: string;
private profile?: ProfileInfo;
private idTokenFunc?: () => Promise<string>;
private signOutFunc?: () => Promise<void>;
getUserId(): string {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi userId before app was loaded',
);
}
return this.userId!;
}
getProfile(): ProfileInfo {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi profile before app was loaded',
);
}
return this.profile!;
}
async getIdToken(): Promise<string | undefined> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi idToken before app was loaded',
);
}
return this.idTokenFunc?.();
}
async signOut(): Promise<void> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi signOutFunc before app was loaded',
);
}
await this.signOutFunc?.();
location.reload();
}
// This is indirectly called by the sign-in page to continue into the app.
setSignInResult(result: SignInResult) {
if (this.hasIdentity) {
return;
}
if (!result.userId) {
throw new Error('Invalid sign-in result, userId not set');
}
if (!result.profile) {
throw new Error('Invalid sign-in result, profile not set');
}
this.hasIdentity = true;
this.userId = result.userId;
this.profile = result.profile;
this.idTokenFunc = result.getIdToken;
this.signOutFunc = result.signOut;
}
}
+11 -16
View File
@@ -43,12 +43,14 @@ import {
AppThemeApi,
ConfigApi,
featureFlagsApiRef,
IdentityApi,
identityApiRef,
BackstagePlugin,
RouteRef,
SubRouteRef,
ExternalRouteRef,
} from '@backstage/core-plugin-api';
import { UserIdentity } from '@backstage/core-components';
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
import {
childDiscoverer,
@@ -66,7 +68,7 @@ import { RoutingProvider } from '../routing/RoutingProvider';
import { RouteTracker } from '../routing/RouteTracker';
import { validateRoutes } from '../routing/validation';
import { AppContextProvider } from './AppContext';
import { AppIdentity } from './AppIdentity';
import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
import {
AppComponents,
AppConfigLoader,
@@ -75,7 +77,6 @@ import {
AppRouteBinder,
BackstageApp,
SignInPageProps,
SignInResult,
} from './types';
import { AppThemeProvider } from './AppThemeProvider';
import { defaultConfigLoader } from './defaultConfigLoader';
@@ -189,7 +190,7 @@ export class AppManager implements BackstageApp {
private readonly defaultApis: Iterable<AnyApiFactory>;
private readonly bindRoutes: AppOptions['bindRoutes'];
private readonly identityApi = new AppIdentity();
private readonly appIdentityProxy = new AppIdentityProxy();
private readonly apiFactoryRegistry: ApiFactoryRegistry;
constructor(options: AppOptions) {
@@ -344,14 +345,14 @@ export class AppManager implements BackstageApp {
component: ComponentType<SignInPageProps>;
children: ReactElement;
}) => {
const [result, setResult] = useState<SignInResult>();
const [identityApi, setIdentityApi] = useState<IdentityApi>();
if (result) {
this.identityApi.setSignInResult(result);
return children;
if (!identityApi) {
return <Component onSignInSuccess={setIdentityApi} />;
}
return <Component onResult={setResult} />;
this.appIdentityProxy.setTarget(identityApi);
return children;
};
const AppRouter = ({ children }: PropsWithChildren<{}>) => {
@@ -360,13 +361,7 @@ export class AppManager implements BackstageApp {
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
this.identityApi.setSignInResult({
userId: 'guest',
profile: {
email: 'guest@example.com',
displayName: 'Guest',
},
});
this.appIdentityProxy.setTarget(UserIdentity.createGuest());
return (
<RouterComponent>
@@ -430,7 +425,7 @@ export class AppManager implements BackstageApp {
this.apiFactoryRegistry.register('static', {
api: identityApiRef,
deps: {},
factory: () => this.identityApi,
factory: () => this.appIdentityProxy,
});
// It's possible to replace the feature flag API, but since we must have at least
+4 -2
View File
@@ -25,6 +25,7 @@ import {
SubRouteRef,
ExternalRouteRef,
PluginOutput,
IdentityApi,
} from '@backstage/core-plugin-api';
import { AppConfig } from '@backstage/config';
@@ -42,6 +43,7 @@ export type BootErrorPageProps = {
* The outcome of signing in on the sign-in page.
*
* @public
* @deprecated replaced by passing the {@link @backstage/core-plugin-api#IdentityApi} to the {@link SignInPageProps.onSignInSuccess} instead.
*/
export type SignInResult = {
/**
@@ -69,9 +71,9 @@ export type SignInResult = {
*/
export type SignInPageProps = {
/**
* Set the sign-in result for the app. This should only be called once.
* Set the IdentityApi on successful sign in. This should only be called once.
*/
onResult(result: SignInResult): void;
onSignInSuccess(identityApi: IdentityApi): void;
};
/**
+21
View File
@@ -1,5 +1,26 @@
# @backstage/core-components
## 0.8.0
### Minor Changes
- a036b65c2f: The `SignInPage` has been updated to use the new `onSignInSuccess` callback that was introduced in the same release. While existing code will usually continue to work, it is technically a breaking change because of the dependency on `SignInProps` from the `@backstage/core-plugin-api`. For more information on this change and instructions on how to migrate existing code, see the [`@backstage/core-app-api` CHANGELOG.md](https://github.com/backstage/backstage/blob/master/packages/core-app-api/CHANGELOG.md).
Added a new `UserIdentity` class which helps create implementations of the `IdentityApi`. It provides a couple of static factory methods such as the most relevant `create`, and `createGuest` to create an `IdentityApi` for a guest user.
Also provides a deprecated `fromLegacy` method to create an `IdentityApi` from the now deprecated `SignInResult`. This method will be removed in the future when `SignInResult` is also removed.
### Patch Changes
- 9603827bb5: Addressed some peer dependency warnings
- cd450844f6: Moved React dependencies to `peerDependencies` and allow both React v16 and v17 to be used.
- dcd1a0c3f4: Minor improvement to the API reports, by not unpacking arguments directly
- e839500286: Introduce new `LogViewer` component that can be used to display logs. It supports copying, searching, filtering, and displaying text with ANSI color escape codes.
- 1357ac30f1: Standardize on `classnames` instead of both that and `clsx`.
- e5976071ea: Use ellipsis style for overflowed text in sidebar menu
- Updated dependencies
- @backstage/core-plugin-api@0.3.0
## 0.7.6
### Patch Changes
+76 -4
View File
@@ -9,6 +9,7 @@ import { ApiRef } from '@backstage/core-plugin-api';
import { BackstageIdentityApi } from '@backstage/core-plugin-api';
import { BackstagePalette } from '@backstage/theme';
import { BackstageTheme } from '@backstage/theme';
import { BackstageUserIdentity } from '@backstage/core-plugin-api';
import { BottomNavigationActionProps } from '@material-ui/core/BottomNavigationAction';
import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button';
import { CardHeaderProps } from '@material-ui/core/CardHeader';
@@ -21,6 +22,7 @@ import { CSSProperties } from 'react';
import { ElementType } from 'react';
import { ErrorInfo } from 'react';
import { IconComponent } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { LinearProgressProps } from '@material-ui/core/LinearProgress';
import { LinkProps as LinkProps_2 } from '@material-ui/core/Link';
import { LinkProps as LinkProps_3 } from 'react-router-dom';
@@ -28,6 +30,7 @@ import MaterialBreadcrumbs from '@material-ui/core/Breadcrumbs';
import { MaterialTableProps } from '@material-table/core';
import { NavLinkProps } from 'react-router-dom';
import { Overrides } from '@material-ui/core/styles/overrides';
import { ProfileInfo } from '@backstage/core-plugin-api';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
@@ -36,6 +39,7 @@ import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { SessionApi } from '@backstage/core-plugin-api';
import { SignInPageProps } from '@backstage/core-plugin-api';
import { SignInResult } from '@backstage/core-plugin-api';
import { SparklinesLineProps } from 'react-sparklines';
import { SparklinesProps } from 'react-sparklines';
import { StyledComponentProps } from '@material-ui/core/styles';
@@ -606,6 +610,50 @@ export type LinkProps = LinkProps_2 &
// @public (undocumented)
export type LoginRequestListItemClassKey = 'root';
// @public
export function LogViewer(props: LogViewerProps): JSX.Element;
// @public
export type LogViewerClassKey =
| 'root'
| 'header'
| 'log'
| 'line'
| 'lineSelected'
| 'lineCopyButton'
| 'lineNumber'
| 'textHighlight'
| 'textSelectedHighlight'
| 'modifierBold'
| 'modifierItalic'
| 'modifierUnderline'
| 'modifierForegroundBlack'
| 'modifierForegroundRed'
| 'modifierForegroundGreen'
| 'modifierForegroundYellow'
| 'modifierForegroundBlue'
| 'modifierForegroundMagenta'
| 'modifierForegroundCyan'
| 'modifierForegroundWhite'
| 'modifierForegroundGrey'
| 'modifierBackgroundBlack'
| 'modifierBackgroundRed'
| 'modifierBackgroundGreen'
| 'modifierBackgroundYellow'
| 'modifierBackgroundBlue'
| 'modifierBackgroundMagenta'
| 'modifierBackgroundCyan'
| 'modifierBackgroundWhite'
| 'modifierBackgroundGrey';
// @public
export interface LogViewerProps {
classes?: {
root?: string;
};
text: string;
}
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "MarkdownContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -2014,10 +2062,7 @@ export const SidebarSpacer: React_2.ComponentType<
>;
// @public
export const SidebarSubmenu: ({
title,
children,
}: PropsWithChildren<SidebarSubmenuProps>) => JSX.Element;
export const SidebarSubmenu: (props: SidebarSubmenuProps) => JSX.Element;
// @public
export const SidebarSubmenuItem: (
@@ -2349,6 +2394,33 @@ export function useQueryParamState<T>(
// @public (undocumented)
export function UserIcon(props: IconComponentProps): JSX.Element;
// @public
export class UserIdentity implements IdentityApi {
static create(options: {
identity: BackstageUserIdentity;
authApi: ProfileInfoApi & BackstageIdentityApi & SessionApi;
profile?: ProfileInfo;
}): IdentityApi;
static createGuest(): IdentityApi;
static fromLegacy(result: SignInResult): IdentityApi;
// (undocumented)
getBackstageIdentity(): Promise<BackstageUserIdentity>;
// (undocumented)
getCredentials(): Promise<{
token?: string | undefined;
}>;
// (undocumented)
getIdToken(): Promise<string | undefined>;
// (undocumented)
getProfile(): ProfileInfo;
// (undocumented)
getProfileInfo(): Promise<ProfileInfo>;
// (undocumented)
getUserId(): string;
// (undocumented)
signOut(): Promise<void>;
}
// Warning: (ae-missing-release-tag) "useSupportConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+16 -9
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core-components",
"description": "Core components used by Backstage plugins and apps",
"version": "0.7.6",
"version": "0.8.0",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,18 +30,17 @@
},
"dependencies": {
"@backstage/config": "^0.1.11",
"@backstage/core-plugin-api": "^0.2.2",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/errors": "^0.1.5",
"@backstage/theme": "^0.2.14",
"@material-table/core": "^3.1.0",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@types/react": "*",
"@types/react-sparklines": "^1.7.0",
"@types/react-text-truncate": "^0.14.0",
"ansi-regex": "^5.0.1",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
"d3-selection": "^3.0.0",
"d3-shape": "^3.0.0",
"d3-zoom": "^3.0.0",
@@ -53,8 +52,6 @@
"prop-types": "^15.7.2",
"qs": "^6.9.4",
"rc-progress": "^3.0.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-helmet": "6.1.0",
"react-hook-form": "^7.12.2",
"react-markdown": "^7.0.1",
@@ -64,13 +61,20 @@
"react-syntax-highlighter": "^15.4.3",
"react-text-truncate": "^0.16.0",
"react-use": "^17.2.4",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6",
"remark-gfm": "^2.0.0",
"zen-observable": "^0.8.15"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/core-app-api": "^0.1.24",
"@backstage/cli": "^0.10.0",
"@backstage/test-utils": "^0.1.23",
"@backstage/core-app-api": "^0.2.0",
"@backstage/cli": "^0.10.1",
"@backstage/test-utils": "^0.1.24",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^7.0.2",
@@ -79,12 +83,15 @@
"@types/d3-selection": "^3.0.1",
"@types/d3-shape": "^3.0.1",
"@types/d3-zoom": "^3.0.1",
"@types/ansi-regex": "^5.0.0",
"@types/dagre": "^0.7.44",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"@types/react-helmet": "^6.1.0",
"@types/react-syntax-highlighter": "^13.5.2",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/zen-observable": "^0.8.0"
},
"files": [
@@ -15,8 +15,7 @@
*/
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { act, fireEvent } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { CopyTextButton } from './CopyTextButton';
import { errorApiRef } from '@backstage/core-plugin-api';
@@ -0,0 +1,233 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AnsiProcessor } from './AnsiProcessor';
describe('AnsiProcessor', () => {
it('should process a single line', () => {
const processor = new AnsiProcessor();
expect(processor.process('foo\x1b[31mbar\x1b[39mbaz')).toEqual([
{
chunks: [
{
text: 'foo',
modifiers: {},
},
{
text: 'bar',
modifiers: { foreground: 'red' },
},
{
text: 'baz',
modifiers: {},
},
],
text: 'foobarbaz',
lineNumber: 1,
},
]);
expect(processor.process(`foo bar: baz`)).toEqual([
{
chunks: [
{
text: 'foo ',
modifiers: {},
},
{
text: 'bar',
modifiers: { foreground: 'green' },
},
{
text: ': baz',
modifiers: {},
},
],
text: 'foo bar: baz',
lineNumber: 1,
},
]);
});
it('should process multiple lines', () => {
const processor = new AnsiProcessor();
expect(
processor.process(`
a\x1b[34mb\x1b[39mc
x\x1b[44my\x1b[49mz
`),
).toEqual([
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
{
chunks: [
{
text: 'a',
modifiers: {},
},
{
text: 'b',
modifiers: { foreground: 'blue' },
},
{
text: 'c',
modifiers: {},
},
],
text: 'abc',
lineNumber: 2,
},
{
chunks: [
{
text: 'x',
modifiers: {},
},
{
text: 'y',
modifiers: { background: 'blue' },
},
{
text: 'z',
modifiers: {},
},
],
text: 'xyz',
lineNumber: 3,
},
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 4 },
]);
});
it('should carry state across lines', () => {
const processor = new AnsiProcessor();
expect(
processor.process(`
a\x1b[45mb\x1b[35mc
x\x1b[39my\x1b[49mz`),
).toEqual([
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
{
chunks: [
{
text: 'a',
modifiers: {},
},
{
text: 'b',
modifiers: { background: 'magenta' },
},
{
text: 'c',
modifiers: { foreground: 'magenta', background: 'magenta' },
},
],
text: 'abc',
lineNumber: 2,
},
{
chunks: [
{
text: 'x',
modifiers: { foreground: 'magenta', background: 'magenta' },
},
{
text: 'y',
modifiers: { background: 'magenta' },
},
{
text: 'z',
modifiers: {},
},
],
text: 'xyz',
lineNumber: 3,
},
]);
});
it('should carry forward state when appending lines', () => {
const processor = new AnsiProcessor();
const out1 = processor.process(`
a\x1b[36mb\x1b[3mc`);
expect(out1).toEqual([
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
{
chunks: [
{
text: 'a',
modifiers: {},
},
{
text: 'b',
modifiers: { foreground: 'cyan' },
},
{
text: 'c',
modifiers: { foreground: 'cyan', italic: true },
},
],
text: 'abc',
lineNumber: 2,
},
]);
const out2 = processor.process(`
a\x1b[36mb\x1b[3mc
x\x1b[39my\x1b[23mz`);
expect(out2).toEqual([
{ chunks: [{ text: '', modifiers: {} }], text: '', lineNumber: 1 },
{
chunks: [
{
text: 'a',
modifiers: {},
},
{
text: 'b',
modifiers: { foreground: 'cyan' },
},
{
text: 'c',
modifiers: { foreground: 'cyan', italic: true },
},
],
text: 'abc',
lineNumber: 2,
},
{
chunks: [
{
text: 'x',
modifiers: { foreground: 'cyan', italic: true },
},
{
text: 'y',
modifiers: { italic: true },
},
{
text: 'z',
modifiers: {},
},
],
text: 'xyz',
lineNumber: 3,
},
]);
// Verifies that we appended rather than reprocessed
expect(out1[0]).toBe(out2[0]);
});
});
@@ -0,0 +1,216 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ansiRegexMaker from 'ansi-regex';
const ansiRegex = ansiRegexMaker();
const newlineRegex = /\n\r?/g;
// A mapping of how each escape code changes the modifiers
const codeModifiers = Object.fromEntries(
Object.entries({
1: m => ({ ...m, bold: true }),
3: m => ({ ...m, italic: true }),
4: m => ({ ...m, underline: true }),
22: ({ bold: _, ...m }) => m,
23: ({ italic: _, ...m }) => m,
24: ({ underline: _, ...m }) => m,
30: m => ({ ...m, foreground: 'black' }),
31: m => ({ ...m, foreground: 'red' }),
32: m => ({ ...m, foreground: 'green' }),
33: m => ({ ...m, foreground: 'yellow' }),
34: m => ({ ...m, foreground: 'blue' }),
35: m => ({ ...m, foreground: 'magenta' }),
36: m => ({ ...m, foreground: 'cyan' }),
37: m => ({ ...m, foreground: 'white' }),
39: ({ foreground: _, ...m }) => m,
90: m => ({ ...m, foreground: 'grey' }),
40: m => ({ ...m, background: 'black' }),
41: m => ({ ...m, background: 'red' }),
42: m => ({ ...m, background: 'green' }),
43: m => ({ ...m, background: 'yellow' }),
44: m => ({ ...m, background: 'blue' }),
45: m => ({ ...m, background: 'magenta' }),
46: m => ({ ...m, background: 'cyan' }),
47: m => ({ ...m, background: 'white' }),
49: ({ background: _, ...m }) => m,
} as Record<string, (m: ChunkModifiers) => ChunkModifiers>).map(
([code, modifier]) => [`\x1b[${code}m`, modifier],
),
);
export type AnsiColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'grey';
export interface ChunkModifiers {
foreground?: AnsiColor;
background?: AnsiColor;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
export interface AnsiChunk {
text: string;
modifiers: ChunkModifiers;
}
export class AnsiLine {
text: string;
constructor(
readonly lineNumber: number = 1,
readonly chunks: AnsiChunk[] = [],
) {
this.text = chunks
.map(c => c.text)
.join('')
.toLocaleLowerCase('en-US');
}
lastChunk(): AnsiChunk | undefined {
return this.chunks[this.chunks.length - 1];
}
replaceLastChunk(newChunks?: AnsiChunk[]) {
if (newChunks) {
this.chunks.splice(this.chunks.length - 1, 1, ...newChunks);
this.text = this.chunks
.map(c => c.text)
.join('')
.toLocaleLowerCase('en-US');
}
}
}
export class AnsiProcessor {
private text: string = '';
private lines: AnsiLine[] = [];
/**
* Processes a chunk of text while keeping internal state that optimizes
* subsequent processing that appends to the text.
*/
process(text: string): AnsiLine[] {
if (this.text === text) {
return this.lines;
}
if (text.startsWith(this.text)) {
const lastLineIndex = this.lines.length > 0 ? this.lines.length - 1 : 0;
const lastLine = this.lines[lastLineIndex] ?? new AnsiLine();
const lastChunk = lastLine.lastChunk();
const newLines = this.processLines(
(lastChunk?.text ?? '') + text.slice(this.text.length),
lastChunk?.modifiers,
lastLine?.lineNumber,
);
lastLine.replaceLastChunk(newLines[0]?.chunks);
this.lines[lastLineIndex] = lastLine;
this.lines.push(...newLines.slice(1));
} else {
this.lines = this.processLines(text);
}
this.text = text;
return this.lines;
}
// Split a chunk of text up into lines and process each line individually
private processLines = (
text: string,
modifiers: ChunkModifiers = {},
startingLineNumber: number = 1,
): AnsiLine[] => {
const lines: AnsiLine[] = [];
let currentModifiers = modifiers;
let currentLineNumber = startingLineNumber;
let prevIndex = 0;
newlineRegex.lastIndex = 0;
for (;;) {
const match = newlineRegex.exec(text);
if (!match) {
const chunks = this.processText(
text.slice(prevIndex),
currentModifiers,
);
lines.push(new AnsiLine(currentLineNumber, chunks));
return lines;
}
const line = text.slice(prevIndex, match.index);
prevIndex = match.index + match[0].length;
const chunks = this.processText(line, currentModifiers);
lines.push(new AnsiLine(currentLineNumber, chunks));
// Modifiers that are active in the last chunk are carried over to the next line
currentModifiers =
chunks[chunks.length - 1].modifiers ?? currentModifiers;
currentLineNumber += 1;
}
};
// Processing of a one individual text chunk
private processText = (
fullText: string,
modifiers: ChunkModifiers,
): AnsiChunk[] => {
const chunks: AnsiChunk[] = [];
let currentModifiers = modifiers;
let prevIndex = 0;
ansiRegex.lastIndex = 0;
for (;;) {
const match = ansiRegex.exec(fullText);
if (!match) {
chunks.push({
text: fullText.slice(prevIndex),
modifiers: currentModifiers,
});
return chunks;
}
const text = fullText.slice(prevIndex, match.index);
chunks.push({ text, modifiers: currentModifiers });
// For every escape code that we encounter we keep track of where the
// next chunk of text starts, and what modifiers it has
prevIndex = match.index + match[0].length;
currentModifiers = this.processCode(match[0], currentModifiers);
}
};
private processCode = (
code: string,
modifiers: ChunkModifiers,
): ChunkModifiers => {
return codeModifiers[code]?.(modifiers) ?? modifiers;
};
}
@@ -0,0 +1,356 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AnsiLine, ChunkModifiers } from './AnsiProcessor';
import {
calculateHighlightedChunks,
findSearchResults,
getModifierClasses,
} from './LogLine';
describe('getModifierClasses', () => {
const classes = {
modifierBold: 'bold',
modifierItalic: 'italic',
modifierUnderline: 'underline',
modifierForegroundBlack: 'black',
modifierForegroundRed: 'red',
modifierForegroundGreen: 'green',
modifierForegroundYellow: 'yellow',
modifierForegroundBlue: 'blue',
modifierForegroundMagenta: 'magenta',
modifierForegroundCyan: 'cyan',
modifierForegroundWhite: 'white',
modifierForegroundGrey: 'grey',
modifierBackgroundBlack: 'bg-black',
modifierBackgroundRed: 'bg-red',
modifierBackgroundGreen: 'bg-green',
modifierBackgroundYellow: 'bg-yellow',
modifierBackgroundBlue: 'bg-blue',
modifierBackgroundMagenta: 'bg-magenta',
modifierBackgroundCyan: 'bg-cyan',
modifierBackgroundWhite: 'bg-white',
modifierBackgroundGrey: 'bg-grey',
};
const curried = (modifiers: ChunkModifiers) =>
getModifierClasses(
classes as Parameters<typeof getModifierClasses>[0],
modifiers,
);
it('should transform modifiers to classes', () => {
expect(curried({})).toEqual(undefined);
expect(curried({ bold: true })).toEqual('bold');
expect(curried({ italic: true })).toEqual('italic');
expect(curried({ underline: true })).toEqual('underline');
expect(curried({ foreground: 'black' })).toEqual('black');
expect(curried({ background: 'black' })).toEqual('bg-black');
expect(
curried({
bold: true,
italic: true,
underline: true,
foreground: 'red',
background: 'red',
}),
).toEqual('bold italic underline red bg-red');
});
});
describe('findSearchResults', () => {
it('should not return results if there is no match', () => {
expect(findSearchResults('Foo', 'Bar')).toEqual(undefined);
expect(findSearchResults('Foo Bar', 'oof')).toEqual(undefined);
expect(findSearchResults('Foo Bar', '')).toEqual(undefined);
expect(findSearchResults('', '')).toEqual(undefined);
expect(findSearchResults('', 'Foo')).toEqual(undefined);
});
it('should find result indices', () => {
expect(findSearchResults('Foo', 'Foo')).toEqual([{ start: 0, end: 3 }]);
expect(findSearchResults('Foo', 'o')).toEqual([
{ start: 1, end: 2 },
{ start: 2, end: 3 },
]);
expect(findSearchResults('FooBarBaz', 'Bar')).toEqual([
{ start: 3, end: 6 },
]);
expect(findSearchResults('Foo Bar Baz', ' ')).toEqual([
{ start: 3, end: 4 },
{ start: 7, end: 8 },
]);
expect(findSearchResults('FooBarBazBarFoo', 'Bar')).toEqual([
{ start: 3, end: 6 },
{ start: 9, end: 12 },
]);
expect(findSearchResults('FooBarBazBarFoo', 'Foo')).toEqual([
{ start: 0, end: 3 },
{ start: 12, end: 15 },
]);
});
it('should not overlap search results', () => {
expect(findSearchResults('aaa', 'aa')).toEqual([{ start: 0, end: 2 }]);
expect(findSearchResults('aaaa', 'aa')).toEqual([
{ start: 0, end: 2 },
{ start: 2, end: 4 },
]);
expect(findSearchResults('aaaaa', 'aa')).toEqual([
{ start: 0, end: 2 },
{ start: 2, end: 4 },
]);
});
});
describe('calculateHighlightedChunks', () => {
it('should pass through chunks if there are no results', () => {
const chunks = [{ text: 'Foo', modifiers: {} }];
const line = new AnsiLine(0, chunks);
expect(calculateHighlightedChunks(line, 'bar')).toBe(chunks);
});
it('should highlight one result from plain text', () => {
const line = new AnsiLine(0, [{ text: 'FooBarBaz', modifiers: {} }]);
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
{
text: 'Foo',
modifiers: {},
highlight: 0,
},
{
text: 'BarBaz',
modifiers: {},
},
]);
expect(calculateHighlightedChunks(line, 'bar')).toEqual([
{
text: 'Foo',
modifiers: {},
},
{
text: 'Bar',
modifiers: {},
highlight: 0,
},
{
text: 'Baz',
modifiers: {},
},
]);
expect(calculateHighlightedChunks(line, 'baz')).toEqual([
{
text: 'FooBar',
modifiers: {},
},
{
text: 'Baz',
modifiers: {},
highlight: 0,
},
]);
});
it('should highlight multiple results from plain text', () => {
const line = new AnsiLine(0, [
{ text: 'FooBarBazBazBarFoo', modifiers: {} },
]);
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
{
text: 'Foo',
modifiers: {},
highlight: 0,
},
{
text: 'BarBazBazBar',
modifiers: {},
},
{
text: 'Foo',
modifiers: {},
highlight: 1,
},
]);
expect(calculateHighlightedChunks(line, 'bar')).toEqual([
{
text: 'Foo',
modifiers: {},
},
{
text: 'Bar',
modifiers: {},
highlight: 0,
},
{
text: 'BazBaz',
modifiers: {},
},
{
text: 'Bar',
modifiers: {},
highlight: 1,
},
{
text: 'Foo',
modifiers: {},
},
]);
expect(calculateHighlightedChunks(line, 'baz')).toEqual([
{
text: 'FooBar',
modifiers: {},
},
{
text: 'Baz',
modifiers: {},
highlight: 0,
},
{
text: 'Baz',
modifiers: {},
highlight: 1,
},
{
text: 'BarFoo',
modifiers: {},
},
]);
});
it('should forward modifiers to result', () => {
const line = new AnsiLine(0, [
{ text: 'FooBarBazBazBarFoo', modifiers: { bold: true } },
]);
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
{
text: 'Foo',
modifiers: { bold: true },
highlight: 0,
},
{
text: 'BarBazBazBar',
modifiers: { bold: true },
},
{
text: 'Foo',
modifiers: { bold: true },
highlight: 1,
},
]);
});
it('should highlight full chunks', () => {
const line = new AnsiLine(0, [
{ text: 'Foo', modifiers: { bold: true } },
{ text: 'BarBaz', modifiers: { bold: true } },
{ text: 'BazBar', modifiers: { italic: true } },
{ text: 'Foo', modifiers: { italic: true } },
]);
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
{
text: 'Foo',
modifiers: { bold: true },
highlight: 0,
},
{
text: 'BarBaz',
modifiers: { bold: true },
},
{
text: 'BazBar',
modifiers: { italic: true },
},
{
text: 'Foo',
modifiers: { italic: true },
highlight: 1,
},
]);
});
it('should highlight partial chunks', () => {
const line = new AnsiLine(0, [
{ text: 'Fo', modifiers: { bold: true } },
{ text: 'oFooFo', modifiers: {} },
{ text: 'oBarBaz', modifiers: { italic: true } },
{ text: 'Foo', modifiers: { foreground: 'blue' } },
{ text: 'FooFoo', modifiers: { italic: true } },
{ text: 'F', modifiers: { bold: true } },
{ text: 'o', modifiers: {} },
{ text: 'o', modifiers: { bold: true } },
]);
expect(calculateHighlightedChunks(line, 'foo')).toEqual([
{
text: 'Fo',
modifiers: { bold: true },
highlight: 0,
},
{
text: 'o',
modifiers: {},
highlight: 0,
},
{
text: 'Foo',
modifiers: {},
highlight: 1,
},
{
text: 'Fo',
modifiers: {},
highlight: 2,
},
{
text: 'o',
modifiers: { italic: true },
highlight: 2,
},
{
text: 'BarBaz',
modifiers: { italic: true },
},
{
text: 'Foo',
modifiers: { foreground: 'blue' },
highlight: 3,
},
{
text: 'Foo',
modifiers: { italic: true },
highlight: 4,
},
{
text: 'Foo',
modifiers: { italic: true },
highlight: 5,
},
{
text: 'F',
modifiers: { bold: true },
highlight: 6,
},
{
text: 'o',
modifiers: {},
highlight: 6,
},
{
text: 'o',
modifiers: { bold: true },
highlight: 6,
},
]);
});
});
@@ -0,0 +1,178 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useMemo } from 'react';
import { AnsiChunk, AnsiLine, ChunkModifiers } from './AnsiProcessor';
import startCase from 'lodash/startCase';
import classnames from 'classnames';
import { useStyles } from './styles';
export function getModifierClasses(
classes: ReturnType<typeof useStyles>,
modifiers: ChunkModifiers,
) {
const classNames = new Array<string>();
if (modifiers.bold) {
classNames.push(classes.modifierBold);
}
if (modifiers.italic) {
classNames.push(classes.modifierItalic);
}
if (modifiers.underline) {
classNames.push(classes.modifierUnderline);
}
if (modifiers.foreground) {
const key = `modifierForeground${startCase(
modifiers.foreground,
)}` as keyof typeof classes;
classNames.push(classes[key]);
}
if (modifiers.background) {
const key = `modifierBackground${startCase(
modifiers.background,
)}` as keyof typeof classes;
classNames.push(classes[key]);
}
return classNames.length > 0 ? classNames.join(' ') : undefined;
}
export function findSearchResults(text: string, searchText: string) {
if (!searchText || !text.includes(searchText)) {
return undefined;
}
const searchResults = new Array<{ start: number; end: number }>();
let offset = 0;
for (;;) {
const start = text.indexOf(searchText, offset);
if (start === -1) {
break;
}
const end = start + searchText.length;
searchResults.push({ start, end });
offset = end;
}
return searchResults;
}
export interface HighlightAnsiChunk extends AnsiChunk {
highlight?: number;
}
export function calculateHighlightedChunks(
line: AnsiLine,
searchText: string,
): HighlightAnsiChunk[] {
const results = findSearchResults(line.text, searchText);
if (!results) {
return line.chunks;
}
const chunks = new Array<HighlightAnsiChunk>();
let lineOffset = 0;
let resultIndex = 0;
let result = results[resultIndex];
for (const chunk of line.chunks) {
const { text, modifiers } = chunk;
if (!result || lineOffset + text.length < result.start) {
chunks.push(chunk);
lineOffset += text.length;
continue;
}
let localOffset = 0;
while (result) {
const localStart = Math.max(result.start - lineOffset, 0);
if (localStart > text.length) {
break; // The next result is not in this chunk
}
const localEnd = Math.min(result.end - lineOffset, text.length);
const hasTextBeforeResult = localStart > localOffset;
if (hasTextBeforeResult) {
chunks.push({ text: text.slice(localOffset, localStart), modifiers });
}
const hasResultText = localEnd > localStart;
if (hasResultText) {
chunks.push({
modifiers,
highlight: resultIndex,
text: text.slice(localStart, localEnd),
});
}
localOffset = localEnd;
const foundCompleteResult = result.end - lineOffset === localEnd;
if (foundCompleteResult) {
resultIndex += 1;
result = results[resultIndex];
} else {
break; // The rest of the result is in the following chunks
}
}
const hasTextAfterResult = localOffset < text.length;
if (hasTextAfterResult) {
chunks.push({ text: text.slice(localOffset), modifiers });
}
lineOffset += text.length;
}
return chunks;
}
export interface LogLineProps {
line: AnsiLine;
classes: ReturnType<typeof useStyles>;
searchText: string;
highlightResultIndex?: number;
}
export function LogLine({
line,
classes,
searchText,
highlightResultIndex,
}: LogLineProps) {
const chunks = useMemo(
() => calculateHighlightedChunks(line, searchText),
[line, searchText],
);
const elements = useMemo(
() =>
chunks.map(({ text, modifiers, highlight }, index) => (
<span
key={index}
className={classnames(
getModifierClasses(classes, modifiers),
highlight !== undefined &&
(highlight === highlightResultIndex
? classes.textSelectedHighlight
: classes.textHighlight),
)}
>
{text}
</span>
)),
[chunks, highlightResultIndex, classes],
);
return <>{elements}</>;
}
@@ -0,0 +1,83 @@
/*
* 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, { ComponentType } from 'react';
import { wrapInTestApp } from '@backstage/test-utils';
import { LogViewer } from './LogViewer';
export default {
title: 'Data Display/LogViewer',
component: LogViewer,
decorators: [(Story: ComponentType<{}>) => wrapInTestApp(<Story />)],
};
const exampleLog = `Starting up task with 3 steps
Beginning step Fetch Skeleton + Template
info: Fetching template content from remote URL {"timestamp":"2021-12-03T15:47:11.625Z"}
info: Listing files and directories in template {"timestamp":"2021-12-03T15:47:12.797Z"}
info: Processing 33 template files/directories with input values {"component_id":"srnthsrthntrhsn","description":"rnthsrtnhssrthnrsthn","destination":{"host":"github.com","owner":"rtshnsrtmhrstmh","repo":"srtmhsrtmhrsthms"},"owner":"rstnhrstnhsrthn","timestamp":"2021-12-03T15:47:12.801Z"}
info: Writing file .editorconfig to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.816Z"}
info: Writing file .eslintignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.818Z"}
info: Writing file .eslintrc.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.820Z"}
info: Writing directory .github/ to template output path. {"timestamp":"2021-12-03T15:47:12.823Z"}
info: Writing file .gitignore to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.824Z"}
info: Writing file README.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.827Z"}
info: Writing file babel.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.829Z"}
info: Writing file catalog-info.yaml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.831Z"}
info: Writing directory docs/ to template output path. {"timestamp":"2021-12-03T15:47:12.834Z"}
info: Writing file jest.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.836Z"}
info: Writing file mkdocs.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.838Z"}
info: Writing file next-env.d.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.841Z"}
info: Writing file next.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.844Z"}
info: Writing file package.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.845Z"}
info: Writing file prettier.config.js to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.848Z"}
info: Writing directory public/ to template output path. {"timestamp":"2021-12-03T15:47:12.849Z"}
info: Writing directory src/ to template output path. {"timestamp":"2021-12-03T15:47:12.850Z"}
info: Writing file tsconfig.json to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.851Z"}
info: Writing directory .github/workflows/ to template output path. {"timestamp":"2021-12-03T15:47:12.853Z"}
info: Writing file docs/index.md to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.854Z"}
info: Writing directory public/static/ to template output path. {"timestamp":"2021-12-03T15:47:12.857Z"}
info: Writing directory src/__tests__/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"}
info: Writing directory src/components/ to template output path. {"timestamp":"2021-12-03T15:47:12.858Z"}
info: Writing directory src/pages/ to template output path. {"timestamp":"2021-12-03T15:47:12.859Z"}
info: Copying file/directory .github/workflows/build.yml without processing. {"timestamp":"2021-12-03T15:47:12.859Z"}
info: Writing file .github/workflows/build.yml to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.860Z"}
info: Writing file public/static/fonts.css to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.861Z"}
info: Writing file src/components/Header.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.863Z"}
info: Writing file src/__tests__/index.test.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.865Z"}
info: Writing file src/pages/_app.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.868Z"}
info: Writing file src/pages/_document.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.871Z"}
info: Writing directory src/pages/api/ to template output path. {"timestamp":"2021-12-03T15:47:12.873Z"}
info: Writing file src/pages/index.tsx to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.874Z"}
info: Writing file src/pages/api/ping.ts to template output path with mode 33188. {"timestamp":"2021-12-03T15:47:12.877Z"}
info: Template result written to /var/folders/k6/9s7hd6w17115xlgwnsp0wsbr0000gn/T/5c9f8584-fded-4741-b6ef-46d94ff2cbdb {"timestamp":"2021-12-03T15:47:12.878Z"}
Finished step Fetch Skeleton + Template
Beginning step Publish
HttpError: Not Found
at /Users/patriko/dev/backstage/node_modules/@octokit/request/dist-node/index.js:86:21
at runMicrotasks (<anonymous>)
at processTicksAndRejections (internal/process/task_queues.js:95:5)
at async Object.handler (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts:156:20)
at async HandlebarsWorkflowRunner.execute (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/HandlebarsWorkflowRunner.ts:254:11)
at async TaskWorker.runOneTask (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:110:13)
at async eval (webpack-internal:///../../plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts:100:9)
Run completed with status: failed`;
export const ExampleLogViewer = () => (
<div style={{ height: 240 }}>
<LogViewer text={exampleLog} />
</div>
);
@@ -0,0 +1,64 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { lazy, Suspense } from 'react';
import { useApp } from '@backstage/core-plugin-api';
const RealLogViewer = lazy(() =>
import('./RealLogViewer').then(m => ({ default: m.RealLogViewer })),
);
/**
* The properties for the LogViewer component.
*
* @public
*/
export interface LogViewerProps {
/**
* The text of the logs to display.
*
* The LogViewer component is optimized for appending content at the end of the text.
*/
text: string;
/**
* Styling overrides for classes within the LogViewer component.
*/
classes?: {
root?: string;
};
}
/**
* A component that displays logs in a scrollable text area.
*
* The LogViewer has support for search and filtering, as well as displaying
* text content with ANSI color escape codes.
*
* Since the LogViewer uses windowing to avoid rendering all contents at once, the
* log is sized automatically to fill the available vertical space. This means
* it may often be needed to wrap the LogViewer in a container that provides it
* with a fixed amount of space.
*
* @public
*/
export function LogViewer(props: LogViewerProps) {
const { Progress } = useApp().getComponents();
return (
<Suspense fallback={<Progress />}>
<RealLogViewer {...props} />
</Suspense>
);
}
@@ -0,0 +1,74 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import IconButton from '@material-ui/core/IconButton';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import FilterListIcon from '@material-ui/icons/FilterList';
import { LogViewerSearch } from './useLogViewerSearch';
export interface LogViewerControlsProps extends LogViewerSearch {}
export function LogViewerControls(props: LogViewerControlsProps) {
const { resultCount, resultIndexStep, toggleShouldFilter } = props;
const resultIndex = props.resultIndex ?? 0;
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
if (event.metaKey || event.ctrlKey || event.altKey) {
toggleShouldFilter();
} else {
resultIndexStep(event.shiftKey);
}
}
};
return (
<>
{resultCount !== undefined && (
<>
<IconButton size="small" onClick={() => resultIndexStep(true)}>
<ChevronLeftIcon />
</IconButton>
<Typography>
{Math.min(resultIndex + 1, resultCount)}/{resultCount}
</Typography>
<IconButton size="small" onClick={() => resultIndexStep()}>
<ChevronRightIcon />
</IconButton>
</>
)}
<TextField
size="small"
variant="standard"
placeholder="Search"
value={props.searchInput}
onKeyPress={handleKeyPress}
onChange={e => props.setSearchInput(e.target.value)}
/>
<IconButton size="small" onClick={toggleShouldFilter}>
{props.shouldFilter ? (
<FilterListIcon color="primary" />
) : (
<FilterListIcon color="disabled" />
)}
</IconButton>
</>
);
}
@@ -0,0 +1,79 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ReactNode } from 'react';
import UserEvent from '@testing-library/user-event';
import { renderInTestApp } from '@backstage/test-utils';
import { RealLogViewer } from './RealLogViewer';
// eslint-disable-next-line import/no-extraneous-dependencies
import copyToClipboard from 'copy-to-clipboard';
// Used by useCopyToClipboard
jest.mock('copy-to-clipboard', () => ({
__esModule: true,
default: jest.fn(),
}));
// The <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
jest.mock('react-virtualized-auto-sizer', () => ({
__esModule: true,
default: (props: {
children: (size: { width: number; height: number }) => ReactNode;
}) => <>{props.children({ width: 400, height: 200 })}</>,
}));
const testText = `Some Log Line
Derp
Foo
Foo Foo
Wat`;
describe('RealLogViewer', () => {
it('should render text with search and filtering and copying', async () => {
const rendered = await renderInTestApp(<RealLogViewer text={testText} />);
expect(rendered.getByText('Derp')).toBeInTheDocument();
expect(rendered.getByText('Foo Foo')).toBeInTheDocument();
UserEvent.tab();
UserEvent.keyboard('Foo');
expect(rendered.getByText('1/3')).toBeInTheDocument();
UserEvent.keyboard('{enter}');
expect(rendered.getByText('2/3')).toBeInTheDocument();
UserEvent.keyboard('{enter}');
expect(rendered.getByText('3/3')).toBeInTheDocument();
UserEvent.keyboard('{enter}');
expect(rendered.getByText('1/3')).toBeInTheDocument();
UserEvent.keyboard('{shift}{enter}{/shift}');
expect(rendered.getByText('3/3')).toBeInTheDocument();
expect(rendered.queryByText('Some Log Line')).toBeInTheDocument();
UserEvent.keyboard('{meta}{enter}{/meta}');
expect(rendered.queryByText('Some Log Line')).not.toBeInTheDocument();
UserEvent.keyboard('{meta}{enter}{/meta}');
expect(rendered.queryByText('Some Log Line')).toBeInTheDocument();
// Tab down to line #2 and click
UserEvent.tab();
UserEvent.tab();
UserEvent.tab();
UserEvent.click(document.activeElement!);
UserEvent.click(rendered.getByTestId('copy-button'));
expect(copyToClipboard).toHaveBeenCalledWith('Derp');
});
});
@@ -0,0 +1,124 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useMemo, useRef } from 'react';
import IconButton from '@material-ui/core/IconButton';
import CopyIcon from '@material-ui/icons/FileCopy';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { AnsiProcessor } from './AnsiProcessor';
import { HEADER_SIZE, useStyles } from './styles';
import classnames from 'classnames';
import { LogLine } from './LogLine';
import { LogViewerControls } from './LogViewerControls';
import { useLogViewerSearch } from './useLogViewerSearch';
import { useLogViewerSelection } from './useLogViewerSelection';
export interface RealLogViewerProps {
text: string;
classes?: { root?: string };
}
export function RealLogViewer(props: RealLogViewerProps) {
const classes = useStyles({ classes: props.classes });
const listRef = useRef<FixedSizeList | null>(null);
// The processor keeps state that optimizes appending to the text
const processor = useMemo(() => new AnsiProcessor(), []);
const lines = processor.process(props.text);
const search = useLogViewerSearch(lines);
const selection = useLogViewerSelection(lines);
useEffect(() => {
if (search.resultLine !== undefined && listRef.current) {
listRef.current.scrollToItem(search.resultLine - 1, 'center');
}
}, [search.resultLine]);
const handleSelectLine = (
line: number,
event: { shiftKey: boolean; preventDefault: () => void },
) => {
event.preventDefault();
selection.setSelection(line, event.shiftKey);
};
return (
<AutoSizer>
{({ height, width }) => (
<div style={{ width, height }} className={classes.root}>
<div className={classes.header}>
<LogViewerControls {...search} />
</div>
<FixedSizeList
ref={listRef}
className={classes.log}
height={height - HEADER_SIZE}
width={width}
itemData={search.lines}
itemSize={20}
itemCount={search.lines.length}
>
{({ index, style, data }) => {
const line = data[index];
const { lineNumber } = line;
return (
<div
style={{ ...style }}
className={classnames(classes.line, {
[classes.lineSelected]: selection.isSelected(lineNumber),
})}
>
{selection.shouldShowButton(lineNumber) && (
<IconButton
data-testid="copy-button"
size="small"
className={classes.lineCopyButton}
onClick={() => selection.copySelection()}
>
<CopyIcon fontSize="inherit" />
</IconButton>
)}
<a
role="row"
target="_self"
href={`#line-${lineNumber}`}
className={classes.lineNumber}
onClick={event => handleSelectLine(lineNumber, event)}
onKeyPress={event => handleSelectLine(lineNumber, event)}
>
{lineNumber}
</a>
<LogLine
line={line}
classes={classes}
searchText={search.searchText}
highlightResultIndex={
search.resultLine === lineNumber
? search.resultLineIndex
: undefined
}
/>
</div>
);
}}
</FixedSizeList>
</div>
)}
</AutoSizer>
);
}
@@ -0,0 +1,19 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { LogViewer } from './LogViewer';
export type { LogViewerProps } from './LogViewer';
export type { LogViewerClassKey } from './styles';
@@ -0,0 +1,168 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { alpha, makeStyles } from '@material-ui/core/styles';
import * as colors from '@material-ui/core/colors';
export const HEADER_SIZE = 40;
/** @public Class keys for overriding LogViewer styles */
export type LogViewerClassKey =
| 'root'
| 'header'
| 'log'
| 'line'
| 'lineSelected'
| 'lineCopyButton'
| 'lineNumber'
| 'textHighlight'
| 'textSelectedHighlight'
| 'modifierBold'
| 'modifierItalic'
| 'modifierUnderline'
| 'modifierForegroundBlack'
| 'modifierForegroundRed'
| 'modifierForegroundGreen'
| 'modifierForegroundYellow'
| 'modifierForegroundBlue'
| 'modifierForegroundMagenta'
| 'modifierForegroundCyan'
| 'modifierForegroundWhite'
| 'modifierForegroundGrey'
| 'modifierBackgroundBlack'
| 'modifierBackgroundRed'
| 'modifierBackgroundGreen'
| 'modifierBackgroundYellow'
| 'modifierBackgroundBlue'
| 'modifierBackgroundMagenta'
| 'modifierBackgroundCyan'
| 'modifierBackgroundWhite'
| 'modifierBackgroundGrey';
export const useStyles = makeStyles(
theme => ({
root: {
background: theme.palette.background.paper,
},
header: {
height: HEADER_SIZE,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
},
log: {
fontFamily: '"Monaco", monospace',
fontSize: theme.typography.pxToRem(12),
},
line: {
position: 'relative',
whiteSpace: 'pre',
'&:hover': {
background: theme.palette.action.hover,
},
},
lineSelected: {
background: theme.palette.action.selected,
'&:hover': {
background: theme.palette.action.selected,
},
},
lineCopyButton: {
position: 'absolute',
paddingTop: 0,
paddingBottom: 0,
},
lineNumber: {
display: 'inline-block',
textAlign: 'end',
width: 60,
marginRight: theme.spacing(1),
cursor: 'pointer',
},
textHighlight: {
background: alpha(theme.palette.info.main, 0.15),
},
textSelectedHighlight: {
background: alpha(theme.palette.info.main, 0.4),
},
modifierBold: {
fontWeight: theme.typography.fontWeightBold,
},
modifierItalic: {
fontStyle: 'italic',
},
modifierUnderline: {
textDecoration: 'underline',
},
modifierForegroundBlack: {
color: colors.common.black,
},
modifierForegroundRed: {
color: colors.red[500],
},
modifierForegroundGreen: {
color: colors.green[500],
},
modifierForegroundYellow: {
color: colors.yellow[500],
},
modifierForegroundBlue: {
color: colors.blue[500],
},
modifierForegroundMagenta: {
color: colors.purple[500],
},
modifierForegroundCyan: {
color: colors.cyan[500],
},
modifierForegroundWhite: {
color: colors.common.white,
},
modifierForegroundGrey: {
color: colors.grey[500],
},
modifierBackgroundBlack: {
background: colors.common.black,
},
modifierBackgroundRed: {
background: colors.red[500],
},
modifierBackgroundGreen: {
background: colors.green[500],
},
modifierBackgroundYellow: {
background: colors.yellow[500],
},
modifierBackgroundBlue: {
background: colors.blue[500],
},
modifierBackgroundMagenta: {
background: colors.purple[500],
},
modifierBackgroundCyan: {
background: colors.cyan[500],
},
modifierBackgroundWhite: {
background: colors.common.white,
},
modifierBackgroundGrey: {
background: colors.grey[500],
},
}),
{ name: 'BackstageLogViewer' },
);
@@ -0,0 +1,221 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { applySearchFilter, useLogViewerSearch } from './useLogViewerSearch';
import { AnsiLine } from './AnsiProcessor';
const lines = [
new AnsiLine(1, [{ text: 'FooBar', modifiers: {} }]),
new AnsiLine(2, [{ text: 'Baz', modifiers: {} }]),
new AnsiLine(3, [{ text: 'FooBarFoo', modifiers: {} }]),
new AnsiLine(4, [{ text: 'Baz', modifiers: {} }]),
new AnsiLine(5, [{ text: 'BazFoo', modifiers: {} }]),
new AnsiLine(6, [{ text: 'FooFooFoo', modifiers: {} }]),
new AnsiLine(7, [{ text: '', modifiers: {} }]),
new AnsiLine(8, [{ text: 'Bar', modifiers: {} }]),
];
describe('applySearchFilter', () => {
it('should find search results', () => {
expect(applySearchFilter(lines, '')).toEqual({
lines: lines,
results: undefined,
});
expect(applySearchFilter(lines, 'foo')).toEqual({
lines: [lines[0], lines[2], lines[4], lines[5]],
results: [
{ lineNumber: 1, lineIndex: 0 },
{ lineNumber: 3, lineIndex: 0 },
{ lineNumber: 3, lineIndex: 1 },
{ lineNumber: 5, lineIndex: 0 },
{ lineNumber: 6, lineIndex: 0 },
{ lineNumber: 6, lineIndex: 1 },
{ lineNumber: 6, lineIndex: 2 },
],
});
expect(applySearchFilter(lines, 'bar')).toEqual({
lines: [lines[0], lines[2], lines[7]],
results: [
{ lineNumber: 1, lineIndex: 0 },
{ lineNumber: 3, lineIndex: 0 },
{ lineNumber: 8, lineIndex: 0 },
],
});
expect(applySearchFilter(lines, 'baz')).toEqual({
lines: [lines[1], lines[3], lines[4]],
results: [
{ lineNumber: 2, lineIndex: 0 },
{ lineNumber: 4, lineIndex: 0 },
{ lineNumber: 5, lineIndex: 0 },
],
});
});
});
describe('useLogViewerSearch', () => {
it('should provide search state', () => {
const rendered = renderHook(() => useLogViewerSearch(lines));
expect(rendered.result.current).toMatchObject({
lines,
searchText: '',
shouldFilter: false,
resultCount: undefined,
resultIndex: 0,
resultLine: undefined,
resultLineIndex: undefined,
});
rendered.result.current.resultIndexStep();
expect(rendered.result.current).toMatchObject({
lines,
searchText: '',
shouldFilter: false,
resultCount: undefined,
resultIndex: 0,
resultLine: undefined,
resultLineIndex: undefined,
});
act(() => rendered.result.current.toggleShouldFilter());
expect(rendered.result.current).toMatchObject({
lines,
searchText: '',
shouldFilter: true,
resultCount: undefined,
resultIndex: 0,
resultLine: undefined,
resultLineIndex: undefined,
});
act(() => rendered.result.current.setSearchInput('BAR'));
expect(rendered.result.current).toMatchObject({
lines: [lines[0], lines[2], lines[7]],
searchInput: 'BAR',
searchText: 'bar',
shouldFilter: true,
resultCount: 3,
resultIndex: 0,
resultLine: 1,
resultLineIndex: 0,
});
act(() => rendered.result.current.resultIndexStep());
expect(rendered.result.current).toMatchObject({
lines: [lines[0], lines[2], lines[7]],
resultIndex: 1,
resultLine: 3,
resultLineIndex: 0,
});
act(() => rendered.result.current.resultIndexStep());
expect(rendered.result.current).toMatchObject({
lines: [lines[0], lines[2], lines[7]],
resultIndex: 2,
resultLine: 8,
resultLineIndex: 0,
});
act(() => rendered.result.current.resultIndexStep());
expect(rendered.result.current).toMatchObject({
lines: [lines[0], lines[2], lines[7]],
resultIndex: 0,
resultLine: 1,
resultLineIndex: 0,
});
act(() => rendered.result.current.resultIndexStep(true));
expect(rendered.result.current).toMatchObject({
lines: [lines[0], lines[2], lines[7]],
resultIndex: 2,
resultLine: 8,
resultLineIndex: 0,
});
act(() => rendered.result.current.setSearchInput('FOO'));
expect(rendered.result.current).toMatchObject({
lines: [lines[0], lines[2], lines[4], lines[5]],
searchInput: 'FOO',
searchText: 'foo',
shouldFilter: true,
resultCount: 7,
resultIndex: 2,
resultLine: 3,
resultLineIndex: 1,
});
act(() => rendered.result.current.toggleShouldFilter());
expect(rendered.result.current).toMatchObject({
lines,
shouldFilter: false,
resultCount: 7,
resultIndex: 2,
resultLine: 3,
resultLineIndex: 1,
});
act(() => rendered.result.current.resultIndexStep());
expect(rendered.result.current).toMatchObject({
lines,
searchInput: 'FOO',
searchText: 'foo',
shouldFilter: false,
resultCount: 7,
resultIndex: 3,
resultLine: 5,
resultLineIndex: 0,
});
act(() => rendered.result.current.resultIndexStep());
expect(rendered.result.current).toMatchObject({
resultIndex: 4,
resultLine: 6,
resultLineIndex: 0,
});
act(() => rendered.result.current.resultIndexStep());
expect(rendered.result.current).toMatchObject({
resultIndex: 5,
resultLine: 6,
resultLineIndex: 1,
});
act(() => rendered.result.current.resultIndexStep());
expect(rendered.result.current).toMatchObject({
resultIndex: 6,
resultLine: 6,
resultLineIndex: 2,
});
act(() => rendered.result.current.setSearchInput('BAR'));
expect(rendered.result.current).toMatchObject({
searchText: 'bar',
resultCount: 3,
resultIndex: 6,
resultLine: 8,
resultLineIndex: 0,
});
act(() => rendered.result.current.resultIndexStep(true));
expect(rendered.result.current).toMatchObject({
searchText: 'bar',
resultCount: 3,
resultIndex: 1,
resultLine: 3,
resultLineIndex: 0,
});
});
});
@@ -0,0 +1,117 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useMemo, useState } from 'react';
import { useToggle } from 'react-use';
import { AnsiLine } from './AnsiProcessor';
export function applySearchFilter(lines: AnsiLine[], searchText: string) {
if (!searchText) {
return { lines };
}
const matchingLines = [];
const searchResults = [];
for (const line of lines) {
if (line.text.includes(searchText)) {
matchingLines.push(line);
let offset = 0;
let lineResultIndex = 0;
for (;;) {
const start = line.text.indexOf(searchText, offset);
if (start === -1) {
break;
}
searchResults.push({
lineNumber: line.lineNumber,
lineIndex: lineResultIndex++,
});
offset = start + searchText.length;
}
}
}
return {
lines: matchingLines,
results: searchResults,
};
}
export interface LogViewerSearch {
lines: AnsiLine[];
searchText: string;
searchInput: string;
setSearchInput: (searchInput: string) => void;
shouldFilter: boolean;
toggleShouldFilter: () => void;
resultCount: number | undefined;
resultIndex: number | undefined;
resultIndexStep: (decrement?: boolean) => void;
resultLine: number | undefined;
resultLineIndex: number | undefined;
}
export function useLogViewerSearch(lines: AnsiLine[]): LogViewerSearch {
const [searchInput, setSearchInput] = useState('');
const searchText = searchInput.toLocaleLowerCase('en-US');
const [resultIndex, setResultIndex] = useState<number>(0);
const [shouldFilter, toggleShouldFilter] = useToggle(false);
const filter = useMemo(
() => applySearchFilter(lines, searchText),
[lines, searchText],
);
const searchResult = filter.results
? filter.results[Math.min(resultIndex, filter.results.length - 1)]
: undefined;
const resultCount = filter.results?.length;
const resultIndexStep = (decrement?: boolean) => {
if (decrement) {
if (resultCount !== undefined) {
const next = Math.min(resultIndex - 1, resultCount - 2);
setResultIndex(next < 0 ? resultCount - 1 : next);
}
} else {
if (resultCount !== undefined) {
const next = resultIndex + 1;
setResultIndex(next >= resultCount ? 0 : next);
}
}
};
return {
lines: shouldFilter ? filter.lines : lines,
searchText,
searchInput,
setSearchInput,
shouldFilter,
toggleShouldFilter,
resultCount,
resultIndex,
resultIndexStep,
resultLine: searchResult?.lineNumber,
resultLineIndex: searchResult?.lineIndex,
};
}
@@ -0,0 +1,123 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { act, renderHook } from '@testing-library/react-hooks';
import { TestApiProvider, MockErrorApi } from '@backstage/test-utils';
import { errorApiRef } from '@backstage/core-plugin-api';
import { AnsiLine } from './AnsiProcessor';
import { useLogViewerSelection } from './useLogViewerSelection';
// eslint-disable-next-line import/no-extraneous-dependencies
import copyToClipboard from 'copy-to-clipboard';
// Used by useCopyToClipboard
jest.mock('copy-to-clipboard', () => ({
__esModule: true,
default: jest.fn(),
}));
const lines = [
new AnsiLine(1, [{ text: '1', modifiers: {} }]),
new AnsiLine(2, [{ text: '2', modifiers: {} }]),
new AnsiLine(3, [{ text: '3', modifiers: {} }]),
new AnsiLine(4, [{ text: '4', modifiers: {} }]),
new AnsiLine(5, [{ text: '5', modifiers: {} }]),
];
describe('useLogViewerSelection', () => {
it('should manage a selection', () => {
const rendered = renderHook(() => useLogViewerSelection(lines), {
wrapper: ({ children }) => (
<TestApiProvider apis={[[errorApiRef, new MockErrorApi()]]}>
{children}
</TestApiProvider>
),
});
expect(rendered.result.current.isSelected(1)).toBe(false);
expect(rendered.result.current.isSelected(2)).toBe(false);
expect(rendered.result.current.isSelected(3)).toBe(false);
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
expect(rendered.result.current.shouldShowButton(3)).toBe(false);
act(() => rendered.result.current.setSelection(2, false));
expect(rendered.result.current.isSelected(1)).toBe(false);
expect(rendered.result.current.isSelected(2)).toBe(true);
expect(rendered.result.current.isSelected(3)).toBe(false);
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
expect(rendered.result.current.shouldShowButton(2)).toBe(true);
expect(rendered.result.current.shouldShowButton(3)).toBe(false);
act(() => rendered.result.current.setSelection(3, false));
expect(rendered.result.current.isSelected(1)).toBe(false);
expect(rendered.result.current.isSelected(2)).toBe(false);
expect(rendered.result.current.isSelected(3)).toBe(true);
expect(rendered.result.current.isSelected(4)).toBe(false);
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
expect(rendered.result.current.shouldShowButton(4)).toBe(false);
act(() => rendered.result.current.setSelection(1, true));
expect(rendered.result.current.isSelected(1)).toBe(true);
expect(rendered.result.current.isSelected(2)).toBe(true);
expect(rendered.result.current.isSelected(3)).toBe(true);
expect(rendered.result.current.isSelected(4)).toBe(false);
expect(rendered.result.current.shouldShowButton(1)).toBe(true);
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
expect(rendered.result.current.shouldShowButton(4)).toBe(false);
act(() => rendered.result.current.setSelection(4, true));
expect(rendered.result.current.isSelected(1)).toBe(false);
expect(rendered.result.current.isSelected(2)).toBe(false);
expect(rendered.result.current.isSelected(3)).toBe(true);
expect(rendered.result.current.isSelected(4)).toBe(true);
expect(rendered.result.current.isSelected(5)).toBe(false);
expect(rendered.result.current.shouldShowButton(1)).toBe(false);
expect(rendered.result.current.shouldShowButton(2)).toBe(false);
expect(rendered.result.current.shouldShowButton(3)).toBe(true);
expect(rendered.result.current.shouldShowButton(4)).toBe(true);
expect(rendered.result.current.shouldShowButton(5)).toBe(false);
expect(copyToClipboard).not.toHaveBeenCalled();
act(() => rendered.result.current.copySelection());
expect(copyToClipboard).toHaveBeenLastCalledWith('3\n4');
act(() => rendered.result.current.setSelection(2, true));
act(() => rendered.result.current.setSelection(4, true));
act(() => rendered.result.current.copySelection());
expect(copyToClipboard).toHaveBeenCalledWith('2\n3\n4');
act(() => rendered.result.current.setSelection(2, false));
act(() => rendered.result.current.setSelection(4, false));
act(() => rendered.result.current.setSelection(4, false));
act(() => rendered.result.current.setSelection(5, true));
act(() => rendered.result.current.copySelection());
expect(copyToClipboard).toHaveBeenCalledWith('5');
});
});
@@ -0,0 +1,70 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { AnsiLine } from './AnsiProcessor';
export function useLogViewerSelection(lines: AnsiLine[]) {
const errorApi = useApi(errorApiRef);
const [sel, setSelection] = useState<{ start: number; end: number }>();
const start = sel ? Math.min(sel.start, sel.end) : undefined;
const end = sel ? Math.max(sel.start, sel.end) : undefined;
const [{ error }, copyToClipboard] = useCopyToClipboard();
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [error, errorApi]);
return {
shouldShowButton(line: number) {
return start === line || end === line;
},
isSelected(line: number) {
if (!sel) {
return false;
}
return start! <= line && line <= end!;
},
setSelection(line: number, add: boolean) {
if (add) {
setSelection(s =>
s ? { start: s.start, end: line } : { start: line, end: line },
);
} else {
setSelection(s =>
s?.start === line && s?.end === line
? undefined
: { start: line, end: line },
);
}
},
copySelection() {
if (sel) {
const copyText = lines
.slice(Math.min(sel.start, sel.end) - 1, Math.max(sel.start, sel.end))
.map(l => l.chunks.map(c => c.text).join(''))
.join('\n');
copyToClipboard(copyText);
setSelection(undefined);
}
},
};
}
@@ -16,7 +16,7 @@
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { act } from 'react-dom/test-utils';
import { act } from '@testing-library/react';
import { Progress } from './Progress';
@@ -14,9 +14,8 @@
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import { act, fireEvent } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route, Routes } from 'react-router';
import { RoutedTabs } from './RoutedTabs';
@@ -14,9 +14,8 @@
* limitations under the License.
*/
import { renderInTestApp, withLogCollector } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import { act, fireEvent } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route, Routes } from 'react-router';
import { TabbedLayout } from './TabbedLayout';
@@ -30,6 +30,7 @@ export * from './HeaderIconLinkRow';
export * from './HorizontalScrollGrid';
export * from './Lifecycle';
export * from './Link';
export * from './LogViewer';
export * from './MarkdownContent';
export * from './OAuthRequestDialog';
export * from './OverflowTooltip';
@@ -16,7 +16,7 @@
import { makeStyles } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import clsx from 'clsx';
import classnames from 'classnames';
import React, { useState, useContext, PropsWithChildren, useRef } from 'react';
import { sidebarConfig, SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
@@ -163,7 +163,7 @@ const DesktopSidebar = ({
onMouseLeave={handleClose}
onBlur={handleClose}
data-testid="sidebar-root"
className={clsx(classes.drawer, {
className={classnames(classes.drawer, {
[classes.drawerOpen]: isOpen,
})}
>
@@ -24,7 +24,7 @@ import Typography from '@material-ui/core/Typography';
import { CreateCSSProperties } from '@material-ui/core/styles/withStyles';
import ArrowRightIcon from '@material-ui/icons/ArrowRight';
import SearchIcon from '@material-ui/icons/Search';
import clsx from 'clsx';
import classnames from 'classnames';
import React, {
Children,
forwardRef,
@@ -343,10 +343,11 @@ const SidebarItemWithSubmenu = ({
onMouseLeave={handleMouseLeave}
onTouchStart={isHoveredOn ? handleMouseLeave : handleMouseEnter}
onMouseEnter={handleMouseEnter}
className={classnames(isHoveredOn && classes.highlighted)}
>
<div
data-testid="item-with-submenu"
className={clsx(
className={classnames(
classes.root,
isOpen ? classes.open : classes.closed,
isActive && classes.selected,
@@ -446,7 +447,10 @@ export const WorkaroundNavLink = React.forwardRef<
ref={ref}
aria-current={ariaCurrent}
style={{ ...style, ...(isActive ? activeStyle : undefined) }}
className={clsx([className, isActive ? activeClassName : undefined])}
className={classnames([
className,
isActive ? activeClassName : undefined,
])}
/>
);
});
@@ -474,7 +478,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
variant="dot"
overlap="circular"
invisible={!hasNotifications}
className={clsx({ [classes.closedItemIcon]: !isOpen })}
className={classnames({ [classes.closedItemIcon]: !isOpen })}
>
<Icon fontSize="small" />
</Badge>
@@ -500,7 +504,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
const childProps = {
onClick,
className: clsx(
className: classnames(
className,
classes.root,
isOpen ? classes.open : classes.closed,
@@ -15,8 +15,8 @@
*/
import { makeStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import clsx from 'clsx';
import React, { PropsWithChildren, ReactNode, useContext } from 'react';
import classnames from 'classnames';
import React, { ReactNode, useContext } from 'react';
import {
SidebarItemWithSubmenuContext,
sidebarConfig,
@@ -97,28 +97,24 @@ export type SidebarSubmenuProps = {
*
* @public
*/
export const SidebarSubmenu = ({
title,
children,
}: PropsWithChildren<SidebarSubmenuProps>) => {
export const SidebarSubmenu = (props: SidebarSubmenuProps) => {
const { isOpen } = useContext(SidebarContext);
const left = isOpen
? sidebarConfig.drawerWidthOpen
: sidebarConfig.drawerWidthClosed;
const props = { left };
const classes = useStyles(props)();
const classes = useStyles({ left })();
const { isHoveredOn } = useContext(SidebarItemWithSubmenuContext);
return (
<div
className={clsx(classes.drawer, {
className={classnames(classes.drawer, {
[classes.drawerOpen]: isHoveredOn,
})}
>
<Typography variant="h5" className={classes.title}>
{title}
{props.title}
</Typography>
{children}
{props.children}
</div>
);
};
@@ -24,7 +24,7 @@ import { makeStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Link from '@material-ui/core/Link';
import { IconComponent } from '@backstage/core-plugin-api';
import clsx from 'clsx';
import classnames from 'classnames';
import { BackstageTheme } from '@backstage/theme';
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
import ArrowDropUpIcon from '@material-ui/icons/ArrowDropUp';
@@ -149,7 +149,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
<button
onClick={handleClickDropdown}
onTouchStart={e => e.stopPropagation()}
className={clsx(
className={classnames(
classes.item,
isActive ? classes.selected : undefined,
)}
@@ -193,7 +193,10 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
component={NavLink}
to={to}
underline="none"
className={clsx(classes.item, isActive ? classes.selected : undefined)}
className={classnames(
classes.item,
isActive ? classes.selected : undefined,
)}
onClick={closeSubmenu}
onTouchStart={e => e.stopPropagation()}
>
@@ -0,0 +1,60 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IdentityApi,
ProfileInfo,
BackstageUserIdentity,
} from '@backstage/core-plugin-api';
export class GuestUserIdentity implements IdentityApi {
getUserId(): string {
return 'guest';
}
async getIdToken(): Promise<string | undefined> {
return undefined;
}
getProfile(): ProfileInfo {
return {
email: 'guest@example.com',
displayName: 'Guest',
};
}
async getProfileInfo(): Promise<ProfileInfo> {
return {
email: 'guest@example.com',
displayName: 'Guest',
};
}
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
const userEntityRef = `user:default/guest`;
return {
type: 'user',
userEntityRef,
ownershipEntityRefs: [userEntityRef],
};
}
async getCredentials(): Promise<{ token?: string | undefined }> {
return {};
}
async signOut(): Promise<void> {}
}
@@ -0,0 +1,65 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
BackstageUserIdentity,
IdentityApi,
ProfileInfo,
} from '@backstage/core-plugin-api';
export class IdentityApiSignOutProxy implements IdentityApi {
private constructor(
private readonly config: {
identityApi: IdentityApi;
signOut: IdentityApi['signOut'];
},
) {}
static from(config: {
identityApi: IdentityApi;
signOut: IdentityApi['signOut'];
}): IdentityApi {
return new IdentityApiSignOutProxy(config);
}
getUserId(): string {
return this.config.identityApi.getUserId();
}
getIdToken(): Promise<string | undefined> {
return this.config.identityApi.getIdToken();
}
getProfile(): ProfileInfo {
return this.config.identityApi.getProfile();
}
getProfileInfo(): Promise<ProfileInfo> {
return this.config.identityApi.getProfileInfo();
}
getBackstageIdentity(): Promise<BackstageUserIdentity> {
return this.config.identityApi.getBackstageIdentity();
}
getCredentials(): Promise<{ token?: string | undefined }> {
return this.config.identityApi.getCredentials();
}
signOut(): Promise<void> {
return this.config.signOut();
}
}
@@ -0,0 +1,80 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IdentityApi,
ProfileInfo,
BackstageUserIdentity,
SignInResult,
} from '@backstage/core-plugin-api';
function parseJwtPayload(token: string) {
const [_header, payload, _signature] = token.split('.');
return JSON.parse(atob(payload));
}
export class LegacyUserIdentity implements IdentityApi {
private constructor(private readonly result: SignInResult) {}
getUserId(): string {
return this.result.userId;
}
static fromResult(result: SignInResult): LegacyUserIdentity {
return new LegacyUserIdentity(result);
}
async getIdToken(): Promise<string | undefined> {
return this.result.getIdToken?.();
}
getProfile(): ProfileInfo {
return this.result.profile;
}
async getProfileInfo(): Promise<ProfileInfo> {
return this.result.profile;
}
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
const token = await this.getIdToken();
if (!token) {
const userEntityRef = `user:default/${this.getUserId()}`;
return {
type: 'user',
userEntityRef,
ownershipEntityRefs: [userEntityRef],
};
}
const { sub, ent } = parseJwtPayload(token);
return {
type: 'user',
userEntityRef: sub,
ownershipEntityRefs: ent ?? [],
};
}
async getCredentials(): Promise<{ token?: string | undefined }> {
const token = await this.result.getIdToken?.();
return { token };
}
async signOut(): Promise<void> {
return this.result.signOut?.();
}
}
@@ -15,11 +15,12 @@
*/
import {
BackstageIdentity,
BackstageIdentityResponse,
configApiRef,
SignInPageProps,
useApi,
} from '@backstage/core-plugin-api';
import { UserIdentity } from './UserIdentity';
import Button from '@material-ui/core/Button';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
@@ -49,7 +50,7 @@ type SingleSignInPageProps = SignInPageProps & {
export type Props = MultiSignInPageProps | SingleSignInPageProps;
export const MultiSignInPage = ({
onResult,
onSignInSuccess,
providers = [],
title,
align = 'left',
@@ -60,7 +61,7 @@ export const MultiSignInPage = ({
const signInProviders = getSignInProviders(providers);
const [loading, providerElements] = useSignInProviders(
signInProviders,
onResult,
onSignInSuccess,
);
if (loading) {
@@ -87,9 +88,9 @@ export const MultiSignInPage = ({
};
export const SingleSignInPage = ({
onResult,
provider,
auto,
onSignInSuccess,
}: SingleSignInPageProps) => {
const classes = useStyles();
const authApi = useApi(provider.apiRef);
@@ -105,47 +106,42 @@ export const SingleSignInPage = ({
type LoginOpts = { checkExisting?: boolean; showPopup?: boolean };
const login = async ({ checkExisting, showPopup }: LoginOpts) => {
try {
let identity: BackstageIdentity | undefined;
let identityResponse: BackstageIdentityResponse | undefined;
if (checkExisting) {
// Do an initial check if any logged-in session exists
identity = await authApi.getBackstageIdentity({
identityResponse = await authApi.getBackstageIdentity({
optional: true,
});
}
// If no session exists, show the sign-in page
if (!identity && (showPopup || auto)) {
if (!identityResponse && (showPopup || auto)) {
// Unless auto is set to true, this step should not happen.
// When user intentionally clicks the Sign In button, autoShowPopup is set to true
setShowLoginPage(true);
identity = await authApi.getBackstageIdentity({
identityResponse = await authApi.getBackstageIdentity({
instantPopup: true,
});
if (!identity) {
if (!identityResponse) {
throw new Error(
`The ${provider.title} provider is not configured to support sign-in`,
);
}
}
if (!identity) {
if (!identityResponse) {
setShowLoginPage(true);
return;
}
const profile = await authApi.getProfile();
onResult({
userId: identity!.id,
profile: profile!,
getIdToken: () => {
return authApi
.getBackstageIdentity()
.then(i => i!.token ?? i!.idToken);
},
signOut: async () => {
await authApi.signOut();
},
});
onSignInSuccess(
UserIdentity.create({
identity: identityResponse.identity,
authApi,
profile,
}),
);
} catch (err: any) {
// User closed the sign-in modal
setError(err);
@@ -0,0 +1,83 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BackstageUserIdentity, ProfileInfo } from '@backstage/core-plugin-api';
import { UserIdentity } from './UserIdentity';
describe('UserIdentity', () => {
it('should cache a successful response from the AuthApi for getProfile', async () => {
const mockIdentity: BackstageUserIdentity = {
type: 'user',
userEntityRef: 'user:default/blam',
ownershipEntityRefs: [],
};
const mockProfileInfo: ProfileInfo = {
displayName: 'Blam',
email: 'blob@boop.com',
};
const mockAuthApi: any = {
getProfile: jest.fn().mockResolvedValue(mockProfileInfo),
};
const userIdentity = UserIdentity.create({
authApi: mockAuthApi,
identity: mockIdentity,
});
await userIdentity.getProfileInfo();
await userIdentity.getProfileInfo();
const response = await userIdentity.getProfileInfo();
expect(mockAuthApi.getProfile).toHaveBeenCalledTimes(1);
expect(response).toEqual(mockProfileInfo);
});
it('should not cache failures for the AuthApi for getProfile', async () => {
const mockIdentity: BackstageUserIdentity = {
type: 'user',
userEntityRef: 'user:default/blam',
ownershipEntityRefs: [],
};
const mockProfileInfo: ProfileInfo = {
displayName: 'Blam',
email: 'blob@boop.com',
};
const mockAuthApi: any = {
getProfile: jest
.fn()
.mockRejectedValueOnce(new Error('boop'))
.mockResolvedValueOnce(mockProfileInfo),
};
const userIdentity = UserIdentity.create({
authApi: mockAuthApi,
identity: mockIdentity,
});
await expect(() => userIdentity.getProfileInfo()).rejects.toThrow('boop');
const response = await userIdentity.getProfileInfo();
expect(mockAuthApi.getProfile).toHaveBeenCalledTimes(2);
expect(response).toEqual(mockProfileInfo);
});
});
@@ -0,0 +1,142 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IdentityApi,
ProfileInfo,
ProfileInfoApi,
BackstageUserIdentity,
BackstageIdentityApi,
SessionApi,
SignInResult,
} from '@backstage/core-plugin-api';
import { GuestUserIdentity } from './GuestUserIdentity';
import { LegacyUserIdentity } from './LegacyUserIdentity';
/**
* An implementation of the IdentityApi that is constructed using
* various backstage user identity representations.
*
* @public
*/
export class UserIdentity implements IdentityApi {
private profilePromise?: Promise<ProfileInfo>;
/**
* Creates a new IdentityApi that acts as a Guest User.
*
* @public
*/
static createGuest(): IdentityApi {
return new GuestUserIdentity();
}
/**
* Creates a new IdentityApi using a legacy SignInResult object.
*
* @public
*/
static fromLegacy(result: SignInResult): IdentityApi {
return LegacyUserIdentity.fromResult(result);
}
/**
* Creates a new IdentityApi implementation using a user identity
* and an auth API that will be used to request backstage tokens.
*
* @public
*/
static create(options: {
identity: BackstageUserIdentity;
authApi: ProfileInfoApi & BackstageIdentityApi & SessionApi;
/**
* Passing a profile synchronously allows the deprecated `getProfile` method to be
* called by consumers of the {@link @backstage/core-plugin-api#IdentityApi}. If you
* do not have any consumers of that method then this is safe to leave out.
*
* @deprecated Only provide this if you have plugins that call the synchronous `getProfile` method, which is also deprecated.
*/
profile?: ProfileInfo;
}): IdentityApi {
return new UserIdentity(options.identity, options.authApi, options.profile);
}
private constructor(
private readonly identity: BackstageUserIdentity,
private readonly authApi: ProfileInfoApi &
BackstageIdentityApi &
SessionApi,
private readonly profile?: ProfileInfo,
) {}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getUserId} */
getUserId(): string {
const ref = this.identity.userEntityRef;
const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref);
if (!match) {
throw new TypeError(`Invalid user entity reference "${ref}"`);
}
return match[3];
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getIdToken} */
async getIdToken(): Promise<string | undefined> {
const identity = await this.authApi.getBackstageIdentity();
return identity!.token;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfile} */
getProfile(): ProfileInfo {
if (!this.profile) {
throw new Error(
'The identity API does not implement synchronous profile fetching, use getProfileInfo() instead',
);
}
return this.profile;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getProfileInfo} */
async getProfileInfo(): Promise<ProfileInfo> {
if (this.profilePromise) {
return await this.profilePromise;
}
try {
this.profilePromise = this.authApi.getProfile() as Promise<ProfileInfo>;
return await this.profilePromise;
} catch (ex) {
this.profilePromise = undefined;
throw ex;
}
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getBackstageIdentity} */
async getBackstageIdentity(): Promise<BackstageUserIdentity> {
return this.identity;
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.getCredentials} */
async getCredentials(): Promise<{ token?: string | undefined }> {
const identity = await this.authApi.getBackstageIdentity();
return { token: identity!.token };
}
/** {@inheritdoc @backstage/core-plugin-api#IdentityApi.signOut} */
async signOut(): Promise<void> {
return this.authApi.signOut();
}
}
@@ -26,17 +26,18 @@ import {
errorApiRef,
} from '@backstage/core-plugin-api';
import { ForwardedError } from '@backstage/errors';
import { UserIdentity } from './UserIdentity';
const Component: ProviderComponent = ({ onResult }) => {
const Component: ProviderComponent = ({ onSignInSuccess }) => {
const auth0AuthApi = useApi(auth0AuthApiRef);
const errorApi = useApi(errorApiRef);
const handleLogin = async () => {
try {
const identity = await auth0AuthApi.getBackstageIdentity({
const identityResponse = await auth0AuthApi.getBackstageIdentity({
instantPopup: true,
});
if (!identity) {
if (!identityResponse) {
throw new Error(
'The Auth0 provider is not configured to support sign-in',
);
@@ -44,15 +45,13 @@ const Component: ProviderComponent = ({ onResult }) => {
const profile = await auth0AuthApi.getProfile();
onResult({
userId: identity!.id,
profile: profile!,
getIdToken: () =>
auth0AuthApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken),
signOut: async () => {
await auth0AuthApi.signOut();
},
});
onSignInSuccess(
UserIdentity.create({
identity: identityResponse.identity,
authApi: auth0AuthApi,
profile,
}),
);
} catch (error) {
errorApi.post(new ForwardedError('Auth0 login failed', error));
}
@@ -77,25 +76,20 @@ const Component: ProviderComponent = ({ onResult }) => {
const loader: ProviderLoader = async apis => {
const auth0AuthApi = apis.get(auth0AuthApiRef)!;
const identity = await auth0AuthApi.getBackstageIdentity({
const identityResponse = await auth0AuthApi.getBackstageIdentity({
optional: true,
});
if (!identity) {
if (!identityResponse) {
return undefined;
}
const profile = await auth0AuthApi.getProfile();
return {
userId: identity.id,
profile: profile!,
getIdToken: () =>
auth0AuthApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken),
signOut: async () => {
await auth0AuthApi.signOut();
},
};
return UserIdentity.create({
identity: identityResponse.identity,
authApi: auth0AuthApi,
profile,
});
};
export const auth0Provider: SignInProvider = { Component, loader };
@@ -27,36 +27,33 @@ import {
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
import { GridItem } from './styles';
import { ForwardedError } from '@backstage/errors';
import { UserIdentity } from './UserIdentity';
const Component: ProviderComponent = ({ config, onResult }) => {
const Component: ProviderComponent = ({ config, onSignInSuccess }) => {
const { apiRef, title, message } = config as SignInProviderConfig;
const authApi = useApi(apiRef);
const errorApi = useApi(errorApiRef);
const handleLogin = async () => {
try {
const identity = await authApi.getBackstageIdentity({
const identityResponse = await authApi.getBackstageIdentity({
instantPopup: true,
});
if (!identity) {
if (!identityResponse) {
throw new Error(
`The ${title} provider is not configured to support sign-in`,
);
}
const profile = await authApi.getProfile();
onResult({
userId: identity!.id,
profile: profile!,
getIdToken: () => {
return authApi
.getBackstageIdentity()
.then(i => i!.token ?? i!.idToken);
},
signOut: async () => {
await authApi.signOut();
},
});
onSignInSuccess(
UserIdentity.create({
identity: identityResponse.identity,
profile,
authApi,
}),
);
} catch (error) {
errorApi.post(new ForwardedError('Login failed', error));
}
@@ -82,25 +79,21 @@ const Component: ProviderComponent = ({ config, onResult }) => {
const loader: ProviderLoader = async (apis, apiRef) => {
const authApi = apis.get(apiRef)!;
const identity = await authApi.getBackstageIdentity({
const identityResponse = await authApi.getBackstageIdentity({
optional: true,
});
if (!identity) {
if (!identityResponse) {
return undefined;
}
const profile = await authApi.getProfile();
return {
userId: identity.id,
profile: profile!,
getIdToken: () =>
authApi.getBackstageIdentity().then(i => i!.token ?? i!.idToken),
signOut: async () => {
await authApi.signOut();
},
};
return UserIdentity.create({
identity: identityResponse.identity,
profile,
authApi,
});
};
export const commonProvider: SignInProvider = { Component, loader };

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