Merge branch 'master' into scaffolder-improvement
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
"@backstage/backend-common": "^0.9.7",
|
||||
"@backstage/config-loader": "^0.7.0",
|
||||
"@backstage/config": "^0.1.8",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
@@ -42,6 +43,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.8.0",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"msw": "^0.29.0",
|
||||
"supertest": "^6.1.3"
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { Logger } from 'winston';
|
||||
import { AppConfig, Config, JsonObject } from '@backstage/config';
|
||||
import { AppConfig, Config } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { loadConfigSchema, readEnvConfig } from '@backstage/config-loader';
|
||||
|
||||
type InjectOptions = {
|
||||
|
||||
@@ -81,7 +81,10 @@ export type RepoBuild = {
|
||||
status?: BuildStatus;
|
||||
result?: BuildResult;
|
||||
queueTime?: Date;
|
||||
startTime?: Date;
|
||||
finishTime?: Date;
|
||||
source: string;
|
||||
uniqueName?: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
|
||||
@@ -13,19 +13,20 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { mappedPullRequest, mappedRepoBuild } from './AzureDevOpsApi';
|
||||
import { PullRequest, RepoBuild } from './types';
|
||||
|
||||
import {
|
||||
Build,
|
||||
BuildResult,
|
||||
BuildStatus,
|
||||
DefinitionReference,
|
||||
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
import {
|
||||
GitPullRequest,
|
||||
GitRepository,
|
||||
PullRequest,
|
||||
PullRequestStatus,
|
||||
} from 'azure-devops-node-api/interfaces/GitInterfaces';
|
||||
import { GitRepository } from 'azure-devops-node-api/interfaces/TfvcInterfaces';
|
||||
RepoBuild,
|
||||
} from './types';
|
||||
import { mappedPullRequest, mappedRepoBuild } from './AzureDevOpsApi';
|
||||
|
||||
import { DefinitionReference } from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces';
|
||||
|
||||
describe('AzureDevOpsApi', () => {
|
||||
@@ -41,16 +42,24 @@ describe('AzureDevOpsApi', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const inputIdentityRef: IdentityRef = {
|
||||
displayName: 'Jane Doe',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
const inputBuild: Build = {
|
||||
id: 1,
|
||||
buildNumber: 'Build-1',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
|
||||
definition: inputBuildDefinition,
|
||||
_links: inputLinks,
|
||||
requestedFor: inputIdentityRef,
|
||||
};
|
||||
|
||||
const outputRepoBuild: RepoBuild = {
|
||||
@@ -60,7 +69,10 @@ describe('AzureDevOpsApi', () => {
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
source: 'refs/heads/develop (f4f78b31)',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
|
||||
@@ -75,16 +87,24 @@ describe('AzureDevOpsApi', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const inputIdentityRef: IdentityRef = {
|
||||
displayName: 'Jane Doe',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
const inputBuild: Build = {
|
||||
id: 1,
|
||||
buildNumber: 'Build-1',
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
|
||||
definition: undefined,
|
||||
_links: inputLinks,
|
||||
requestedFor: inputIdentityRef,
|
||||
};
|
||||
|
||||
const outputRepoBuild: RepoBuild = {
|
||||
@@ -94,7 +114,10 @@ describe('AzureDevOpsApi', () => {
|
||||
status: BuildStatus.Completed,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
source: 'refs/heads/develop (f4f78b31)',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
|
||||
@@ -109,16 +132,24 @@ describe('AzureDevOpsApi', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const inputIdentityRef: IdentityRef = {
|
||||
displayName: 'Jane Doe',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
const inputBuild: Build = {
|
||||
id: 1,
|
||||
buildNumber: 'Build-1',
|
||||
status: undefined,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
|
||||
definition: undefined,
|
||||
_links: inputLinks,
|
||||
requestedFor: inputIdentityRef,
|
||||
};
|
||||
|
||||
const outputRepoBuild: RepoBuild = {
|
||||
@@ -128,7 +159,10 @@ describe('AzureDevOpsApi', () => {
|
||||
status: BuildStatus.None,
|
||||
result: BuildResult.Succeeded,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
source: 'refs/heads/develop (f4f78b31)',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
|
||||
@@ -143,16 +177,24 @@ describe('AzureDevOpsApi', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const inputIdentityRef: IdentityRef = {
|
||||
displayName: 'Jane Doe',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
const inputBuild: Build = {
|
||||
id: 1,
|
||||
buildNumber: 'Build-1',
|
||||
status: BuildStatus.InProgress,
|
||||
result: undefined,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
|
||||
definition: undefined,
|
||||
_links: inputLinks,
|
||||
requestedFor: inputIdentityRef,
|
||||
};
|
||||
|
||||
const outputRepoBuild: RepoBuild = {
|
||||
@@ -162,7 +204,10 @@ describe('AzureDevOpsApi', () => {
|
||||
status: BuildStatus.InProgress,
|
||||
result: BuildResult.None,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
source: 'refs/heads/develop (f4f78b31)',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
|
||||
@@ -171,16 +216,24 @@ describe('AzureDevOpsApi', () => {
|
||||
|
||||
describe('mappedRepoBuild with undefined link', () => {
|
||||
it('should return empty string for link', () => {
|
||||
const inputIdentityRef: IdentityRef = {
|
||||
displayName: 'Jane Doe',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
const inputBuild: Build = {
|
||||
id: 1,
|
||||
buildNumber: 'Build-1',
|
||||
status: BuildStatus.InProgress,
|
||||
result: undefined,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
sourceBranch: 'refs/heads/develop',
|
||||
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
|
||||
definition: undefined,
|
||||
_links: undefined,
|
||||
requestedFor: inputIdentityRef,
|
||||
};
|
||||
|
||||
const outputRepoBuild: RepoBuild = {
|
||||
@@ -190,7 +243,10 @@ describe('AzureDevOpsApi', () => {
|
||||
status: BuildStatus.InProgress,
|
||||
result: BuildResult.None,
|
||||
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
|
||||
startTime: new Date('2020-09-12T06:15:23.9325232Z'),
|
||||
finishTime: new Date('2020-09-12T06:20:23.9325232Z'),
|
||||
source: 'refs/heads/develop (f4f78b31)',
|
||||
uniqueName: 'DOMAINjdoe',
|
||||
};
|
||||
|
||||
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
|
||||
|
||||
@@ -111,7 +111,7 @@ export class AzureDevOpsApi {
|
||||
options: PullRequestOptions,
|
||||
): Promise<PullRequest[]> {
|
||||
this.logger?.debug(
|
||||
`Calling Azure DevOps REST API, getting up to ${top} Pull Requests for Repository ${repoName} for Project ${projectName}`,
|
||||
`Calling Azure DevOps REST API, getting up to ${options.top} Pull Requests for Repository ${repoName} for Project ${projectName}`,
|
||||
);
|
||||
|
||||
const gitRepository = await this.getGitRepository(projectName, repoName);
|
||||
@@ -144,11 +144,14 @@ export function mappedRepoBuild(build: Build): RepoBuild {
|
||||
title: [build.definition?.name, build.buildNumber]
|
||||
.filter(Boolean)
|
||||
.join(' - '),
|
||||
link: build._links?.web.href ? build._links?.web.href : '',
|
||||
status: build.status ? build.status : BuildStatus.None,
|
||||
result: build.result ? build.result : BuildResult.None,
|
||||
link: build._links?.web.href ?? '',
|
||||
status: build.status ?? BuildStatus.None,
|
||||
result: build.result ?? BuildResult.None,
|
||||
queueTime: build.queueTime,
|
||||
startTime: build.startTime,
|
||||
finishTime: build.finishTime,
|
||||
source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`,
|
||||
uniqueName: build.requestedFor?.uniqueName ?? 'N/A',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,8 +163,8 @@ export function mappedPullRequest(
|
||||
pullRequestId: pullRequest.pullRequestId,
|
||||
repoName: pullRequest.repository?.name,
|
||||
title: pullRequest.title,
|
||||
uniqueName: pullRequest.createdBy?.uniqueName,
|
||||
createdBy: pullRequest.createdBy?.displayName,
|
||||
uniqueName: pullRequest.createdBy?.uniqueName ?? 'N/A',
|
||||
createdBy: pullRequest.createdBy?.displayName ?? 'N/A',
|
||||
creationDate: pullRequest.creationDate,
|
||||
sourceRefName: pullRequest.sourceRefName,
|
||||
targetRefName: pullRequest.targetRefName,
|
||||
|
||||
@@ -41,7 +41,10 @@ export type RepoBuild = {
|
||||
status?: BuildStatus;
|
||||
result?: BuildResult;
|
||||
queueTime?: Date;
|
||||
startTime?: Date;
|
||||
finishTime?: Date;
|
||||
source: string;
|
||||
uniqueName?: string;
|
||||
};
|
||||
|
||||
export type PullRequest = {
|
||||
|
||||
@@ -2,74 +2,50 @@
|
||||
|
||||
Website: [https://dev.azure.com/](https://dev.azure.com/)
|
||||
|
||||

|
||||
## Features
|
||||
|
||||
### Azure Pipelines
|
||||
|
||||
Lists the top _n_ builds for a given repository where _n_ is a configurable value
|
||||
|
||||

|
||||
|
||||
## Setup
|
||||
|
||||
The following sections will help you get the Azure DevOps plugin setup and running
|
||||
|
||||
### Configuration
|
||||
### Azure DevOps Backend
|
||||
|
||||
The Azure DevOps plugin requires the following YAML to be added to your app-config.yaml:
|
||||
You need to setup the [Azure DevOps backend plugin](https://github.com/backstage/backstage/tree/master/plugins/azure-devops-backend) before you move forward with any of these steps if you haven't already
|
||||
|
||||
### Entity Annotation
|
||||
|
||||
To be able to use the Azure DevOps plugin you need to add the following annotation to any entities you want to use it with:
|
||||
|
||||
```yaml
|
||||
azureDevOps:
|
||||
host: dev.azure.com
|
||||
token: ${AZURE_TOKEN}
|
||||
organization: my-company
|
||||
dev.azure.com/project-repo: <project-name>/<repo-name>
|
||||
```
|
||||
|
||||
Configuration Details:
|
||||
Let's break this down a little: `<project-name>` will be the name of your Team Project and `<repo-name>` will be the name of your repository which needs to be part of the Team Project you entered for `<project-name>`.
|
||||
|
||||
- `host` and `token` can be the same as the ones used for the `integration` section
|
||||
- `AZURE_TOKEN` environment variable must be set to a [Personal Access Token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) with read access to both Code and Build
|
||||
- `organization` is your Azure DevOps Organization name or for Azure DevOps Server (on-premise) this will be your Collection name
|
||||
Here's what that will look like in action:
|
||||
|
||||
### Backend
|
||||
```yaml
|
||||
# Example catalog-info.yaml entity definition file
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
# ...
|
||||
annotations:
|
||||
dev.azure.com/project-repo: my-project/my-repo
|
||||
spec:
|
||||
type: service
|
||||
# ...
|
||||
```
|
||||
|
||||
Here's how to get the backend up and running:
|
||||
### Azure Pipelines Component
|
||||
|
||||
1. First we need to add the `@backstage/plugin-azure-devops-backend` package to your backend:
|
||||
|
||||
```sh
|
||||
# From the Backstage root directory
|
||||
cd packages/backend
|
||||
yarn add @backstage/plugin-azure-devops-backend
|
||||
```
|
||||
|
||||
2. Then we will create a new file named `packages/backend/src/plugins/azure-devops.ts`, and add the
|
||||
following to it:
|
||||
|
||||
```ts
|
||||
import { createRouter } from '@backstage/plugin-azure-devops-backend';
|
||||
import { Router } from 'express';
|
||||
import type { PluginEnvironment } from '../types';
|
||||
|
||||
export default function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
return createRouter({ logger, config });
|
||||
}
|
||||
```
|
||||
|
||||
3. Next we wire this into the overall backend router, edit `packages/backend/src/index.ts`:
|
||||
|
||||
```ts
|
||||
import azureDevOps from './plugins/azuredevops';
|
||||
// ...
|
||||
async function main() {
|
||||
// ...
|
||||
const azureDevOpsEnv = useHotMemoize(module, () => createEnv('azure-devops'));
|
||||
apiRouter.use('/azure-devops', await azureDevOps(azureDevOpsEnv));
|
||||
```
|
||||
|
||||
4. Now run `yarn start-backend` from the repo root
|
||||
5. Finally open `http://localhost:7000/api/azure-devops/health` in a browser and it should return `{"status":"ok"}`
|
||||
|
||||
### Frontend
|
||||
|
||||
To get the frontend working you'll need to do the following two steps:
|
||||
To get the Azure Pipelines component working you'll need to do the following two steps:
|
||||
|
||||
1. First we need to add the @backstage/plugin-azure-devops package to your frontend app:
|
||||
|
||||
@@ -101,35 +77,7 @@ To get the frontend working you'll need to do the following two steps:
|
||||
</EntitySwitch>
|
||||
```
|
||||
|
||||
### Entity Annotation
|
||||
|
||||
You need to add the following annotation to any entities you want to be able to use the Azure Devops plugin with:
|
||||
|
||||
```yaml
|
||||
dev.azure.com/project-repo: <project-name>/<repo-name>
|
||||
```
|
||||
|
||||
Let's break this down a little: `<project-name>` will be the name of your Team Project and `<repo-name>` will be the name of your repository which needs to be part of the Team Project you entered for `<project-name>`.
|
||||
|
||||
Here's what that will look like in action:
|
||||
|
||||
```yaml
|
||||
# Example catalog-info.yaml entity definition file
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
# ...
|
||||
annotations:
|
||||
dev.azure.com/project-repo: my-project/my-repo
|
||||
spec:
|
||||
type: service
|
||||
# ...
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Lists the top _n_ builds for a given repository where _n_ is the value configured for `top`
|
||||
|
||||
## Limitations
|
||||
|
||||
- Currently multiple organizations is not supported
|
||||
- Currently multiple organizations are not supported
|
||||
- Mixing Azure DevOps Services (cloud) and Azure DevOps Server (on-premise) is not supported
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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 {
|
||||
BuildResult,
|
||||
BuildStatus,
|
||||
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
import { getBuildResultComponent, getBuildStateComponent } from './BuildTable';
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
describe('getBuildResultComponent', () => {
|
||||
describe('getBuildResultComponent with Succeeded result', () => {
|
||||
it('should return Status ok Succeeded', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildResultComponent(BuildResult.Succeeded),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status ok')).toBeInTheDocument();
|
||||
expect(getByText('Succeeded')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildResultComponent with Partially Succeeded result', () => {
|
||||
it('should return Status warning Partially Succeeded', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildResultComponent(BuildResult.PartiallySucceeded),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('Partially Succeeded')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildResultComponent with Failed result', () => {
|
||||
it('should return Status error Failed', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildResultComponent(BuildResult.Failed),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status error')).toBeInTheDocument();
|
||||
expect(getByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildResultComponent with Canceled result', () => {
|
||||
it('should return Status aborted Canceled', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildResultComponent(BuildResult.Canceled),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status aborted')).toBeInTheDocument();
|
||||
expect(getByText('Canceled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildResultComponent with None result', () => {
|
||||
it('should return Status warning Unknown', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildResultComponent(BuildResult.None),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildResultComponent with undefined result', () => {
|
||||
it('should return Status warning Unknown', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildResultComponent(undefined),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent', () => {
|
||||
describe('getBuildStateComponent with In Progress status and undefined result', () => {
|
||||
it('should return Status running In Progress', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.InProgress, undefined),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status running')).toBeInTheDocument();
|
||||
expect(getByText('In Progress')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with Completed status and Succeeded result', () => {
|
||||
it('should return Status ok Succeeded', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.Completed, BuildResult.Succeeded),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status ok')).toBeInTheDocument();
|
||||
expect(getByText('Succeeded')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with Completed status and Partially Succeeded result', () => {
|
||||
it('should return Status warning Partially Succeeded', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(
|
||||
BuildStatus.Completed,
|
||||
BuildResult.PartiallySucceeded,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('Partially Succeeded')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with Completed status and Failed result', () => {
|
||||
it('should return Status error Failed', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.Completed, BuildResult.Failed),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status error')).toBeInTheDocument();
|
||||
expect(getByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with Completed status and Canceled result', () => {
|
||||
it('should return Status aborted Canceled', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.Completed, BuildResult.Canceled),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status aborted')).toBeInTheDocument();
|
||||
expect(getByText('Canceled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with Completed status and None result', () => {
|
||||
it('should return Status warning Unknown', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.Completed, BuildResult.None),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with Completed status and undefined result', () => {
|
||||
it('should return Status warning Unknown', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.Completed, undefined),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Add remaining Completed iterations
|
||||
|
||||
describe('getBuildStateComponent with Cancelling status and undefined result', () => {
|
||||
it('should return Status aborted Cancelling', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.Cancelling, undefined),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status aborted')).toBeInTheDocument();
|
||||
expect(getByText('Cancelling')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with Postponed status and undefined result', () => {
|
||||
it('should return Status pending Postponed', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.Postponed, undefined),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status pending')).toBeInTheDocument();
|
||||
expect(getByText('Postponed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with Not Started status and undefined result', () => {
|
||||
it('should return Status aborted Not Started', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.NotStarted, undefined),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status aborted')).toBeInTheDocument();
|
||||
expect(getByText('Not Started')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with None status and undefined result', () => {
|
||||
it('should return Status warning Unknown', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(BuildStatus.None, undefined),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with undefined and undefined result', () => {
|
||||
it('should return Status warning Unknown', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(undefined, undefined),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuildStateComponent with undefined and any valid BuildResult', () => {
|
||||
it('should return Status warning Unknown', async () => {
|
||||
const { getByLabelText, getByText } = await renderInTestApp(
|
||||
getBuildStateComponent(undefined, BuildResult.Succeeded),
|
||||
);
|
||||
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
expect(getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ import { DateTime } from 'luxon';
|
||||
import React from 'react';
|
||||
import { RepoBuild } from '../../api/types';
|
||||
|
||||
const getBuildResultComponent = (result: number | undefined) => {
|
||||
export const getBuildResultComponent = (result: number | undefined) => {
|
||||
switch (result) {
|
||||
case BuildResult.Succeeded:
|
||||
return (
|
||||
@@ -72,7 +72,7 @@ const getBuildResultComponent = (result: number | undefined) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getBuildStateComponent = (
|
||||
export const getBuildStateComponent = (
|
||||
status: number | undefined,
|
||||
result: number | undefined,
|
||||
) => {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { azureDevOpsRouteRef } from '../routes';
|
||||
import { EntityPageAzurePipelines } from './EntityPageAzurePipelines';
|
||||
import { AZURE_DEVOPS_ANNOTATION } from '../constants';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { MissingAnnotationEmptyState } from '@backstage/core-components';
|
||||
|
||||
export const isAzureDevOpsAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION]);
|
||||
|
||||
export const Router = ({ defaultLimit }: { defaultLimit?: number }) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
if (!isAzureDevOpsAvailable(entity)) {
|
||||
return <MissingAnnotationEmptyState annotation={AZURE_DEVOPS_ANNOTATION} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path={`/${azureDevOpsRouteRef.path}`}
|
||||
element={<EntityPageAzurePipelines defaultLimit={defaultLimit} />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
@@ -13,5 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { azureDevOpsPlugin, EntityAzurePipelinesContent } from './plugin';
|
||||
export { isAzureDevOpsAvailable } from './components/Router';
|
||||
export {
|
||||
azureDevOpsPlugin,
|
||||
EntityAzurePipelinesContent,
|
||||
isAzureDevOpsAvailable,
|
||||
} from './plugin';
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { azureDevOpsApiRef } from './api/AzureDevOpsApi';
|
||||
import { AzureDevOpsClient } from './api/AzureDevOpsClient';
|
||||
import {
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
@@ -23,8 +21,16 @@ import {
|
||||
discoveryApiRef,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import { AZURE_DEVOPS_ANNOTATION } from './constants';
|
||||
import { AzureDevOpsClient } from './api/AzureDevOpsClient';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { azureDevOpsApiRef } from './api/AzureDevOpsApi';
|
||||
import { azureDevOpsRouteRef } from './routes';
|
||||
|
||||
export const isAzureDevOpsAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[AZURE_DEVOPS_ANNOTATION]);
|
||||
|
||||
export const azureDevOpsPlugin = createPlugin({
|
||||
id: 'azureDevOps',
|
||||
apis: [
|
||||
@@ -43,7 +49,10 @@ export const azureDevOpsPlugin = createPlugin({
|
||||
export const EntityAzurePipelinesContent = azureDevOpsPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'EntityAzurePipelinesContent',
|
||||
component: () => import('./components/Router').then(m => m.Router),
|
||||
component: () =>
|
||||
import('./components/EntityPageAzurePipelines').then(
|
||||
m => m.EntityPageAzurePipelines,
|
||||
),
|
||||
mountPoint: azureDevOpsRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Config } from '@backstage/config';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-backend';
|
||||
import { EntityProviderConnection } from '@backstage/plugin-catalog-backend';
|
||||
import { GroupEntity } from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
import { SearchEntry } from 'ldapjs';
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@backstage/config": "^0.1.10",
|
||||
"@backstage/errors": "^0.1.3",
|
||||
"@backstage/plugin-catalog-backend": "^0.17.1",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@types/ldapjs": "^2.2.0",
|
||||
"ldapjs": "^2.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config, JsonValue } from '@backstage/config';
|
||||
import { Config } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { SearchOptions } from 'ldapjs';
|
||||
import mergeWith from 'lodash/mergeWith';
|
||||
import { RecursivePartial } from '@backstage/plugin-catalog-backend';
|
||||
|
||||
@@ -18,8 +18,8 @@ import { EntityRelationSpec } from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
import { GitHubIntegrationConfig } from '@backstage/integration';
|
||||
import { IndexableDocument } from '@backstage/search-common';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Knex } from 'knex';
|
||||
import { Location as Location_2 } from '@backstage/catalog-model';
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@backstage/errors": "^0.1.3",
|
||||
"@backstage/integration": "^0.6.8",
|
||||
"@backstage/search-common": "^0.2.0",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@octokit/graphql": "^4.5.8",
|
||||
"@types/express": "^4.17.6",
|
||||
"aws-sdk": "^2.840.0",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Entity, EntityRelationSpec } from '@backstage/catalog-model';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { DateTime } from 'luxon';
|
||||
import { DeferredEntity } from '../processing/types';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import yaml from 'yaml';
|
||||
import { CatalogProcessor } from './types';
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
EntityRelationSpec,
|
||||
LocationSpec,
|
||||
} from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
export type CatalogProcessor = {
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
InputError,
|
||||
NotAllowedError,
|
||||
} from '@backstage/errors';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import path from 'path';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { CatalogProcessor } from '../ingestion/processors';
|
||||
import { CatalogProcessorCache } from '../ingestion/processors/types';
|
||||
import { isObject } from './util';
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Entity, EntityRelationSpec } from '@backstage/catalog-model';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
export type EntityProcessingRequest = {
|
||||
entity: Entity;
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
ORIGIN_LOCATION_ANNOTATION,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import path from 'path';
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.5",
|
||||
"@backstage/config": "^0.1.8",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@graphql-modules/core": "^0.7.17",
|
||||
"apollo-server": "^2.16.1",
|
||||
"cross-fetch": "^3.0.6",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Entity, EntityMeta } from '@backstage/catalog-model';
|
||||
import fetch from 'cross-fetch';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
export interface ReaderEntityMeta extends EntityMeta {
|
||||
uid: string;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import { LinkProps } from '@backstage/core-components';
|
||||
import { Observable } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@backstage/core-plugin-api": "^0.1.11",
|
||||
"@backstage/errors": "^0.1.3",
|
||||
"@backstage/integration": "^0.6.8",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@backstage/version-bridge": "^0.1.0",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Observable, StorageApi } from '@backstage/core-plugin-api';
|
||||
import { StorageApi } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import { performMigrationToTheNewBucket } from './migration';
|
||||
import { StarredEntitiesApi } from './StarredEntitiesApi';
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiRef, createApiRef, Observable } from '@backstage/core-plugin-api';
|
||||
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* An API to store starred entities
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
import { Schema } from 'jsonschema';
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@backstage/core-plugin-api": "^0.1.11",
|
||||
"@backstage/errors": "^0.1.3",
|
||||
"@backstage/theme": "^0.2.11",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
|
||||
@@ -18,7 +18,7 @@ import ObservableImpl from 'zen-observable';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { Schema } from 'jsonschema';
|
||||
import { ConfigSchemaApi, ConfigSchemaResult } from './types';
|
||||
import { Observable } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
const DEFAULT_URL = 'config-schema.json';
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
import { Schema } from 'jsonschema';
|
||||
import { createApiRef, Observable } from '@backstage/core-plugin-api';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
export interface ConfigSchemaResult {
|
||||
schema?: Schema;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Box, Chip, Divider, makeStyles, Typography } from '@material-ui/core';
|
||||
import { Schema } from 'jsonschema';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import {
|
||||
Paper,
|
||||
Table,
|
||||
|
||||
@@ -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 React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { Cluster } from './Cluster';
|
||||
|
||||
jest.mock('../../hooks');
|
||||
import * as oneDeployment from '../../__fixtures__/1-deployments.json';
|
||||
|
||||
describe('Cluster', () => {
|
||||
it('render 1 cluster', async () => {
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<Cluster
|
||||
{...({
|
||||
clusterObjects: {
|
||||
cluster: {
|
||||
name: 'cluster-1',
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
type: 'deployments',
|
||||
resources: oneDeployment.deployments,
|
||||
},
|
||||
{
|
||||
type: 'replicasets',
|
||||
resources: oneDeployment.replicaSets,
|
||||
},
|
||||
{
|
||||
type: 'pods',
|
||||
resources: oneDeployment.pods,
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
},
|
||||
podsWithErrors: new Set<string>(),
|
||||
} as any)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getByText('cluster-1')).toBeInTheDocument();
|
||||
expect(getByText('10 pods')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+2
-118
@@ -23,32 +23,20 @@ import {
|
||||
Grid,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { ClusterObjects } from '@backstage/plugin-kubernetes-common';
|
||||
import { ErrorPanel } from './ErrorPanel';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { DeploymentsAccordions } from '../DeploymentsAccordions';
|
||||
import { ErrorReporting } from '../ErrorReporting';
|
||||
import { groupResponses } from '../../utils/response';
|
||||
import { DetectedError, detectErrors } from '../../error-detection';
|
||||
import { IngressesAccordions } from '../IngressesAccordions';
|
||||
import { ServicesAccordions } from '../ServicesAccordions';
|
||||
import { CustomResources } from '../CustomResources';
|
||||
import EmptyStateImage from '../../assets/emptystate.svg';
|
||||
import {
|
||||
ClusterContext,
|
||||
GroupedResponsesContext,
|
||||
PodNamesWithErrorsContext,
|
||||
useKubernetesObjects,
|
||||
} from '../../hooks';
|
||||
|
||||
import {
|
||||
Content,
|
||||
Page,
|
||||
Progress,
|
||||
StatusError,
|
||||
StatusOK,
|
||||
} from '@backstage/core-components';
|
||||
import { StatusError, StatusOK } from '@backstage/core-components';
|
||||
|
||||
type ClusterSummaryProps = {
|
||||
clusterName: string;
|
||||
@@ -117,7 +105,7 @@ type ClusterProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => {
|
||||
export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => {
|
||||
const groupedResponses = groupResponses(clusterObjects.resources);
|
||||
return (
|
||||
<ClusterContext.Provider value={clusterObjects.cluster}>
|
||||
@@ -153,107 +141,3 @@ const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => {
|
||||
</ClusterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type KubernetesContentProps = { entity: Entity; children?: React.ReactNode };
|
||||
|
||||
export const KubernetesContent = ({ entity }: KubernetesContentProps) => {
|
||||
const { kubernetesObjects, error } = useKubernetesObjects(entity);
|
||||
|
||||
const clustersWithErrors =
|
||||
kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? [];
|
||||
|
||||
const detectedErrors =
|
||||
kubernetesObjects !== undefined
|
||||
? detectErrors(kubernetesObjects)
|
||||
: new Map<string, DetectedError[]>();
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Content>
|
||||
{kubernetesObjects === undefined && error === undefined && <Progress />}
|
||||
|
||||
{/* errors retrieved from the kubernetes clusters */}
|
||||
{clustersWithErrors.length > 0 && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorPanel
|
||||
entityName={entity.metadata.name}
|
||||
clustersWithErrors={clustersWithErrors}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* other errors */}
|
||||
{error !== undefined && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorPanel
|
||||
entityName={entity.metadata.name}
|
||||
errorMessage={error}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{kubernetesObjects && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorReporting detectedErrors={detectedErrors} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="h3">Your Clusters</Typography>
|
||||
</Grid>
|
||||
<Grid item container>
|
||||
{kubernetesObjects?.items.length <= 0 && (
|
||||
<Grid
|
||||
container
|
||||
justifyContent="space-around"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="h5">
|
||||
No resources on any known clusters for{' '}
|
||||
{entity.metadata.name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<img
|
||||
src={EmptyStateImage}
|
||||
alt="EmptyState"
|
||||
data-testid="emptyStateImg"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{kubernetesObjects?.items.length > 0 &&
|
||||
kubernetesObjects?.items.map((item, i) => {
|
||||
const podsWithErrors = new Set<string>(
|
||||
detectedErrors
|
||||
.get(item.cluster.name)
|
||||
?.filter(de => de.kind === 'Pod')
|
||||
.map(de => de.names)
|
||||
.flat() ?? [],
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid item key={i} xs={12}>
|
||||
<Cluster
|
||||
clusterObjects={item}
|
||||
podsWithErrors={podsWithErrors}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { KubernetesContent } from './KubernetesContent';
|
||||
export { Cluster } from './Cluster';
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export { ErrorPanel } from './ErrorPanel';
|
||||
+4
-4
@@ -18,11 +18,11 @@ import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { KubernetesContent } from './KubernetesContent';
|
||||
import { useKubernetesObjects } from '../../hooks';
|
||||
import { useKubernetesObjects } from '../hooks';
|
||||
|
||||
jest.mock('../../hooks');
|
||||
import * as oneDeployment from '../../__fixtures__/1-deployments.json';
|
||||
import * as twoDeployments from '../../__fixtures__/2-deployments.json';
|
||||
jest.mock('../hooks');
|
||||
import * as oneDeployment from '../__fixtures__/1-deployments.json';
|
||||
import * as twoDeployments from '../__fixtures__/2-deployments.json';
|
||||
|
||||
describe('KubernetesContent', () => {
|
||||
it('render empty response', async () => {
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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 { Divider, Grid, Typography } from '@material-ui/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { ErrorPanel } from './ErrorPanel';
|
||||
import { ErrorReporting } from './ErrorReporting';
|
||||
import { DetectedError, detectErrors } from '../error-detection';
|
||||
import { Cluster } from './Cluster';
|
||||
import EmptyStateImage from '../assets/emptystate.svg';
|
||||
import { useKubernetesObjects } from '../hooks';
|
||||
import { Content, Page, Progress } from '@backstage/core-components';
|
||||
|
||||
type KubernetesContentProps = { entity: Entity; children?: React.ReactNode };
|
||||
|
||||
export const KubernetesContent = ({ entity }: KubernetesContentProps) => {
|
||||
const { kubernetesObjects, error } = useKubernetesObjects(entity);
|
||||
|
||||
const clustersWithErrors =
|
||||
kubernetesObjects?.items.filter(r => r.errors.length > 0) ?? [];
|
||||
|
||||
const detectedErrors =
|
||||
kubernetesObjects !== undefined
|
||||
? detectErrors(kubernetesObjects)
|
||||
: new Map<string, DetectedError[]>();
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Content>
|
||||
{kubernetesObjects === undefined && error === undefined && <Progress />}
|
||||
|
||||
{/* errors retrieved from the kubernetes clusters */}
|
||||
{clustersWithErrors.length > 0 && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorPanel
|
||||
entityName={entity.metadata.name}
|
||||
clustersWithErrors={clustersWithErrors}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* other errors */}
|
||||
{error !== undefined && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorPanel
|
||||
entityName={entity.metadata.name}
|
||||
errorMessage={error}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{kubernetesObjects && (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ErrorReporting detectedErrors={detectedErrors} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Divider />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="h3">Your Clusters</Typography>
|
||||
</Grid>
|
||||
<Grid item container>
|
||||
{kubernetesObjects?.items.length <= 0 && (
|
||||
<Grid
|
||||
container
|
||||
justifyContent="space-around"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="h5">
|
||||
No resources on any known clusters for{' '}
|
||||
{entity.metadata.name}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<img
|
||||
src={EmptyStateImage}
|
||||
alt="EmptyState"
|
||||
data-testid="emptyStateImg"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{kubernetesObjects?.items.length > 0 &&
|
||||
kubernetesObjects?.items.map((item, i) => {
|
||||
const podsWithErrors = new Set<string>(
|
||||
detectedErrors
|
||||
.get(item.cluster.name)
|
||||
?.filter(de => de.kind === 'Pod')
|
||||
.map(de => de.names)
|
||||
.flat() ?? [],
|
||||
);
|
||||
|
||||
return (
|
||||
<Grid item key={i} xs={12}>
|
||||
<Cluster
|
||||
clusterObjects={item}
|
||||
podsWithErrors={podsWithErrors}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -71,10 +71,10 @@ describe('useKubernetesObjects', () => {
|
||||
const mockGetObjectsByEntity = jest.fn();
|
||||
const mockDecorateRequestBodyForAuth = jest.fn();
|
||||
|
||||
const expectMocksCalledCorrectly = () => {
|
||||
expect(mockGetClusters).toBeCalledTimes(1);
|
||||
const expectMocksCalledCorrectly = (numOfCalls: number = 1) => {
|
||||
expect(mockGetClusters).toBeCalledTimes(numOfCalls);
|
||||
expect(mockGetClusters).toHaveBeenLastCalledWith();
|
||||
expect(mockDecorateRequestBodyForAuth).toBeCalledTimes(2);
|
||||
expect(mockDecorateRequestBodyForAuth).toBeCalledTimes(numOfCalls * 2);
|
||||
expect(mockDecorateRequestBodyForAuth).toHaveBeenCalledWith('google', {
|
||||
entity,
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe('useKubernetesObjects', () => {
|
||||
'authprovider2',
|
||||
entityWithAuthToken,
|
||||
);
|
||||
expect(mockGetObjectsByEntity).toBeCalledTimes(1);
|
||||
expect(mockGetObjectsByEntity).toBeCalledTimes(numOfCalls);
|
||||
expect(mockGetObjectsByEntity).toHaveBeenLastCalledWith(
|
||||
entityWithAuthToken,
|
||||
);
|
||||
@@ -110,6 +110,26 @@ describe('useKubernetesObjects', () => {
|
||||
|
||||
expectMocksCalledCorrectly();
|
||||
});
|
||||
it('should update on an interval', async () => {
|
||||
(useApi as any).mockReturnValue({
|
||||
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
|
||||
getObjectsByEntity:
|
||||
mockGetObjectsByEntity.mockResolvedValue(mockResponse),
|
||||
decorateRequestBodyForAuth:
|
||||
mockDecorateRequestBodyForAuth.mockResolvedValue(entityWithAuthToken),
|
||||
});
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useKubernetesObjects(entity, 100),
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.kubernetesObjects).toStrictEqual(mockResponse);
|
||||
|
||||
expectMocksCalledCorrectly(2);
|
||||
});
|
||||
it('should return error when getObjectsByEntity throws', async () => {
|
||||
(useApi as any).mockReturnValue({
|
||||
getClusters: mockGetClusters.mockResolvedValue(getClustersResponse),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { kubernetesApiRef } from '../api/types';
|
||||
import { kubernetesAuthProvidersApiRef } from '../kubernetes-auth-provider/types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useInterval } from 'react-use';
|
||||
import {
|
||||
KubernetesRequestBody,
|
||||
ObjectsByEntityResponse,
|
||||
@@ -29,7 +30,10 @@ export interface KubernetesObjects {
|
||||
error: string | undefined;
|
||||
}
|
||||
|
||||
export const useKubernetesObjects = (entity: Entity): KubernetesObjects => {
|
||||
export const useKubernetesObjects = (
|
||||
entity: Entity,
|
||||
intervalMs: number = 10000,
|
||||
): KubernetesObjects => {
|
||||
const kubernetesApi = useApi(kubernetesApiRef);
|
||||
const kubernetesAuthProvidersApi = useApi(kubernetesAuthProvidersApiRef);
|
||||
const [kubernetesObjects, setKubernetesObjects] = useState<
|
||||
@@ -38,51 +42,55 @@ export const useKubernetesObjects = (entity: Entity): KubernetesObjects => {
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const getObjects = async () => {
|
||||
let clusters = [];
|
||||
|
||||
try {
|
||||
clusters = await kubernetesApi.getClusters();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const authProviders: string[] = [
|
||||
...new Set(clusters.map(c => c.authProvider)),
|
||||
];
|
||||
// For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider
|
||||
let requestBody: KubernetesRequestBody = {
|
||||
entity,
|
||||
};
|
||||
for (const authProviderStr of authProviders) {
|
||||
// Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously
|
||||
try {
|
||||
requestBody =
|
||||
await kubernetesAuthProvidersApi.decorateRequestBodyForAuth(
|
||||
authProviderStr,
|
||||
requestBody,
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setKubernetesObjects(await kubernetesApi.getObjectsByEntity(requestBody));
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let clusters = [];
|
||||
|
||||
try {
|
||||
clusters = await kubernetesApi.getClusters();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const authProviders: string[] = [
|
||||
...new Set(clusters.map(c => c.authProvider)),
|
||||
];
|
||||
// For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider
|
||||
let requestBody: KubernetesRequestBody = {
|
||||
entity,
|
||||
};
|
||||
for (const authProviderStr of authProviders) {
|
||||
// Multiple asyncs done sequentially instead of all at once to prevent same requestBody from being modified simultaneously
|
||||
try {
|
||||
requestBody =
|
||||
await kubernetesAuthProvidersApi.decorateRequestBodyForAuth(
|
||||
authProviderStr,
|
||||
requestBody,
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setKubernetesObjects(
|
||||
await kubernetesApi.getObjectsByEntity(requestBody),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
return;
|
||||
}
|
||||
})();
|
||||
getObjects();
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
}, [entity.metadata.name, kubernetesApi, kubernetesAuthProvidersApi]);
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
useInterval(() => {
|
||||
getObjects();
|
||||
}, intervalMs);
|
||||
|
||||
return {
|
||||
kubernetesObjects,
|
||||
error,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@backstage/integration": "^0.6.2",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.15.10",
|
||||
"@backstage/config": "^0.1.8",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"fs-extra": "10.0.0",
|
||||
"winston": "^3.2.1",
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ import {
|
||||
UrlReader,
|
||||
ContainerRunner,
|
||||
} from '@backstage/backend-common';
|
||||
import { ConfigReader, JsonObject } from '@backstage/config';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import mockFs from 'mock-fs';
|
||||
import os from 'os';
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
UrlReader,
|
||||
resolveSafeChildPath,
|
||||
} from '@backstage/backend-common';
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import commandExists from 'command-exists';
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@backstage/config": "^0.1.8",
|
||||
"@backstage/errors": "^0.1.3",
|
||||
"@backstage/integration": "^0.6.2",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"fs-extra": "^9.0.0"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { ContainerRunner, UrlReader } from '@backstage/backend-common';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
railsArgumentResolver,
|
||||
RailsRunOptions,
|
||||
} from './railsArgumentResolver';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { Writable } from 'stream';
|
||||
|
||||
export class RailsNewRunner {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.8",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.15.2",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"winston": "^3.2.1",
|
||||
"yeoman-environment": "^3.6.0"
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ import os from 'os';
|
||||
import { PassThrough } from 'stream';
|
||||
import { createRunYeomanAction } from './yeoman';
|
||||
import type { ActionContext } from '@backstage/plugin-scaffolder-backend';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
describe('run:yeoman', () => {
|
||||
const mockTmpDir = os.tmpdir();
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { createTemplateAction } from '@backstage/plugin-scaffolder-backend';
|
||||
import { yeomanRun } from './yeomanRun';
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
/*
|
||||
* This module should use '@types/yeoman-environment' eventually as soon as '@types/yeoman-environment' supports
|
||||
|
||||
@@ -189,7 +189,7 @@ export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
// @public (undocumented)
|
||||
export const createTemplateAction: <
|
||||
Input extends Partial<{
|
||||
[name: string]: JsonValue_2 | Partial<JsonObject_2> | undefined;
|
||||
[name: string]: JsonValue | Partial<JsonObject> | undefined;
|
||||
}>,
|
||||
>(
|
||||
templateAction: TemplateAction<Input>,
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@backstage/plugin-catalog-backend": "^0.17.1",
|
||||
"@backstage/plugin-scaffolder-common": "^0.1.0",
|
||||
"@backstage/plugin-scaffolder-backend-module-cookiecutter": "^0.1.2",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@gitbeaker/core": "^30.2.0",
|
||||
"@gitbeaker/node": "^30.2.0",
|
||||
"@octokit/rest": "^18.5.3",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { resolveSafeChildPath, UrlReader } from '@backstage/backend-common';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { createTemplateAction } from '../../createTemplateAction';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
interface FilesToRename extends JsonObject {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Logger } from 'winston';
|
||||
import { Writable } from 'stream';
|
||||
import { JsonValue, JsonObject } from '@backstage/config';
|
||||
import { JsonValue, JsonObject } from '@backstage/types';
|
||||
import { Schema } from 'jsonschema';
|
||||
|
||||
type PartialJsonObject = Partial<JsonObject>;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { resolvePackagePath } from '@backstage/backend-common';
|
||||
import { ConflictError, NotFoundError } from '@backstage/errors';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
@@ -26,7 +26,7 @@ import * as winston from 'winston';
|
||||
import nunjucks from 'nunjucks';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { PassThrough } from 'stream';
|
||||
import { isTruthy } from './helper';
|
||||
|
||||
@@ -31,7 +31,7 @@ import { Logger } from 'winston';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { validate as validateJsonSchema } from 'jsonschema';
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
|
||||
type Options = {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonValue, JsonObject } from '@backstage/config';
|
||||
import { JsonValue, JsonObject } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* Status
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
```ts
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JSONSchema } from '@backstage/catalog-model';
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.9.5",
|
||||
"@backstage/config": "^0.1.10"
|
||||
"@backstage/types": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.8.0"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -19,10 +19,10 @@ import { FieldProps } from '@rjsf/core';
|
||||
import { FieldValidation } from '@rjsf/core';
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JSONSchema } from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { Observable } from '@backstage/core-plugin-api';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { default as React_2 } from 'react';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
@@ -240,6 +240,29 @@ const scaffolderPlugin: BackstagePlugin<
|
||||
export { scaffolderPlugin as plugin };
|
||||
export { scaffolderPlugin };
|
||||
|
||||
// Warning: (ae-missing-release-tag) "TemplateList" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const TemplateList: ({
|
||||
TemplateCardComponent,
|
||||
}: TemplateListProps) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "TemplateListProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export type TemplateListProps = {
|
||||
TemplateCardComponent?:
|
||||
| ComponentType<{
|
||||
template: TemplateEntityV1beta2;
|
||||
}>
|
||||
| undefined;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "TemplateTypePicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const TemplateTypePicker: () => JSX.Element | null;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "TextValuePicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@backstage/integration-react": "^0.1.12",
|
||||
"@backstage/plugin-catalog-react": "^0.6.1",
|
||||
"@backstage/theme": "^0.2.11",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { JsonObject, JsonValue, Observable } from '@backstage/types';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import { Field, FieldValidation } from '@rjsf/core';
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
createApiRef,
|
||||
DiscoveryApi,
|
||||
IdentityApi,
|
||||
Observable,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { FormProps } from '@rjsf/core';
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
|
||||
@@ -14,3 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { TemplateList } from './TemplateList';
|
||||
export type { TemplateListProps } from './TemplateList';
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { LinearProgress } from '@material-ui/core';
|
||||
import { FormValidation, IChangeEvent } from '@rjsf/core';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
@@ -17,7 +17,8 @@ import { useImmerReducer } from 'use-immer';
|
||||
import { useEffect } from 'react';
|
||||
import { scaffolderApiRef, LogEvent } from '../../api';
|
||||
import { ScaffolderTask, Status, TaskOutput } from '../../types';
|
||||
import { Subscription, useApi } from '@backstage/core-plugin-api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { Subscription } from '@backstage/types';
|
||||
|
||||
type Step = {
|
||||
id: string;
|
||||
|
||||
@@ -44,3 +44,6 @@ export {
|
||||
TextValuePicker,
|
||||
} from './components/fields';
|
||||
export { FavouriteTemplate } from './components/FavouriteTemplate';
|
||||
export { TemplateList } from './components/TemplateList';
|
||||
export type { TemplateListProps } from './components/TemplateList';
|
||||
export { TemplateTypePicker } from './components/TemplateTypePicker';
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { JSONSchema } from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
export type Status = 'open' | 'processing' | 'failed' | 'completed' | 'skipped';
|
||||
export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { IndexableDocument } from '@backstage/search-common';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { default as React_2 } from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@backstage/plugin-catalog-react": "^0.6.1",
|
||||
"@backstage/search-common": "^0.2.0",
|
||||
"@backstage/theme": "^0.2.11",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { SearchResultSet } from '@backstage/search-common';
|
||||
import React, {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { usePrevious } from 'react-use';
|
||||
import qs from 'qs';
|
||||
import { useLocation, useOutlet } from 'react-router';
|
||||
import { SearchContextProvider, useSearch } from '../SearchContext';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { LegacySearchPage } from '../LegacySearchPage';
|
||||
|
||||
export const UrlUpdater = () => {
|
||||
|
||||
@@ -36,7 +36,11 @@ export class MockSentryApi implements SentryApi {
|
||||
export class ProductionSentryApi implements SentryApi {
|
||||
constructor(discoveryApi: DiscoveryApi, organization: string);
|
||||
// (undocumented)
|
||||
fetchIssues(project: string, statsFor: string): Promise<SentryIssue[]>;
|
||||
fetchIssues(
|
||||
project: string,
|
||||
statsFor: string,
|
||||
query?: string,
|
||||
): Promise<SentryIssue[]>;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "Router" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -49,7 +53,11 @@ export const Router: ({ entity }: { entity: Entity }) => JSX.Element;
|
||||
// @public (undocumented)
|
||||
export interface SentryApi {
|
||||
// (undocumented)
|
||||
fetchIssues(project: string, statsFor: string): Promise<SentryIssue[]>;
|
||||
fetchIssues(
|
||||
project: string,
|
||||
statsFor: string,
|
||||
query?: string,
|
||||
): Promise<SentryIssue[]>;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "sentryApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -67,7 +75,7 @@ export type SentryIssue = {
|
||||
userCount: number;
|
||||
stats: {
|
||||
'24h'?: EventPoint[];
|
||||
'12h'?: EventPoint[];
|
||||
'14d'?: EventPoint[];
|
||||
};
|
||||
culprit: string;
|
||||
title: string;
|
||||
@@ -100,10 +108,12 @@ export const SentryIssuesWidget: ({
|
||||
entity,
|
||||
statsFor,
|
||||
variant,
|
||||
query,
|
||||
}: {
|
||||
entity: Entity;
|
||||
statsFor?: '12h' | '24h' | undefined;
|
||||
statsFor?: '' | '14d' | '24h' | undefined;
|
||||
variant?: InfoCardVariants | undefined;
|
||||
query?: string | undefined;
|
||||
}) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "sentryPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
|
||||
@@ -20,7 +20,7 @@ import mockData from './sentry-issue-mock.json';
|
||||
|
||||
function getMockIssue(): SentryIssue {
|
||||
const randomizedStats = {
|
||||
'12h': new Array(12)
|
||||
'24h': new Array(24)
|
||||
.fill(0)
|
||||
.map(() => [0, Math.floor(Math.random() * 100)]),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"numComments": 0,
|
||||
"userCount": 0,
|
||||
"stats": {
|
||||
"12h": [
|
||||
"24h": [
|
||||
[1589450400, 7],
|
||||
[1589454000, 2],
|
||||
[1589457600, 6],
|
||||
|
||||
@@ -24,15 +24,21 @@ export class ProductionSentryApi implements SentryApi {
|
||||
private readonly organization: string,
|
||||
) {}
|
||||
|
||||
async fetchIssues(project: string, statsFor: string): Promise<SentryIssue[]> {
|
||||
async fetchIssues(
|
||||
project: string,
|
||||
statsFor: string,
|
||||
query?: string,
|
||||
): Promise<SentryIssue[]> {
|
||||
if (!project) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sentry/api`;
|
||||
|
||||
const queryPart = query ? `&query=${query}` : '';
|
||||
|
||||
const response = await fetch(
|
||||
`${apiUrl}/0/projects/${this.organization}/${project}/issues/?statsPeriod=${statsFor}`,
|
||||
`${apiUrl}/0/projects/${this.organization}/${project}/issues/?statsPeriod=${statsFor}${queryPart}`,
|
||||
);
|
||||
|
||||
if (response.status >= 400 && response.status < 600) {
|
||||
|
||||
@@ -23,5 +23,9 @@ export const sentryApiRef = createApiRef<SentryApi>({
|
||||
});
|
||||
|
||||
export interface SentryApi {
|
||||
fetchIssues(project: string, statsFor: string): Promise<SentryIssue[]>;
|
||||
fetchIssues(
|
||||
project: string,
|
||||
statsFor: string,
|
||||
query?: string,
|
||||
): Promise<SentryIssue[]>;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export type SentryIssue = {
|
||||
userCount: number;
|
||||
stats: {
|
||||
'24h'?: EventPoint[];
|
||||
'12h'?: EventPoint[];
|
||||
'14d'?: EventPoint[];
|
||||
};
|
||||
culprit: string;
|
||||
title: string;
|
||||
|
||||
@@ -20,8 +20,8 @@ import { Sparklines, SparklinesBars } from 'react-sparklines';
|
||||
|
||||
export const ErrorGraph = ({ sentryIssue }: { sentryIssue: SentryIssue }) => {
|
||||
const data =
|
||||
'12h' in sentryIssue.stats
|
||||
? sentryIssue.stats['12h']
|
||||
'14d' in sentryIssue.stats
|
||||
? sentryIssue.stats['14d']
|
||||
: sentryIssue.stats['24h'];
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,7 +59,7 @@ const columns: TableColumn[] = [
|
||||
|
||||
type SentryIssuesTableProps = {
|
||||
sentryIssues: SentryIssue[];
|
||||
statsFor?: '24h' | '12h';
|
||||
statsFor?: '24h' | '14d' | '';
|
||||
};
|
||||
|
||||
const SentryIssuesTable = ({
|
||||
|
||||
@@ -38,10 +38,12 @@ export const SentryIssuesWidget = ({
|
||||
entity,
|
||||
statsFor = '24h',
|
||||
variant = 'gridItem',
|
||||
query = '',
|
||||
}: {
|
||||
entity: Entity;
|
||||
statsFor?: '24h' | '12h';
|
||||
statsFor?: '24h' | '14d' | '';
|
||||
variant?: InfoCardVariants;
|
||||
query?: string;
|
||||
}) => {
|
||||
const errorApi = useApi<ErrorApi>(errorApiRef);
|
||||
const sentryApi = useApi(sentryApiRef);
|
||||
@@ -49,8 +51,8 @@ export const SentryIssuesWidget = ({
|
||||
const projectId = useProjectSlug(entity);
|
||||
|
||||
const { loading, value, error } = useAsync(
|
||||
() => sentryApi.fetchIssues(projectId, statsFor),
|
||||
[sentryApi, statsFor, projectId],
|
||||
() => sentryApi.fetchIssues(projectId, statsFor, query),
|
||||
[sentryApi, statsFor, projectId, query],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import { StorageApi } from '@backstage/core-plugin-api';
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@backstage/core-components": "^0.7.1",
|
||||
"@backstage/core-plugin-api": "^0.1.11",
|
||||
"@backstage/theme": "^0.2.11",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
import { Shortcut } from '../types';
|
||||
import { createApiRef, Observable } from '@backstage/core-plugin-api';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
export const shortcutsApiRef = createApiRef<ShortcutApi>({
|
||||
id: 'plugin.shortcuts.api',
|
||||
|
||||
@@ -321,6 +321,7 @@ export const useTechDocsReaderDom = (): Element | null => {
|
||||
baseUrl: window.location.origin,
|
||||
onClick: (_: MouseEvent, url: string) => {
|
||||
const parsedUrl = new URL(url);
|
||||
// hash exists when anchor is clicked on secondary sidebar
|
||||
if (parsedUrl.hash) {
|
||||
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
|
||||
// Scroll to hash if it's on the current page
|
||||
@@ -329,6 +330,10 @@ export const useTechDocsReaderDom = (): Element | null => {
|
||||
?.scrollIntoView();
|
||||
} else {
|
||||
navigate(parsedUrl.pathname);
|
||||
// Scroll to top of reader if primary sidebar link is clicked
|
||||
transformedElement
|
||||
?.querySelector('.md-content__inner')
|
||||
?.scrollIntoView();
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user