Merge branch 'backstage:master' into community-page

This commit is contained in:
Suzanne Daniels
2022-03-22 12:48:37 +01:00
committed by GitHub
109 changed files with 3648 additions and 465 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/catalog-model': patch
'@backstage/config-loader': patch
'@backstage/plugin-tech-insights-backend-module-jsonfc': patch
---
build(deps): bump `ajv` from 7.0.3 to 8.10.0
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added an experimental `package fix` command which applies automated fixes to the target package. The initial fix that is available is to add missing monorepo dependencies to the target package.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
fixed empty body issue for POST requests using FetchAPI with 'plugin://' prefix
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Enable internal batching of very large deletions, to not run into SQL binding limits
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Applied the fix from version 0.12.3 of this package, which is part of the v1.0.1 release of Backstage.
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/backend-common': patch
'@backstage/integration': patch
---
Support external ID when assuming roles in S3 integration
In order to assume a role created by a 3rd party as external
ID is needed. This change adds an optional field to the s3
integration configuration and consumes that in the AwsS3UrlReader.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-scaffolder': patch
---
Added a new `NextScaffolderRouter` which will eventually replace the exiting router
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/cli': patch
'@backstage/config-loader': patch
'@backstage/core-app-api': patch
'@backstage/plugin-catalog-backend-module-bitbucket': patch
'@backstage/plugin-tech-insights-backend': patch
---
Implemented changes suggested by Deepsource.io including multiple double non-null assertion operators and unexpected awaits for non-promise values.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added a new experimental `repo list-deprecations` command, which scans the entire project for usage of deprecated APIs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Fix permalink scrolling for anchors where the id starts with a number.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Fixed a bug affecting those with cache enabled that would result in empty content being cached if the first attempt to load a static asset from storage were made via a `HEAD` request, rather than a `GET` request.
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-tech-insights': patch
'@backstage/plugin-tech-insights-backend': patch
---
Improved the Tech-Insights documentation:
- `lifecycle` examples used `ttl` when it should be `timeToLive`
- Added list of included FactRetrievers
- Added full backend example using all included FactRetrievers
- Added boolean scorecard example image showing results of backend example
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-jenkins-backend': patch
---
Make `resourceRef` required in `JenkinsApi` to match usage.
+37
View File
@@ -0,0 +1,37 @@
name: Sync Issue Labels
on:
issues:
types: [opened]
jobs:
label-issue:
runs-on: ubuntu-latest
steps:
- name: View context attributes
uses: actions/github-script@v6
with:
script: |
const keywords = {
'techdocs|tech-docs|tech docs': 'docs-like-code',
'search': 'search',
'catalog': 'catalog',
'scaffolder': 'scaffolder',
};
const labels = Object.entries(keywords)
.map(([regexp, label]) => {
if (new RegExp(regexp, 'gi').test(context.payload.issue.title)) {
return label;
}
})
.filter(Boolean);
if(!labels.length) {
return;
}
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels
});
+1
View File
@@ -107,3 +107,4 @@ _If you're using Backstage in your organization, please try to add your company
| [VIA](https://www.via.com.br) | [@vagnerguedes](https://github.com/vagnerguedes) | Centralized Developer Experience portal - Software catalog and documentation platform, software templates, techdocs, scaffolding, self-service infrastructure |
| [Surevine](https://www.surevine.com/) | [@DJDANNY123](https://github.com/djdanny123) | Developer portal for software catalog, discovery and a view of the technologies we are using across the organisation, we are looking to explore how we can enrich our entities in Backstage by integrating a software bill of materials. |
| [Bonial International GmbH](https://www.bonial.com/) | [@pjungermann](https://github.com/pjungermann) | Centralized developer portal with software catalog, tech docs, templates, and more. |
| [Beez Innovation Labs Pvt. Ltd](https://www.beezlabs.com/) | [Karthikeyan Venkatesan](https://github.com/karthikeyan23) | Developer portal with software catalog, scaffolding, tech docs, templates, and infra. |
+9
View File
@@ -100,6 +100,15 @@ This section describes guidelines for designing public APIs. It can also be appl
}
```
1. Prefer common prefixes over suffixes when naming constants.
```ts
// May be tempting to use `GITHUB_WIDGET_LABEL` instead.
const WIDGET_LABEL_GITHUB = 'github';
const WIDGET_LABEL_GITLAB = 'gitlab';
const WIDGET_LABEL_BITBUCKET = 'bitbucket';
```
1. When a type relates directly to other symbols, use the name of those as prefix for the type.
```ts
+169
View File
@@ -0,0 +1,169 @@
import {
ANNOTATION_LOCATION,
ANNOTATION_ORIGIN_LOCATION,
Entity,
entitySchemaValidator,
} from '@backstage/catalog-model';
import { InputError } from '@backstage/errors';
import {
DeferredEntity,
EntityProvider,
EntityProviderConnection,
parseEntityYaml,
} from '@backstage/plugin-catalog-backend';
import bodyParser from 'body-parser';
import express from 'express';
import Router from 'express-promise-router';
import lodash from 'lodash';
import { Logger } from 'winston';
/**
* An entity provider attached to a router, that lets users perform direct
* manipulation of a set of entities using REST requests.
*
* @remarks
*
* Installation:
*
* Add it to the catalog builder in your
* `packages/backend/src/plugins/catalog.ts`. Note that it BOTH adds a provider
* and amends the catalog router:
*
* ```
* const immediate = new ImmediateEntityProvider({
* logger: env.logger,
* handleEntity: (deferred) => {
* // Optionally modify the incoming entity
* },
* });
* builder.addEntityProvider(immediate);
*
* // ...
*
* return router.use('/immediate', immediate.getRouter());
* ```
*
* API (assume a catalog prefix, e.g. `/api/catalog`):
*
* - `POST /immediate/entities`: Accepts a YAML document of entities, and
* inserts or updates the entities that match that document. Returns 201 OK on
* success.
*
* - `PUT /immediate/entities`: Accepts a YAML document of entities, and
* replaces the entire set of entities managed by the provider with those
* entities. Returns 201 OK on success.
*/
export class ImmediateEntityProvider implements EntityProvider {
private connection?: EntityProviderConnection;
private readonly entityValidator: (data: unknown) => Entity;
constructor(private readonly options: ImmediateEntityProviderOptions) {
this.entityValidator = entitySchemaValidator();
}
/** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.getProviderName} */
getProviderName() {
return `ImmediateEntityProvider`;
}
/** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.connect} */
async connect(connection: EntityProviderConnection) {
this.connection = connection;
}
getRouter(): express.Router {
const router = Router();
router.use(bodyParser.raw({ type: '*/*' }));
router.post('/entities', async (req, res) => {
if (!this.connection) {
throw new Error(`Service is not yet initialized`);
}
const deferred = await this.getRequestBodyEntities(req);
await this.connection.applyMutation({
type: 'delta',
added: deferred,
removed: [],
});
res.status(201).end();
});
router.put('/entities', async (req, res) => {
if (!this.connection) {
throw new Error(`Service is not yet initialized`);
}
const deferred = await this.getRequestBodyEntities(req);
await this.connection.applyMutation({
type: 'full',
entities: deferred,
});
res.status(201).end();
});
return router;
}
private async getRequestBodyEntities(
req: express.Request,
): Promise<DeferredEntity[]> {
if (!Buffer.isBuffer(req.body) || !req.body.length) {
throw new InputError(`Missing request body`);
}
const result: DeferredEntity[] = [];
for await (const item of parseEntityYaml(req.body, {
type: 'immediate',
target: 'immediate',
})) {
if (item.type === 'entity') {
const deferred: DeferredEntity = {
entity: lodash.merge(
{
metadata: {
annotations: {
[ANNOTATION_ORIGIN_LOCATION]: 'immediate:immediate',
[ANNOTATION_LOCATION]: 'immediate:immediate',
},
},
},
item.entity,
),
locationKey: `immediate:`,
};
await this.options.handleEntity?.(req, deferred);
deferred.entity = this.entityValidator(deferred.entity);
result.push(deferred);
} else if (item.type === 'error') {
throw new InputError(`Malformed entity YAML, ${item.error}`);
} else {
throw new InputError(`Internal error, failed to parse entity`);
}
}
return result;
}
}
/**
* Options for {@link ImmediateEntityProvider}.
*/
export interface ImmediateEntityProviderOptions {
/**
* The logger to use.
*/
logger: Logger;
/**
* An optional function to perform adjustments to, or validate, an incoming
* entity before being stored. It is permitted to modify the deferred entity,
* but the request is static and has had its body consumed.
*/
handleEntity?: (
request: express.Request,
deferred: DeferredEntity,
) => void | Promise<void>;
}
@@ -0,0 +1,151 @@
import {
ANNOTATION_LOCATION,
ANNOTATION_ORIGIN_LOCATION,
Entity,
} from '@backstage/catalog-model';
import {
EntityProvider,
EntityProviderConnection,
} from '@backstage/plugin-catalog-backend';
import { Logger } from 'winston';
/**
* An entity provider that can be used for load testing. Not for production use.
*
* @remarks
*
* Add it to the catalog builder in your
* `packages/backend/src/plugins/catalog.ts` doing some type of work, for
* example:
*
* ```
* builder.addEntityProvider(
* new LoadTestingEntityProvider({
* logger: env.logger,
* onStartup: async ({ connection, generateRandomEntities }) => {
* await connection.applyMutation({
* type: 'full',
* entities: generateRandomEntities(100000).map(e => ({
* entity: e,
* locationKey: 'l',
* })),
* });
* },
* }),
* );
* ```
*
* The provider will run the test, outputting some timing info onto the console.
* It will also clean up everything you added through the given connection.
*/
export class LoadTestingEntityProvider implements EntityProvider {
constructor(private readonly options: LoadTestingEntityProviderOptions) {}
/** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.getProviderName} */
getProviderName() {
return `LoadTestingEntityProvider`;
}
/** {@inheritdoc @backstage/plugin-catalog-backend#EntityProvider.connect} */
async connect(connection: EntityProviderConnection) {
const delayStartup = this.options.delayStartup ?? 10_000;
const logger = this.options.logger.child({
class: LoadTestingEntityProvider.prototype.constructor.name,
});
if (delayStartup) {
logger.info(
`[LOAD-TEST] Starting in ${(delayStartup / 1000).toFixed(1)}s`,
);
}
setTimeout(async () => {
const timer = () => {
const startedOn = Date.now();
return () => `${((Date.now() - startedOn) / 1000).toFixed(1)}s`;
};
const overallTimer = timer();
logger.info(`[LOAD-TEST] Started`);
const runTimer = timer();
try {
await this.options.onStartup({
connection,
logger,
generateRandomEntities,
});
logger.info(`[LOAD-TEST] Finished in ${runTimer()}`);
} catch (error) {
logger.error(`[LOAD-TEST] Failed after ${runTimer()}`, error);
}
const cleanupTimer = timer();
logger.info(`[LOAD-TEST] Running cleanup`);
await connection.applyMutation({
type: 'full',
entities: [],
});
logger.info(`[LOAD-TEST] ***************************************`);
logger.info(`[LOAD-TEST] Test run time: ${runTimer()}`);
logger.info(`[LOAD-TEST] Cleanup run time: ${cleanupTimer()}`);
logger.info(`[LOAD-TEST] Total time: ${overallTimer()}`);
logger.info(`[LOAD-TEST] ***************************************`);
}, delayStartup);
}
}
function generateRandomEntities(count: number): Entity[] {
const result: Entity[] = [];
for (let i = 1; i <= count; ++i) {
result.push({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
annotations: {
[ANNOTATION_ORIGIN_LOCATION]: 'url:http://example.com/load-testing',
[ANNOTATION_LOCATION]: 'url:http://example.com/load-testing',
},
namespace: 'load-test',
name: `load-test-${i}`,
},
spec: {
type: 'load-test-data',
owner: 'me',
lifecycle: 'experimental',
},
});
}
return result;
}
/**
* Options for LoadTestingEntityProvider.
*/
export interface LoadTestingEntityProviderOptions {
/**
* The logger to use.
*/
logger: Logger;
/**
* The number of milliseconds of delay to wait before starting the test. This
* gives the backend a chance to settle into a stable state before the test
* starts.
*
* @defaultValue 5000
*/
delayStartup?: number;
/**
* What work to do on startup.
*/
onStartup: (context: {
connection: EntityProviderConnection;
logger: Logger;
generateRandomEntities(count: number): Entity[];
}) => Promise<void>;
}
+27
View File
@@ -0,0 +1,27 @@
# Catalog Contrib
This directory contains various community contributions related to [the Backstage catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview).
There is no guarantee of correctness or fitness of purpose of these
contributions, but we hope that they are helpful to someone!
Installation instructions are generally in the doc comment on top of each class.
## ImmediateEntityProvider
Sometimes we get requests for the ability to POST/PUT entities directly to the
catalog, instead of its regular mode of operation where it pulls data from
authoritative sources itself.
The core product does not intend to support this use case, since it comes with a
number of caveats. However, this entity provider demonstrates how to build a
very basic version of such functionality yourself. It does not offer any
protection from misuse, but can serve as a good starting point to build out such
a provider yourself, fit for your particular needs.
## LoadTestingEntityProvider
This is a trivial little test bed entity provider that lets you make huge batch
operations and get some timings back. It also tries to clean up after itself
when it's done. This can be useful if you are working on optimizing the catalog
itself, or on processors or similar that you add to it.
@@ -109,6 +109,10 @@ import {
createRouter,
createAwsAlbProvider,
} from '@backstage/plugin-auth-backend';
import {
DEFAULT_NAMESPACE,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
@@ -154,16 +158,20 @@ export default async function createPlugin({
const [id] = email?.split('@') ?? '';
// Fetch from an external system that returns entity claims like:
// ['user:default/breanna.davison', ...]
const ent = [`user:default/${id}`];
const userEntityRef = stringifyEntityRef({
kind: 'User',
namespace: DEFAULT_NAMESPACE,
name: id,
});
// Resolve group membership from the Backstage catalog
const fullEnt =
await ctx.catalogIdentityClient.resolveCatalogMembership({
entityRefs: [id].concat(ent),
entityRefs: [id].concat([userEntityRef]),
logger: ctx.logger,
});
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent: fullEnt },
claims: { sub: userEntityRef, ent: fullEnt },
});
return { id, token };
},
+12 -4
View File
@@ -51,11 +51,12 @@ export default async function createPlugin(
// Let's use the username in the email ID as the user's default
// unique identifier inside Backstage.
const [id] = email.split('@');
ent.push(stringifyEntityRef({
const userEntityRef = stringifyEntityRef({
kind: 'User',
namespace: DEFAULT_NAMESPACE,
name: id,
}));
});
ent.push(userEntityRef);
// Let's call the internal LDAP provider to get a list of groups
// that the user belongs to, and add those to the list as well
@@ -68,7 +69,7 @@ export default async function createPlugin(
// Issue the token containing the entity claims
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent },
claims: { sub: userEntityRef, ent },
});
return { id, token };
},
@@ -130,6 +131,8 @@ can do this using the `CatalogIdentityClient` provided as context to Sign-In
resolvers:
```ts
import { DEFAULT_NAMESPACE, stringifyEntityRef } from '@backstage/catalog-model';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
@@ -140,6 +143,11 @@ export default async function createPlugin(
signIn: {
resolver: async ({ profile: { email } }, ctx) => {
const [id] = email?.split('@') ?? '';
const userEntityRef = stringifyEntityRef({
kind: 'User',
namespace: DEFAULT_NAMESPACE,
name: id,
});
// Fetch from an external system that returns entity claims like:
// ['user:default/breanna.davison', ...]
const ent = await externalSystemClient.getUsernames(email);
@@ -150,7 +158,7 @@ export default async function createPlugin(
logger: ctx.logger,
});
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent: fullEnt },
claims: { sub: userEntityRef, ent: fullEnt },
});
return { id, token };
},
@@ -180,6 +180,35 @@ These should be moved to `links` under the `output` object instead.
```
## Watch out for `dash-case`
The nunjucks compiler can run into issues if the `id` fields in your template steps use dash characters, since these IDs translate directly to JavaScript object properties when accessed as output. One possible migration path is to use `camelCase` for your action IDs.
```diff
steps:
- id: my-custom-action
- ...
-
- id: publish-pull-request
- input:
- repoUrl: {{ steps.my-custom-action.output.repoUrl }} # Will not recognize 'my-custom-action' as a JS property since it contains dashes!
steps:
+ id: myCustomAction
+ ...
+
+ id: publishPullRequest
+ input:
+ repoUrl: ${{ steps.myCustomAction.output.repoUrl }}
```
Alternatively, it's possible to keep the `dash-case` syntax and use brackets for property access as you would in JavaScript:
```yaml
input:
repoUrl: ${{ steps['my-custom-action'].output.repoUrl }}
```
### Summary
Of course, we're always available on [discord](https://discord.gg/MUpMjP2) if
+13 -19
View File
@@ -74,32 +74,26 @@ providers are used.
storage solutions, source control systems).
- [Instructions for upgrading from Alpha to Beta](how-to-guides.md#how-to-migrate-from-techdocs-alpha-to-beta)
**v1** ✅
TechDocs packages:
- '@backstage/plugin-techdocs'
- '@backstage/plugin-techdocs-backend'
- '@backstage/plugin-techdocs-node'
- '@techdocs/cli'
was promoted to v1.0! To understand how this change affects the package, please check out our [versioning policy](https://backstage.io/docs/overview/versioning-policy).
### **Future work 🔮**
**General Availability (GA) release** -
[Milestone](https://github.com/backstage/backstage/milestone/30)
- Bugs are rare, TechDocs APIs are stable and scales easily in large
organizations.
- Better integration with
[Scaffolder V2](https://github.com/backstage/backstage/issues/2771) (e.g. easy
to choose and plug documentation template with Software Templates).
- Static site generator agnostic
- Possible to configure several aspects about TechDocs (e.g. URL, homepage,
theme).
**Implement Feedback loop** -
[Milestone](https://github.com/backstage/backstage/milestone/31)
- A feedback loop between documentation reader and writer using TechDocs
- The `+` in `docs-like-code+` experience
**TechDocs widget framework**
Platformize TechDocs with a widget framework so that it is easy for TechDocs
contributors to add pieces of functionality and for users to choose which
functionalities they want to adopt. As a pre-requisite, the re-architecture of
TechDocs frontend [RFC](https://github.com/backstage/backstage/issues/3998)
needs to be addressed.
- [TechDocs Addon Framework](https://github.com/backstage/backstage/issues/9636)
## Tech stack
-12
View File
@@ -131,15 +131,3 @@ layer for users to determine whether they have the permission to view a
particular docs site. There are a handful of features which are extremely hard
to develop without a tightly integrated backend in place. Hence, support for
`techdocs` without `techdocs-backend` is limited and challenging to develop.
# Future work
_Ideas here are far fetched and not in the project's milestone for near future
(~6 months)._
We currently depend on MkDocs to parse doc sites written in Markdown. And we
store the generated static assets and re-use it later to render in Backstage. A
better (futuristic) approach will be to directly parse whatever type of source
files you have in your docs repository and directly render in Backstage in
real-time. You can read more in this
[RFC - Simplifying TechDocs Frontend Architecture](https://github.com/backstage/backstage/issues/3998).
-6
View File
@@ -160,10 +160,4 @@ techdocs:
# object was not found (e.g. when the cache sercice is unavailable). The
# default value is 1000
readTimeout: 500
# (Optional and Legacy) Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware
# to serve files from either a local directory or an External storage provider.
# You don't have to specify this anymore.
storageUrl: http://localhost:7007/api/techdocs/static/docs
```
+1
View File
@@ -37,6 +37,7 @@ integrations:
- accessKeyId: ${AWS_ACCESS_KEY_ID}
secretAccessKey: ${AWS_SECRET_ACCESS_KEY}
roleArn: 'arn:aws:iam::xxxxxxxxxxxx:role/example-role'
externalId: 'some-id' # optional
```
Configuration allows specifying custom S3 endpoint, along with
+1 -1
View File
@@ -26,7 +26,7 @@
"backstage-create": "backstage-cli create --scope backstage --no-private",
"create-plugin": "yarn backstage-create --select plugin",
"remove-plugin": "backstage-cli remove-plugin",
"release": "node scripts/prepare-release.js && changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' && yarn install",
"release": "node scripts/prepare-release.js && changeset version && yarn diff --yes && yarn prettier --write '{packages,plugins}/*/{package.json,CHANGELOG.md}' '.changeset/*.json' && yarn install",
"prettier:check": "prettier --check .",
"lerna": "lerna",
"storybook": "yarn --cwd storybook start",
@@ -177,6 +177,7 @@ export class AwsS3UrlReader implements UrlReader {
params: {
RoleSessionName: 'backstage-aws-s3-url-reader',
RoleArn: roleArn,
ExternalId: integration.config.externalId,
},
});
}
+1 -1
View File
@@ -37,7 +37,7 @@
"@backstage/config": "^1.0.0",
"@backstage/errors": "^1.0.0",
"@backstage/types": "^1.0.0",
"ajv": "^7.0.3",
"ajv": "^8.10.0",
"json-schema": "^0.4.0",
"lodash": "^4.17.21",
"uuid": "^8.0.0"
@@ -54,7 +54,7 @@ export class SchemaValidEntityPolicy implements EntityPolicy {
}
throw new Error(
`Malformed envelope, ${error.dataPath || '<root>'} ${error.message}`,
`Malformed envelope, ${error.instancePath || '<root>'} ${error.message}`,
);
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ export function throwAjvError(
const error = errors[0];
throw new TypeError(
`${error.dataPath || '<root>'} ${error.message}${
`${error.instancePath || '<root>'} ${error.message}${
error.params
? ` - ${Object.entries(error.params)
.map(([key, val]) => `${key}: ${val}`)
@@ -74,7 +74,7 @@ export function entityKindSchemaValidator<T extends Entity>(
// Only in the case where kind and/or apiVersion have enum mismatches AND
// have NO other errors, we call it a soft error.
const softCandidates = validate.errors?.filter(e =>
['/kind', '/apiVersion'].includes(e.dataPath),
['/kind', '/apiVersion'].includes(e.instancePath),
);
if (
softCandidates?.length &&
+5
View File
@@ -57,6 +57,11 @@ ignore:
reason: This is a development dependency only and is therefore acceptable
expires: 2031-09-06T17:18:55.027Z
created: 2021-09-06T17:18:55.027Z
'snyk:lic:npm:eslint-plugin-deprecation:LGPL-3.0':
- '*':
reason: This is a development dependency only and is therefore acceptable
expires: 2032-03-22T13:37:00.000Z
created: 2022-03-22T13:37:00.000Z
SNYK-JS-UNSETVALUE-2400660:
- '*':
reason: >-
+8 -7
View File
@@ -68,15 +68,16 @@
"esbuild": "^0.14.10",
"esbuild-loader": "^2.18.0",
"eslint": "^8.6.0",
"eslint-webpack-plugin": "^2.6.0",
"eslint-config-prettier": "^8.3.0",
"eslint-formatter-friendly": "^7.0.0",
"eslint-plugin-deprecation": "^1.3.2",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^25.3.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-monorepo": "^0.3.2",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-webpack-plugin": "^2.6.0",
"express": "^4.17.1",
"fork-ts-checker-webpack-plugin": "^7.0.0-alpha.8",
"fs-extra": "10.0.1",
@@ -86,13 +87,13 @@
"inquirer": "^8.2.0",
"jest": "^26.0.1",
"jest-css-modules": "^2.1.0",
"json-schema": "^0.4.0",
"jest-transform-yaml": "^1.0.0",
"json-schema": "^0.4.0",
"lodash": "^4.17.21",
"minimatch": "5.0.1",
"mini-css-extract-plugin": "^2.4.2",
"npm-packlist": "^3.0.0",
"minimatch": "5.0.1",
"node-libs-browser": "^2.2.1",
"npm-packlist": "^3.0.0",
"ora": "^5.3.0",
"postcss": "^8.1.0",
"process": "^0.11.10",
@@ -123,9 +124,9 @@
"devDependencies": {
"@backstage/backend-common": "^0.13.1",
"@backstage/config": "^1.0.0",
"@backstage/core-app-api": "^1.0.0",
"@backstage/core-components": "^0.9.2",
"@backstage/core-plugin-api": "^1.0.0",
"@backstage/core-app-api": "^1.0.0",
"@backstage/dev-utils": "^1.0.0",
"@backstage/test-utils": "^1.0.0",
"@backstage/theme": "^0.2.15",
@@ -147,9 +148,9 @@
"@types/yarnpkg__lockfile": "^1.1.4",
"del": "^6.0.0",
"mock-fs": "^5.1.0",
"msw": "^0.35.0",
"nodemon": "^2.0.2",
"ts-node": "^10.0.0",
"msw": "^0.35.0"
"ts-node": "^10.0.0"
},
"peerDependencies": {
"@microsoft/api-extractor": "^7.19.2"
+142
View File
@@ -0,0 +1,142 @@
/*
* 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 { paths } from '../lib/paths';
import { ESLint } from 'eslint';
import { join as joinPath, basename } from 'path';
import fs from 'fs-extra';
import { isChildPath } from '@backstage/cli-common';
import { PackageGraph } from '../lib/monorepo';
function isTestPath(filePath: string) {
if (!isChildPath(joinPath(paths.targetDir, 'src'), filePath)) {
return true;
}
const name = basename(filePath);
return (
name.startsWith('setupTests.') ||
name.includes('.test.') ||
name.includes('.stories.')
);
}
export async function command() {
const pkgJsonPath = paths.resolveTarget('package.json');
const pkg = await fs.readJson(pkgJsonPath);
if (pkg.workspaces) {
throw new Error(
'Adding dependencies to the workspace root is not supported',
);
}
const packages = await PackageGraph.listTargetPackages();
const localPackageVersions = new Map(
packages.map(p => [p.packageJson.name, p.packageJson.version]),
);
const eslint = new ESLint({
cwd: paths.targetDir,
overrideConfig: {
plugins: ['monorepo'],
rules: {
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: [
`!${joinPath(paths.targetDir, 'src/**')}`,
joinPath(paths.targetDir, 'src/**/*.test.*'),
joinPath(paths.targetDir, 'src/**/*.stories.*'),
joinPath(paths.targetDir, 'src/setupTests.*'),
],
optionalDependencies: true,
peerDependencies: true,
bundledDependencies: true,
},
],
},
},
extensions: ['jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
const results = await eslint.lintFiles(['.']);
const addedDeps = new Set<string>();
const addedDevDeps = new Set<string>();
const removedDevDeps = new Set<string>();
for (const result of results) {
for (const message of result.messages) {
// Just in case
if (message.ruleId !== 'import/no-extraneous-dependencies') {
continue;
}
const match = message.message.match(/^'([^']*)' should be listed/);
if (!match) {
continue;
}
const packageName = match[1];
if (!localPackageVersions.has(packageName)) {
continue;
}
if (message.message.endsWith('not devDependencies.')) {
addedDeps.add(packageName);
removedDevDeps.add(packageName);
} else if (isTestPath(result.filePath)) {
addedDevDeps.add(packageName);
} else {
addedDeps.add(packageName);
}
}
}
if (addedDeps.size || addedDevDeps.size || removedDevDeps.size) {
for (const name of addedDeps) {
if (!pkg.dependencies) {
pkg.dependencies = {};
}
pkg.dependencies[name] = `^${localPackageVersions.get(name)}`;
}
for (const name of addedDevDeps) {
if (!pkg.devDependencies) {
pkg.devDependencies = {};
}
pkg.devDependencies[name] = `^${localPackageVersions.get(name)}`;
}
for (const name of removedDevDeps) {
delete pkg.devDependencies[name];
}
if (Object.keys(pkg.devDependencies).length === 0) {
delete pkg.devDependencies;
}
if (pkg.dependencies) {
pkg.dependencies = Object.fromEntries(
Object.entries(pkg.dependencies).sort(([a], [b]) => a.localeCompare(b)),
);
}
if (pkg.devDependencies) {
pkg.devDependencies = Object.fromEntries(
Object.entries(pkg.devDependencies).sort(([a], [b]) =>
a.localeCompare(b),
),
);
}
await fs.writeJson(pkgJsonPath, pkg, { spaces: 2 });
}
}
+14
View File
@@ -59,6 +59,14 @@ export function registerRepoCommand(program: CommanderStatic) {
)
.option('--fix', 'Attempt to automatically fix violations')
.action(lazy(() => import('./repo/lint').then(m => m.command)));
command
.command('list-deprecations', { hidden: true })
.description('List deprecations. [EXPERIMENTAL]')
.option('--json', 'Output as JSON')
.action(
lazy(() => import('./repo/list-deprecations').then(m => m.command)),
);
}
export function registerScriptCommand(program: CommanderStatic) {
@@ -125,6 +133,12 @@ export function registerScriptCommand(program: CommanderStatic) {
.description('Run tests, forwarding args to Jest, defaulting to watch mode')
.action(lazy(() => import('./testCommand').then(m => m.default)));
command
.command('fix', { hidden: true })
.description('Applies automated fixes to the package. [EXPERIMENTAL]')
.option('--deps', 'Only fix monorepo dependencies in package.json')
.action(lazy(() => import('./fix').then(m => m.command)));
command
.command('clean')
.description('Delete cache directories')
+1 -1
View File
@@ -63,7 +63,7 @@ export default async (cmd: Command) => {
const data = await readPluginData();
const templateFiles = await diffTemplateFiles('default-plugin', data);
await handleAllFiles(fileHandlers, templateFiles, promptFunc);
await finalize();
finalize();
};
// Reads templating data from the existing plugin
@@ -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 chalk from 'chalk';
import { ESLint } from 'eslint';
import { Command } from 'commander';
import { join as joinPath, relative as relativePath } from 'path';
import { paths } from '../../lib/paths';
import { PackageGraph } from '../../lib/monorepo';
export async function command(cmd: Command) {
const packages = await PackageGraph.listTargetPackages();
const eslint = new ESLint({
cwd: paths.targetDir,
overrideConfig: {
plugins: ['deprecation'],
rules: {
'deprecation/deprecation': 'error',
},
parserOptions: {
project: [paths.resolveTargetRoot('tsconfig.json')],
},
},
extensions: ['jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
const { stderr } = process;
if (stderr.isTTY) {
stderr.write('Initializing TypeScript...');
}
const deprecations = [];
for (const [index, pkg] of packages.entries()) {
const results = await eslint.lintFiles(joinPath(pkg.dir, 'src'));
for (const result of results) {
for (const message of result.messages) {
if (message.ruleId !== 'deprecation/deprecation') {
continue;
}
const path = relativePath(paths.targetRoot, result.filePath);
deprecations.push({
path,
message: message.message,
line: message.line,
column: message.column,
});
}
}
if (stderr.isTTY) {
stderr.clearLine(0);
stderr.cursorTo(0);
stderr.write(`Scanning packages ${index + 1}/${packages.length}`);
}
}
if (stderr.isTTY) {
stderr.clearLine(0);
stderr.cursorTo(0);
}
if (cmd.json) {
console.log(JSON.stringify(deprecations, null, 2));
} else {
for (const d of deprecations) {
const location = `${d.path}:${d.line}:${d.column}`;
const wrappedMessage = d.message.replace(/\r?\n\s*/g, ' ');
console.log(`${location} - ${chalk.yellow(wrappedMessage)}`);
}
}
if (deprecations.length > 0) {
process.exit(1);
}
}
+1 -1
View File
@@ -39,7 +39,7 @@
"@backstage/errors": "^1.0.0",
"@backstage/types": "^1.0.0",
"@types/json-schema": "^7.0.6",
"ajv": "^7.0.3",
"ajv": "^8.10.0",
"chokidar": "^3.5.2",
"fs-extra": "10.0.1",
"json-schema": "^0.4.0",
@@ -32,9 +32,9 @@ describe('compileConfigSchemas', () => {
errors: [
{
keyword: 'type',
dataPath: '/a',
instancePath: '/a',
schemaPath: '#/properties/a/type',
message: 'should be string',
message: 'must be string',
params: { type: 'string' },
},
],
@@ -46,9 +46,9 @@ describe('compileConfigSchemas', () => {
errors: [
{
keyword: 'type',
dataPath: '/b',
instancePath: '/b',
schemaPath: '#/properties/b/type',
message: 'should be number',
message: 'must be number',
params: { type: 'number' },
},
],
@@ -57,11 +57,11 @@ export function compileConfigSchemas(
},
compile(visibility: ConfigVisibility) {
return (_data, context) => {
if (context?.dataPath === undefined) {
if (context?.instancePath === undefined) {
return false;
}
if (visibility && visibility !== 'backend') {
const normalizedPath = context.dataPath.replace(
const normalizedPath = context.instancePath.replace(
/\['?(.*?)'?\]/g,
(_, segment) => `/${segment}`,
);
@@ -77,10 +77,10 @@ export function compileConfigSchemas(
metaSchema: { type: 'string' },
compile(deprecationDescription: string) {
return (_data, context) => {
if (context?.dataPath === undefined) {
if (context?.instancePath === undefined) {
return false;
}
const normalizedPath = context.dataPath.replace(
const normalizedPath = context.instancePath.replace(
/\['?(.*?)'?\]/g,
(_, segment) => `/${segment}`,
);
@@ -244,21 +244,21 @@ describe('filterErrorsByVisibility', () => {
const errors = [
{
keyword: 'something',
dataPath: '/a',
instancePath: '/a',
schemaPath: '#/properties/a/something',
params: {},
message: 'a',
},
{
keyword: 'something',
dataPath: '/b',
instancePath: '/b',
schemaPath: '#/properties/b/something',
params: {},
message: 'b',
},
{
keyword: 'something',
dataPath: '/c',
instancePath: '/c',
schemaPath: '#/properties/c/something',
params: {},
message: 'c',
@@ -314,35 +314,35 @@ describe('filterErrorsByVisibility', () => {
const errors = [
{
keyword: 'type',
dataPath: '/a',
instancePath: '/a',
schemaPath: '#/properties/a/type',
params: { type: 'number' },
message: 'a',
},
{
keyword: 'type',
dataPath: '/b',
instancePath: '/b',
schemaPath: '#/properties/b/type',
params: { type: 'string' },
message: 'b',
},
{
keyword: 'type',
dataPath: '/c',
instancePath: '/c',
schemaPath: '#/properties/c/type',
params: { type: 'array' },
message: 'c',
},
{
keyword: 'type',
dataPath: '/c',
instancePath: '/c',
schemaPath: '#/properties/c/type',
params: { type: 'object' },
message: 'd',
},
{
keyword: 'type',
dataPath: '/c',
instancePath: '/c',
schemaPath: '#/properties/c/type',
params: { type: 'null' },
message: 'e',
@@ -373,21 +373,21 @@ describe('filterErrorsByVisibility', () => {
const errors = [
{
keyword: 'required',
dataPath: '/a',
instancePath: '/a',
schemaPath: '#/properties/o/required',
params: { missingProperty: 'a' },
message: 'a',
},
{
keyword: 'required',
dataPath: '/b',
instancePath: '/b',
schemaPath: '#/properties/o/required',
params: { missingProperty: 'b' },
message: 'b',
},
{
keyword: 'required',
dataPath: '/c',
instancePath: '/c',
schemaPath: '#/properties/o/required',
params: { missingProperty: 'c' },
message: 'c',
@@ -173,7 +173,7 @@ export function filterErrorsByVisibility(
}
const vis =
visibilityByDataPath.get(error.dataPath) ?? DEFAULT_CONFIG_VISIBILITY;
visibilityByDataPath.get(error.instancePath) ?? DEFAULT_CONFIG_VISIBILITY;
return vis && includeVisibilities.includes(vis);
});
}
@@ -105,7 +105,7 @@ describe('loadConfigSchema', () => {
expect(() =>
schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]),
).toThrow(
'Config validation failed, Config should be string { type=string } at /key1',
'Config validation failed, Config must be string { type=string } at /key1',
);
await expect(
@@ -142,7 +142,7 @@ describe('loadConfigSchema', () => {
];
expect(() => schema.process(configs)).toThrow(
'Config validation failed, Config should be number { type=number } at /key2',
'Config validation failed, Config must be number { type=number } at /key2',
);
expect(schema.process(configs, { visibility: ['frontend'] })).toEqual([
{
@@ -151,7 +151,7 @@ describe('loadConfigSchema', () => {
},
]);
expect(() => schema.process(configs, { visibility: ['secret'] })).toThrow(
'Config validation failed, Config should be number { type=number } at /key2',
'Config validation failed, Config must be number { type=number } at /key2',
);
});
@@ -203,12 +203,12 @@ describe('loadConfigSchema', () => {
},
]);
expect(() => schema.process(mkConfig({ y: 1 }))).toThrow(
'Config validation failed, Config should be string { type=string } at /nested/0/y',
'Config validation failed, Config must be string { type=string } at /nested/0/y',
);
expect(() =>
schema.process(mkConfig({ y: 1 }), { visibility: ['frontend'] }),
).toThrow(
'Config validation failed, Config should be string { type=string } at /nested/0/y',
'Config validation failed, Config must be string { type=string } at /nested/0/y',
);
expect(
schema.process(mkConfig({ x: 'a' }), { visibility: ['frontend'] }),
@@ -229,7 +229,7 @@ describe('loadConfigSchema', () => {
expect(() =>
schema.process(mkConfig({ y: 'aaaa' }), { visibility: ['frontend'] }),
).toThrow(
'Config validation failed, Config should match pattern "^...$" { pattern=^...$ } at /nested/0/y',
'Config validation failed, Config must match pattern "^...$" { pattern=^...$ } at /nested/0/y',
);
// This is a bit of an edge case where we have a structural error, these should always be reported
@@ -238,7 +238,7 @@ describe('loadConfigSchema', () => {
visibility: ['frontend'],
}),
).toThrow(
'Config validation failed, Config should be array { type=array } at /nested',
'Config validation failed, Config must be array { type=array } at /nested',
);
});
});
@@ -273,7 +273,7 @@ describe('loadConfigSchema', () => {
visibility: ['frontend'],
}),
).toThrow(
"Config should have required property 'x a' { missingProperty=x a } at /other",
"Config must have required property 'x a' { missingProperty=x a } at /other",
);
});
});
@@ -41,11 +41,11 @@ export type LoadConfigSchemaOptions =
};
function errorsToError(errors: ValidationError[]): Error {
const messages = errors.map(({ dataPath, message, params }) => {
const messages = errors.map(({ instancePath, message, params }) => {
const paramStr = Object.entries(params)
.map(([name, value]) => `${name}=${value}`)
.join(' ');
return `Config ${message || ''} { ${paramStr} } at ${dataPath}`;
return `Config ${message || ''} { ${paramStr} } at ${instancePath}`;
});
const error = new Error(`Config validation failed, ${messages.join('; ')}`);
(error as any).messages = messages;
@@ -53,7 +53,7 @@ export const DEFAULT_CONFIG_VISIBILITY: ConfigVisibility = 'backend';
*/
export type ValidationError = {
keyword: string;
dataPath: string;
instancePath: string;
schemaPath: string;
params: Record<string, any>;
propertyName?: string;
+1 -1
View File
@@ -226,7 +226,7 @@ export async function loadConfig(
}
}
const envConfigs = await readEnvConfig(process.env);
const envConfigs = readEnvConfig(process.env);
const watchConfigFile = (watchProp: LoadConfigOptionsWatch) => {
let watchedFiles = Array.from(loadedPaths);
@@ -90,4 +90,32 @@ describe('PluginProtocolResolverFetchMiddleware', () => {
expect(resolve).toHaveBeenLastCalledWith(host);
},
);
it('properly supports transferring request bodies too', async () => {
const resolve = jest.fn();
const discoveryApi = { getBaseUrl: resolve } as unknown as DiscoveryApi;
const middleware = new PluginProtocolResolverFetchMiddleware(discoveryApi);
const inner = jest.fn();
const outer = middleware.apply(inner);
resolve.mockResolvedValue('https://elsewhere.com');
await outer('plugin://a', {
method: 'POST',
body: '123',
});
expect(inner.mock.calls[0][0]).toBe('https://elsewhere.com');
expect(inner.mock.calls[0][1].body).toBe('123');
await outer(
new Request('plugin://a', {
method: 'POST',
body: '123',
}),
);
expect(inner.mock.calls[1][0]).toBe('https://elsewhere.com');
expect(inner.mock.calls[1][1].body).toEqual(Buffer.from('123', 'utf8'));
});
});
@@ -55,7 +55,7 @@ export class PluginProtocolResolverFetchMiddleware implements FetchMiddleware {
}
const target = `${join(base, pathname)}${search}${hash}`;
return next(target, request);
return next(target, typeof input === 'string' ? init : input);
};
}
}
@@ -185,7 +185,7 @@ describe('GheAuth AuthSessionStore', () => {
await expect(
withLogCollector(async () => {
await secondStore.setSession('no' as any);
secondStore.setSession('no' as any);
}),
).resolves.toMatchObject({
warn: [
+1
View File
@@ -35,6 +35,7 @@ export type AwsS3IntegrationConfig = {
accessKeyId?: string;
secretAccessKey?: string;
roleArn?: string;
externalId?: string;
};
// @public
+6
View File
@@ -225,6 +225,12 @@ export interface Config {
* @visibility backend
*/
roleArn?: string;
/**
* External ID to use when assuming role
* @visibility backend
*/
externalId?: string;
}>;
};
}
+7
View File
@@ -59,6 +59,11 @@ export type AwsS3IntegrationConfig = {
* (Optional) ARN of role to be assumed
*/
roleArn?: string;
/**
* (Optional) External ID to use when assuming role
*/
externalId?: string;
};
/**
@@ -98,6 +103,7 @@ export function readAwsS3IntegrationConfig(
const accessKeyId = config.getOptionalString('accessKeyId');
const secretAccessKey = config.getOptionalString('secretAccessKey');
const roleArn = config.getOptionalString('roleArn');
const externalId = config.getOptionalString('externalId');
return {
host,
@@ -106,6 +112,7 @@ export function readAwsS3IntegrationConfig(
accessKeyId,
secretAccessKey,
roleArn,
externalId,
};
}
@@ -48,14 +48,13 @@ describe('wrapInTestApp', () => {
await Promise.resolve();
});
expect(error).toEqual([
expect.stringMatching(
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
expect(
error.some(e =>
e.includes(
'Warning: An update to %s inside a test was not wrapped in act(...)',
),
),
expect.stringMatching(
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
),
]);
).toBeTruthy();
});
it('should render a component in a test app without warning about missing act()', async () => {
+8
View File
@@ -1,5 +1,13 @@
# @backstage/plugin-auth-backend
## 0.12.3
### Patch Changes
- Fix migrations to do the right thing on sqlite databases, and reapply the column type fix for those who are _not_ on sqlite databases.
Reconstruction of #10317 in the form of a patch release instead.
## 0.12.2
### Patch Changes
@@ -21,7 +21,7 @@
*/
exports.up = async function up(knex) {
// Sqlite does not support alter column.
if (knex.client.config.client.includes('sqlite3')) {
if (!knex.client.config.client.includes('sqlite3')) {
await knex.schema.alterTable('signing_keys', table => {
table
.timestamp('created_at', { useTz: true, precision: 0 })
@@ -38,7 +38,7 @@ exports.up = async function up(knex) {
*/
exports.down = async function down(knex) {
// Sqlite does not support alter column.
if (knex.client.config.client.includes('sqlite3')) {
if (!knex.client.config.client.includes('sqlite3')) {
await knex.schema.alterTable('signing_keys', table => {
table
.timestamp('created_at', { useTz: false, precision: 0 })
@@ -0,0 +1,58 @@
/*
* 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.
*/
// @ts-check
// NOTE: This may look like a plain duplicate of the previous one, but that
// file had a bug added when improving sqlite driver support:
// https://github.com/backstage/backstage/pull/10053/files#diff-30bb343265e71ca2f1cdcccd5ac8fdbb2a597507c5531bf26945059783377b15R24
// Since the old file was released to end users, those who created a new
// Backstage app specifically for PostgreSQL since the release will be missing
// this fix on their table. So we re-apply it.
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
// Sqlite does not support alter column.
if (!knex.client.config.client.includes('sqlite3')) {
await knex.schema.alterTable('signing_keys', table => {
table
.timestamp('created_at', { useTz: true, precision: 0 })
.notNullable()
.defaultTo(knex.fn.now())
.comment('The creation time of the key')
.alter({ alterType: true });
});
}
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
// Sqlite does not support alter column.
if (!knex.client.config.client.includes('sqlite3')) {
await knex.schema.alterTable('signing_keys', table => {
table
.timestamp('created_at', { useTz: false, precision: 0 })
.notNullable()
.defaultTo(knex.fn.now())
.comment('The creation time of the key')
.alter({ alterType: true });
});
}
};
@@ -30,7 +30,7 @@ describe('BitbucketRepositoryParser', () => {
presence: 'optional',
}),
];
const actual = await defaultRepositoryParser({
const actual = defaultRepositoryParser({
target: `${browseUrl}${path}`,
});
@@ -164,8 +164,9 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
const { toAdd, toUpsert, toRemove } = await this.createDelta(tx, options);
if (toRemove.length) {
// TODO(freben): Batch split, to not hit variable limits?
/*
let removedCount = 0;
for (const refs of lodash.chunk(toRemove, 1000)) {
/*
WITH RECURSIVE
-- All the nodes that can be reached downwards from our root
descendants(root_id, entity_ref) AS (
@@ -200,78 +201,79 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
-- Exclude all lines that had such a foreign ancestor
WHERE ancestors.root_id IS NULL;
*/
const removedCount = await tx<DbRefreshStateRow>('refresh_state')
.whereIn('entity_ref', function orphanedEntityRefs(orphans) {
return (
orphans
// All the nodes that can be reached downwards from our root
.withRecursive('descendants', function descendants(outer) {
return outer
.select({ root_id: 'id', entity_ref: 'target_entity_ref' })
.from('refresh_state_references')
.where('source_key', options.sourceKey)
.whereIn('target_entity_ref', toRemove)
.union(function recursive(inner) {
return inner
.select({
root_id: 'descendants.root_id',
entity_ref:
'refresh_state_references.target_entity_ref',
})
.from('descendants')
.join('refresh_state_references', {
'descendants.entity_ref':
'refresh_state_references.source_entity_ref',
});
});
})
// All the nodes that can be reached upwards from the descendants
.withRecursive('ancestors', function ancestors(outer) {
return outer
.select({
root_id: tx.raw('CAST(NULL as INT)', []),
via_entity_ref: 'entity_ref',
to_entity_ref: 'entity_ref',
})
.from('descendants')
.union(function recursive(inner) {
return inner
.select({
root_id: tx.raw(
'CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END',
[],
),
via_entity_ref: 'source_entity_ref',
to_entity_ref: 'ancestors.to_entity_ref',
})
.from('ancestors')
.join('refresh_state_references', {
target_entity_ref: 'ancestors.via_entity_ref',
});
});
})
// Start out with all of the descendants
.select('descendants.entity_ref')
.from('descendants')
// Expand with all ancestors that point to those, but aren't the current root
.leftOuterJoin('ancestors', function keepaliveRoots() {
this.on(
'ancestors.to_entity_ref',
'=',
'descendants.entity_ref',
);
this.andOnNotNull('ancestors.root_id');
this.andOn('ancestors.root_id', '!=', 'descendants.root_id');
})
.whereNull('ancestors.root_id')
);
})
.delete();
removedCount += await tx<DbRefreshStateRow>('refresh_state')
.whereIn('entity_ref', function orphanedEntityRefs(orphans) {
return (
orphans
// All the nodes that can be reached downwards from our root
.withRecursive('descendants', function descendants(outer) {
return outer
.select({ root_id: 'id', entity_ref: 'target_entity_ref' })
.from('refresh_state_references')
.where('source_key', options.sourceKey)
.whereIn('target_entity_ref', refs)
.union(function recursive(inner) {
return inner
.select({
root_id: 'descendants.root_id',
entity_ref:
'refresh_state_references.target_entity_ref',
})
.from('descendants')
.join('refresh_state_references', {
'descendants.entity_ref':
'refresh_state_references.source_entity_ref',
});
});
})
// All the nodes that can be reached upwards from the descendants
.withRecursive('ancestors', function ancestors(outer) {
return outer
.select({
root_id: tx.raw('CAST(NULL as INT)', []),
via_entity_ref: 'entity_ref',
to_entity_ref: 'entity_ref',
})
.from('descendants')
.union(function recursive(inner) {
return inner
.select({
root_id: tx.raw(
'CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END',
[],
),
via_entity_ref: 'source_entity_ref',
to_entity_ref: 'ancestors.to_entity_ref',
})
.from('ancestors')
.join('refresh_state_references', {
target_entity_ref: 'ancestors.via_entity_ref',
});
});
})
// Start out with all of the descendants
.select('descendants.entity_ref')
.from('descendants')
// Expand with all ancestors that point to those, but aren't the current root
.leftOuterJoin('ancestors', function keepaliveRoots() {
this.on(
'ancestors.to_entity_ref',
'=',
'descendants.entity_ref',
);
this.andOnNotNull('ancestors.root_id');
this.andOn('ancestors.root_id', '!=', 'descendants.root_id');
})
.whereNull('ancestors.root_id')
);
})
.delete();
await tx<DbRefreshStateReferencesRow>('refresh_state_references')
.where('source_key', '=', options.sourceKey)
.whereIn('target_entity_ref', toRemove)
.delete();
await tx<DbRefreshStateReferencesRow>('refresh_state_references')
.where('source_key', '=', options.sourceKey)
.whereIn('target_entity_ref', refs)
.delete();
}
this.options.logger.debug(
`removed, ${removedCount} entities: ${JSON.stringify(toRemove)}`,
@@ -69,6 +69,7 @@ export const EntitySearchBar = () => {
<Toolbar className={classes.searchToolbar}>
<FormControl>
<Input
aria-label="search"
id="input-with-icon-adornment"
className={classes.input}
placeholder="Search"
@@ -43,6 +43,7 @@ export const FavoriteEntity = (props: FavoriteEntityProps) => {
);
return (
<IconButton
aria-label="favorite"
color="inherit"
{...props}
onClick={() => toggleStarredEntity()}
@@ -34,6 +34,7 @@ const mockedJenkinsClient = {
const mockedJenkins = jenkins as jest.Mocked<any>;
mockedJenkins.mockReturnValue(mockedJenkinsClient);
const resourceRef = 'component:default/example-component';
const jobFullName = 'example-jobName/foo';
const buildNumber = 19;
const jenkinsInfo: JenkinsInfo = {
@@ -413,7 +414,7 @@ describe('JenkinsApi', () => {
);
});
it('buildProject', async () => {
await jenkinsApi.buildProject(jenkinsInfo, jobFullName);
await jenkinsApi.buildProject(jenkinsInfo, jobFullName, resourceRef);
expect(mockedJenkins).toHaveBeenCalledWith({
baseUrl: jenkinsInfo.baseUrl,
@@ -431,7 +432,7 @@ describe('JenkinsApi', () => {
]);
await expect(() =>
jenkinsApi.buildProject(jenkinsInfo, jobFullName),
jenkinsApi.buildProject(jenkinsInfo, jobFullName, resourceRef),
).rejects.toThrow(NotAllowedError);
});
@@ -442,7 +443,7 @@ describe('JenkinsApi', () => {
},
]);
await jenkinsApi.buildProject(jenkinsInfo, jobFullName);
await jenkinsApi.buildProject(jenkinsInfo, jobFullName, resourceRef);
expect(mockedJenkins).toHaveBeenCalledWith({
baseUrl: jenkinsInfo.baseUrl,
headers: jenkinsInfo.headers,
@@ -453,7 +454,7 @@ describe('JenkinsApi', () => {
it('buildProject with crumbIssuer option', async () => {
const info: JenkinsInfo = { ...jenkinsInfo, crumbIssuer: true };
await jenkinsApi.buildProject(info, jobFullName);
await jenkinsApi.buildProject(info, jobFullName, resourceRef);
expect(mockedJenkins).toHaveBeenCalledWith({
baseUrl: jenkinsInfo.baseUrl,
@@ -139,7 +139,7 @@ export class JenkinsApiImpl {
async buildProject(
jenkinsInfo: JenkinsInfo,
jobFullName: string,
resourceRef?: string,
resourceRef: string,
options?: { token?: string },
) {
const client = await JenkinsApiImpl.getClient(jenkinsInfo);
+23
View File
@@ -20,6 +20,7 @@ import { JsonObject } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
import { JsonValue } from '@backstage/types';
import { Observable } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -126,6 +127,22 @@ export type LogEvent = {
taskId: string;
};
// @alpha
export type NextRouterProps = {
components?: {
TemplateCardComponent?: React_2.ComponentType<{
template: TemplateEntityV1beta3;
}>;
TaskPageComponent?: React_2.ComponentType<{}>;
};
groups?: TemplateGroupFilter[];
};
// @alpha
export const NextScaffolderPage: (
props: PropsWithChildren<NextRouterProps>,
) => JSX.Element;
// @public
export const OwnedEntityPickerFieldExtension: FieldExtensionComponent<
string,
@@ -354,6 +371,12 @@ export type TaskPageProps = {
loadingText?: string;
};
// @alpha (undocumented)
export type TemplateGroupFilter = {
title?: React_2.ReactNode;
filter: (entity: Entity) => boolean;
};
// @public
export type TemplateParameterSchema = {
title: string;
+5 -3
View File
@@ -9,7 +9,8 @@
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
"types": "dist/index.d.ts",
"alphaTypes": "dist/index.alpha.d.ts"
},
"backstage": {
"role": "frontend-plugin"
@@ -24,7 +25,7 @@
"backstage"
],
"scripts": {
"build": "backstage-cli package build",
"build": "backstage-cli package build --experimental-type-build",
"start": "backstage-cli package start",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
@@ -95,6 +96,7 @@
"msw": "^0.35.0"
},
"files": [
"dist"
"dist",
"alpha"
]
}
+5
View File
@@ -55,6 +55,11 @@ export {
RepoUrlPickerFieldExtension,
ScaffolderPage,
scaffolderPlugin,
NextScaffolderPage,
} from './plugin';
export * from './components';
export type { TaskPageProps } from './components/TaskPage';
/** next exports */
export type { NextRouterProps } from './next';
export type { TemplateGroupFilter } from './next';
@@ -0,0 +1,83 @@
/*
* Copyright 2022 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 { TemplateListPage } from '../TemplateListPage';
import { TemplateWizardPage } from '../TemplateWizardPage';
import { Router } from './Router';
import { renderInTestApp } from '@backstage/test-utils';
import {
createScaffolderFieldExtension,
ScaffolderFieldExtensions,
} from '../../extensions';
import { scaffolderPlugin } from '../../plugin';
jest.mock('../TemplateListPage', () => ({
TemplateListPage: jest.fn(() => null),
}));
jest.mock('../TemplateWizardPage', () => ({
TemplateWizardPage: jest.fn(() => null),
}));
describe('Router', () => {
beforeEach(() => {
(TemplateWizardPage as jest.Mock).mockClear();
(TemplateListPage as jest.Mock).mockClear();
});
describe('/', () => {
it('should render the TemplateListPage', async () => {
await renderInTestApp(<Router />);
expect(TemplateListPage).toHaveBeenCalled();
});
});
describe('/templates/:templateName', () => {
it('should render the TemplateWizard page', async () => {
await renderInTestApp(<Router />, { routeEntries: ['/templates/foo'] });
expect(TemplateWizardPage).toHaveBeenCalled();
});
it('should extract the fieldExtensions and pass them through', async () => {
const mockComponent = () => null;
const CustomFieldExtension = scaffolderPlugin.provide(
createScaffolderFieldExtension({
name: 'custom',
component: mockComponent,
}),
);
await renderInTestApp(
<Router>
<ScaffolderFieldExtensions>
<CustomFieldExtension />
</ScaffolderFieldExtensions>
</Router>,
{ routeEntries: ['/templates/foo'] },
);
const mock = TemplateWizardPage as jest.Mock;
const [{ customFieldExtensions }] = mock.mock.calls[0];
expect(customFieldExtensions).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'custom', component: mockComponent }),
]),
);
});
});
});
@@ -0,0 +1,99 @@
/*
* Copyright 2022 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, { PropsWithChildren } from 'react';
import { Routes, Route, useOutlet } from 'react-router';
import { TemplateListPage } from '../TemplateListPage';
import { SecretsContextProvider } from '../TemplateWizardPage/SecretsContext';
import { TemplateWizardPage } from '../TemplateWizardPage';
import {
FieldExtensionOptions,
FIELD_EXTENSION_WRAPPER_KEY,
FIELD_EXTENSION_KEY,
DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS,
} from '../../extensions';
import { useElementFilter } from '@backstage/core-plugin-api';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '../TemplateListPage/TemplateGroups';
/**
* The Props for the Scaffolder Router
*
* @alpha
*/
export type NextRouterProps = {
components?: {
TemplateCardComponent?: React.ComponentType<{
template: TemplateEntityV1beta3;
}>;
TaskPageComponent?: React.ComponentType<{}>;
};
groups?: TemplateGroupFilter[];
};
/**
* The Scaffolder Router
*
* @alpha
*/
export const Router = (props: PropsWithChildren<NextRouterProps>) => {
const { components: { TemplateCardComponent } = {} } = props;
const outlet = useOutlet() || props.children;
const customFieldExtensions = useElementFilter(outlet, elements =>
elements
.selectByComponentData({
key: FIELD_EXTENSION_WRAPPER_KEY,
})
.findComponentData<FieldExtensionOptions>({
key: FIELD_EXTENSION_KEY,
}),
);
const fieldExtensions = [
...customFieldExtensions,
...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter(
({ name }) =>
!customFieldExtensions.some(
customFieldExtension => customFieldExtension.name === name,
),
),
];
return (
<Routes>
<Route
path="/"
element={
<TemplateListPage
TemplateCardComponent={TemplateCardComponent}
groups={props.groups}
/>
}
/>
<Route
path="/templates/:templateName"
element={
<SecretsContextProvider>
<TemplateWizardPage customFieldExtensions={fieldExtensions} />
</SecretsContextProvider>
}
/>
</Routes>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { Router } from './Router';
export type { NextRouterProps } from './Router';
@@ -0,0 +1,152 @@
/*
* Copyright 2022 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 { useEntityTypeFilter } from '@backstage/plugin-catalog-react';
import { CategoryPicker } from './CategoryPicker';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { alertApiRef } from '@backstage/core-plugin-api';
import { fireEvent } from '@testing-library/react';
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntityTypeFilter: jest.fn(),
}));
describe('CategoryPicker', () => {
const mockAlertApi = { post: jest.fn() };
beforeEach(() => {
mockAlertApi.post.mockClear();
});
it('should post the error to errorApi if an errors is returned', async () => {
(useEntityTypeFilter as jest.Mock).mockReturnValue({
error: new Error('something broked'),
});
await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
expect(mockAlertApi.post).toHaveBeenCalledWith({
message: expect.stringContaining('something broked'),
severity: 'error',
});
});
it('should render loading if the hook is loading', async () => {
(useEntityTypeFilter as jest.Mock).mockReturnValue({
loading: true,
});
const { findByTestId } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
expect(await findByTestId('progress')).toBeInTheDocument();
});
it('should not render if there is no available types', async () => {
(useEntityTypeFilter as jest.Mock).mockReturnValue({
availableTypes: null,
});
const { queryByText } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
expect(queryByText('Categories')).not.toBeInTheDocument();
});
it('renders the autocomplete with the availableTypes', async () => {
const mockAvailableTypes = ['foo', 'bar'];
(useEntityTypeFilter as jest.Mock).mockReturnValue({
availableTypes: mockAvailableTypes,
});
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
const openButton = getByRole('button', { name: 'Open' });
openButton.click();
expect(getByRole('checkbox', { name: 'Foo' })).toBeInTheDocument();
expect(getByRole('checkbox', { name: 'Bar' })).toBeInTheDocument();
});
it('should call setSelectedTypes when one of the options are called', async () => {
const mockAvailableTypes = ['foo', 'bar'];
const mockSetSelectedTypes = jest.fn();
(useEntityTypeFilter as jest.Mock).mockReturnValue({
availableTypes: mockAvailableTypes,
setSelectedTypes: mockSetSelectedTypes,
});
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
const openButton = getByRole('button', { name: 'Open' });
await fireEvent(openButton, new MouseEvent('click', { bubbles: true }));
const fooCheckbox = getByRole('checkbox', { name: 'Foo' });
await fireEvent(fooCheckbox, new MouseEvent('click', { bubbles: true }));
expect(mockSetSelectedTypes).toHaveBeenCalledWith(['foo']);
await fireEvent(openButton, new MouseEvent('click', { bubbles: true }));
const barCheckbox = getByRole('checkbox', { name: 'Bar' });
await fireEvent(barCheckbox, new MouseEvent('click', { bubbles: true }));
expect(mockSetSelectedTypes).toHaveBeenCalledWith(['foo', 'bar']);
});
it('should render the selectedTypes already in the document', async () => {
const mockAvailableTypes = ['foo', 'bar'];
const mockSelectedTypes = ['foo'];
(useEntityTypeFilter as jest.Mock).mockReturnValue({
availableTypes: mockAvailableTypes,
selectedTypes: mockSelectedTypes,
});
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
<CategoryPicker />
</TestApiProvider>,
);
const openButton = getByRole('button', { name: 'Open' });
await fireEvent(openButton, new MouseEvent('click', { bubbles: true }));
const fooCheckbox = getByRole('checkbox', { name: 'Foo' });
expect(fooCheckbox).toBeChecked();
});
});
@@ -0,0 +1,85 @@
/*
* 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 capitalize from 'lodash/capitalize';
import { Progress } from '@backstage/core-components';
import {
Box,
Checkbox,
FormControlLabel,
TextField,
Typography,
} from '@material-ui/core';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Autocomplete } from '@material-ui/lab';
import { useEntityTypeFilter } from '@backstage/plugin-catalog-react';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
/**
* The Category Picker that is rendered on the left side for picking
* categories and filtering the template list.
*/
export const CategoryPicker = () => {
const alertApi = useApi(alertApiRef);
const { error, loading, availableTypes, selectedTypes, setSelectedTypes } =
useEntityTypeFilter();
if (loading) return <Progress />;
if (error) {
alertApi.post({
message: `Failed to load entity types with error: ${error}`,
severity: 'error',
});
return null;
}
if (!availableTypes) return null;
return (
<Box pb={1} pt={1}>
<Typography variant="button">Categories</Typography>
<Autocomplete
multiple
aria-label="Categories"
options={availableTypes}
value={selectedTypes}
onChange={(_: object, value: string[]) => setSelectedTypes(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
label={capitalize(option)}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon />}
renderInput={params => <TextField {...params} variant="outlined" />}
/>
</Box>
);
};
@@ -0,0 +1,57 @@
/*
* Copyright 2022 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 { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { RegisterExistingButton } from './RegisterExistingButton';
import { usePermission } from '@backstage/plugin-permission-react';
jest.mock('@backstage/plugin-permission-react', () => ({
usePermission: jest.fn(),
}));
describe('RegisterExistingButton', () => {
beforeEach(() => {
(usePermission as jest.Mock).mockClear();
});
it('should not render if to is unset', async () => {
(usePermission as jest.Mock).mockReturnValue({ allowed: true });
const { queryByText } = await renderInTestApp(
<RegisterExistingButton title="Pick me" />,
);
expect(await queryByText('Pick me')).not.toBeInTheDocument();
});
it('should not render if permissions are not allowed', async () => {
(usePermission as jest.Mock).mockReturnValue({ allowed: false });
const { queryByText } = await renderInTestApp(
<RegisterExistingButton title="Pick me" to="blah" />,
);
expect(await queryByText('Pick me')).not.toBeInTheDocument();
});
it('should render the button with the text', async () => {
(usePermission as jest.Mock).mockReturnValue({ allowed: true });
const { queryByText } = await renderInTestApp(
<RegisterExistingButton title="Pick me" to="blah" />,
);
expect(await queryByText('Pick me')).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
/*
* 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 { BackstageTheme } from '@backstage/theme';
import Button from '@material-ui/core/Button';
import IconButton from '@material-ui/core/IconButton';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import React from 'react';
import { Link as RouterLink, LinkProps } from 'react-router-dom';
import AddCircleOutline from '@material-ui/icons/AddCircleOutline';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common';
import { usePermission } from '@backstage/plugin-permission-react';
/**
* Properties for {@link RegisterExistingButton}
*
* @alpha
*/
export type RegisterExistingButtonProps = {
title: string;
} & Partial<Pick<LinkProps, 'to'>>;
/**
* A button that helps users to register an existing component.
* @alpha
*/
export const RegisterExistingButton = (props: RegisterExistingButtonProps) => {
const { title, to } = props;
const { allowed } = usePermission(catalogEntityCreatePermission);
const isXSScreen = useMediaQuery<BackstageTheme>(theme =>
theme.breakpoints.down('xs'),
);
if (!to || !allowed) {
return null;
}
return isXSScreen ? (
<IconButton
component={RouterLink}
color="primary"
title={title}
size="small"
to={to}
>
<AddCircleOutline />
</IconButton>
) : (
<Button component={RouterLink} variant="contained" color="primary" to={to}>
{title}
</Button>
);
};
@@ -0,0 +1,188 @@
/*
* Copyright 2022 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 { fireEvent, render } from '@testing-library/react';
import { CardHeader } from './CardHeader';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import {
MockStorageApi,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { starredEntitiesApiRef } from '@backstage/plugin-catalog-react';
import { DefaultStarredEntitiesApi } from '@backstage/plugin-catalog';
import Observable from 'zen-observable';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
describe('CardHeader', () => {
it('should select the correct theme from the theme provider from the header', () => {
// Can't really test what we want here.
// But we can check that we call the getPage theme with the right type of template at least.
const mockTheme = {
...lightTheme,
getPageTheme: jest.fn(lightTheme.getPageTheme),
};
render(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<ThemeProvider theme={mockTheme}>
<CardHeader
template={{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
}}
/>
</ThemeProvider>
</TestApiProvider>,
);
expect(mockTheme.getPageTheme).toHaveBeenCalledWith({ themeId: 'service' });
});
it('should render the type', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<CardHeader
template={{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
}}
/>
</TestApiProvider>,
);
expect(getByText('service')).toBeInTheDocument();
});
it('should enable favoriting of the entity', async () => {
const starredEntitiesApi = {
starredEntitie$: () => new Observable(() => {}),
toggleStarred: jest.fn(async () => {}),
};
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
};
const { getByRole } = await renderInTestApp(
<TestApiProvider apis={[[starredEntitiesApiRef, starredEntitiesApi]]}>
<CardHeader template={mockTemplate} />
</TestApiProvider>,
);
const favorite = getByRole('button', { name: 'favorite' });
await fireEvent.click(favorite);
expect(starredEntitiesApi.toggleStarred).toHaveBeenCalledWith(
stringifyEntityRef(mockTemplate),
);
});
it('should render the name of the entity', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<CardHeader
template={{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
}}
/>
</TestApiProvider>,
);
expect(getByText('bob')).toBeInTheDocument();
});
it('should render the title of the entity in favor of the name if it is provided', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<CardHeader
template={{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', title: 'Iamtitle' },
spec: {
steps: [],
type: 'service',
},
}}
/>
</TestApiProvider>,
);
expect(getByText('Iamtitle')).toBeInTheDocument();
});
});
@@ -0,0 +1,76 @@
/*
* Copyright 2022 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 { makeStyles, useTheme } from '@material-ui/core';
import { ItemCardHeader } from '@backstage/core-components';
import { BackstageTheme } from '@backstage/theme';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { FavoriteEntity } from '@backstage/plugin-catalog-react';
const useStyles = makeStyles<BackstageTheme, { cardBackgroundImage: string }>(
() => ({
header: {
backgroundImage: ({ cardBackgroundImage }) => cardBackgroundImage,
},
subtitleWrapper: {
display: 'flex',
justifyContent: 'space-between',
},
}),
);
/**
* Props for the CardHeader component
*/
export interface CardHeaderProps {
template: TemplateEntityV1beta3;
}
/**
* The Card Header with the background for the TemplateCard.
*/
export const CardHeader = (props: CardHeaderProps) => {
const {
template: {
metadata: { title, name },
spec: { type },
},
} = props;
const { getPageTheme } = useTheme<BackstageTheme>();
const themeForType = getPageTheme({ themeId: type });
const styles = useStyles({
cardBackgroundImage: themeForType.backgroundImage,
});
const SubtitleComponent = (
<div className={styles.subtitleWrapper}>
<div>{type}</div>
<div>
<FavoriteEntity entity={props.template} style={{ padding: 0 }} />
</div>
</div>
);
return (
<ItemCardHeader
title={title ?? name}
subtitle={SubtitleComponent}
classes={{ root: styles.header }}
/>
);
};
@@ -0,0 +1,239 @@
/*
* Copyright 2022 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 { DefaultStarredEntitiesApi } from '@backstage/plugin-catalog';
import {
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import {
MockStorageApi,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { TemplateCard } from './TemplateCard';
import React from 'react';
import { rootRouteRef } from '../../../routes';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
describe('TemplateCard', () => {
it('should render the card title', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByText('bob')).toBeInTheDocument();
});
it('should render the description as markdown', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', description: 'hello **test**' },
spec: {
steps: [],
type: 'service',
},
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
const description = getByText('hello');
expect(description.querySelector('strong')).toBeInTheDocument();
});
it('should render no descroption if none is provided through the template', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob' },
spec: {
steps: [],
type: 'service',
},
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByText('No description')).toBeInTheDocument();
});
it('should render the tags', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', tags: ['cpp', 'react'] },
spec: {
steps: [],
type: 'service',
},
};
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
for (const tag of mockTemplate.metadata.tags!) {
expect(getByText(tag)).toBeInTheDocument();
}
});
it('should render a link to the owner', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', tags: ['cpp', 'react'] },
spec: {
steps: [],
type: 'service',
},
relations: [
{
targetRef: 'group:default/my-test-user',
type: RELATION_OWNED_BY,
},
],
};
const { getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{
mountedRoutes: {
'/': rootRouteRef,
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
);
expect(getByRole('link', { name: 'my-test-user' })).toBeInTheDocument();
expect(getByRole('link', { name: 'my-test-user' })).toHaveAttribute(
'href',
'/catalog/group/default/my-test-user',
);
});
it('should render the choose button to navigate to the selected template', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'bob', tags: ['cpp', 'react'] },
spec: {
steps: [],
type: 'service',
},
};
const { getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{
mountedRoutes: {
'/': rootRouteRef,
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
);
expect(getByRole('button', { name: 'Choose' })).toBeInTheDocument();
expect(getByRole('button', { name: 'Choose' })).toHaveAttribute(
'href',
'/templates/bob',
);
});
});
@@ -0,0 +1,136 @@
/*
* Copyright 2022 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 { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import {
Box,
Card,
CardActions,
CardContent,
Chip,
Divider,
makeStyles,
} from '@material-ui/core';
import { CardHeader } from './CardHeader';
import { MarkdownContent, UserIcon, Button } from '@backstage/core-components';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
EntityRefLinks,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import { useRouteRef } from '@backstage/core-plugin-api';
import { selectedTemplateRouteRef } from '../../../routes';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>(theme => ({
box: {
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
'-webkit-line-clamp': 10,
'-webkit-box-orient': 'vertical',
/** to make the styles for React Markdown not leak into the description */
'& p:first-child': {
marginTop: 0,
marginBottom: theme.spacing(2),
},
},
label: {
color: theme.palette.text.secondary,
textTransform: 'uppercase',
fontWeight: 'bold',
letterSpacing: 0.5,
lineHeight: 1,
fontSize: '0.75rem',
},
margin: {
marginBottom: theme.spacing(2),
},
footer: {
display: 'flex',
justifyContent: 'space-between',
flex: 1,
alignItems: 'center',
},
ownedBy: {
display: 'flex',
alignItems: 'center',
flex: 1,
color: theme.palette.link,
},
}));
/**
* The Props for the Template Card component
* @alpha
*/
export interface TemplateCardProps {
template: TemplateEntityV1beta3;
deprecated?: boolean;
}
/**
* The Template Card component that is rendered in a list for each template
* @alpha
*/
export const TemplateCard = (props: TemplateCardProps) => {
const { template } = props;
const styles = useStyles();
const ownedByRelations = getEntityRelations(template, RELATION_OWNED_BY);
const templateRoute = useRouteRef(selectedTemplateRouteRef);
const href = templateRoute({ templateName: template.metadata.name });
return (
<Card>
<CardHeader template={template} />
<CardContent>
<Box className={styles.box}>
<MarkdownContent
content={template.metadata.description ?? 'No description'}
/>
</Box>
{(template.metadata.tags?.length ?? 0) > 0 && (
<>
<Divider className={styles.margin} />
<Box>
{template.metadata.tags?.map(tag => (
<Chip size="small" label={tag} key={tag} />
))}
</Box>
</>
)}
</CardContent>
<CardActions>
<div className={styles.footer}>
<div className={styles.ownedBy}>
{ownedByRelations.length > 0 && (
<>
<UserIcon />
<EntityRefLinks
entityRefs={ownedByRelations}
defaultKind="Group"
/>
</>
)}
</div>
<Button size="small" variant="outlined" color="primary" to={href}>
Choose
</Button>
</div>
</CardActions>
</Card>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { TemplateCard } from './TemplateCard';
export type { TemplateCardProps } from './TemplateCard';
@@ -0,0 +1,143 @@
/*
* Copyright 2022 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.
*/
jest.mock('./TemplateCard', () => ({ TemplateCard: jest.fn(() => null) }));
import React from 'react';
import { TemplateGroup } from './TemplateGroup';
import { render } from '@testing-library/react';
import { TemplateCard } from './TemplateCard';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
describe('TemplateGroup', () => {
it('should return a message when no templates are passed in', async () => {
const { getByText } = render(<TemplateGroup title="Test" templates={[]} />);
expect(
getByText(/No templates found that match your filter/),
).toBeInTheDocument();
});
it('should render a card for each template with the template being passed as a prop', () => {
const mockTemplates: TemplateEntityV1beta3[] = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test' },
spec: {
parameters: [],
steps: [],
type: 'website',
},
},
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test2' },
spec: {
parameters: [],
steps: [],
type: 'service',
},
},
];
render(<TemplateGroup title="Test" templates={mockTemplates} />);
expect(TemplateCard).toHaveBeenCalledTimes(2);
for (const template of mockTemplates) {
expect(TemplateCard).toHaveBeenCalledWith(
expect.objectContaining({ template }),
{},
);
}
});
it('should use the passed in TemplateCard prop to render the template card', () => {
const mockTemplateCardComponent = jest.fn(() => null);
const mockTemplates: TemplateEntityV1beta3[] = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test' },
spec: {
parameters: [],
steps: [],
type: 'website',
},
},
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test2' },
spec: {
parameters: [],
steps: [],
type: 'service',
},
},
];
render(
<TemplateGroup
title="Test"
templates={mockTemplates}
components={{ CardComponent: mockTemplateCardComponent }}
/>,
);
expect(mockTemplateCardComponent).toHaveBeenCalledTimes(2);
for (const template of mockTemplates) {
expect(mockTemplateCardComponent).toHaveBeenCalledWith(
expect.objectContaining({ template }),
{},
);
}
});
it('should render the title when no templates passed', () => {
const { getByText } = render(<TemplateGroup title="Test" templates={[]} />);
expect(getByText('Test')).toBeInTheDocument();
});
it('should render the title when there are templates in the list', () => {
const mockTemplates: TemplateEntityV1beta3[] = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test' },
spec: { parameters: [], steps: [], type: 'website' },
},
];
const { getByText } = render(
<TemplateGroup title="Test" templates={mockTemplates} />,
);
expect(getByText('Test')).toBeInTheDocument();
});
it('should allow for passing through a user given title component', () => {
const TitleComponent = <p>Im a custom header</p>;
const { getByText } = render(
<TemplateGroup templates={[]} title={TitleComponent} />,
);
expect(getByText('Im a custom header')).toBeInTheDocument();
});
});
@@ -0,0 +1,68 @@
/*
* Copyright 2022 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 { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import React from 'react';
import {
Content,
ContentHeader,
ItemCardGrid,
Link,
} from '@backstage/core-components';
import { Typography } from '@material-ui/core';
import { TemplateCard, TemplateCardProps } from './TemplateCard';
import { stringifyEntityRef } from '@backstage/catalog-model';
export interface TemplateGroupProps {
templates: TemplateEntityV1beta3[];
title: React.ReactNode;
components?: {
CardComponent?: React.ComponentType<TemplateCardProps>;
};
}
export const TemplateGroup = (props: TemplateGroupProps) => {
const { templates, title, components: { CardComponent } = {} } = props;
const titleComponent =
typeof title === 'string' ? <ContentHeader title={title} /> : title;
if (templates.length === 0) {
return (
<Content>
{titleComponent}
<Typography variant="body2">
No templates found that match your filter. Learn more about{' '}
<Link to="https://backstage.io/docs/features/software-templates/adding-templates">
adding templates
</Link>
.
</Typography>
</Content>
);
}
const Card = CardComponent || TemplateCard;
return (
<Content>
{titleComponent}
<ItemCardGrid>
{templates.map(template => (
<Card key={stringifyEntityRef(template)} template={template} />
))}
</ItemCardGrid>
</Content>
);
};
@@ -0,0 +1,172 @@
/*
* Copyright 2022 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.
*/
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntityList: jest.fn(),
}));
jest.mock('./TemplateGroup', () => ({
TemplateGroup: jest.fn(() => null),
}));
import React from 'react';
import { render } from '@testing-library/react';
import { useEntityList } from '@backstage/plugin-catalog-react';
import { TemplateGroups } from './TemplateGroups';
import { TestApiProvider } from '@backstage/test-utils';
import { errorApiRef } from '@backstage/core-plugin-api';
import { TemplateGroup } from './TemplateGroup';
describe('TemplateGroups', () => {
it('should return progress if the hook is loading', async () => {
(useEntityList as jest.Mock).mockReturnValue({ loading: true });
const { findByTestId } = render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
);
expect(await findByTestId('progress')).toBeInTheDocument();
});
it('should use the error api if there is an error with the retrieval of entitylist', async () => {
const mockError = new Error('tings went poop');
(useEntityList as jest.Mock).mockReturnValue({
error: mockError,
});
const errorApi = {
post: jest.fn(),
};
render(
<TestApiProvider apis={[[errorApiRef, errorApi]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
);
expect(errorApi.post).toHaveBeenCalledWith(mockError);
});
it('should return a no templates message if entities is unset', async () => {
(useEntityList as jest.Mock).mockReturnValue({
entities: null,
loading: false,
error: null,
});
const { findByText } = render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
);
expect(await findByText(/No templates found/)).toBeInTheDocument();
});
it('should return a no templates message if entities has no values in it', async () => {
(useEntityList as jest.Mock).mockReturnValue({
entities: [],
loading: false,
error: null,
});
const { findByText } = render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
);
expect(await findByText(/No templates found/)).toBeInTheDocument();
});
it('should call the template group with the components', async () => {
const mockEntities = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 't1',
},
spec: {},
},
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 't2',
},
spec: {},
},
];
(useEntityList as jest.Mock).mockReturnValue({
entities: mockEntities,
loading: false,
error: null,
});
render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[{ title: 'all', filter: () => true }]} />
</TestApiProvider>,
);
expect(TemplateGroup).toHaveBeenCalledWith(
expect.objectContaining({ templates: mockEntities }),
{},
);
});
it('should apply the filter for each group', async () => {
const mockEntities = [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 't1',
},
spec: {},
},
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 't2',
},
spec: {},
},
];
(useEntityList as jest.Mock).mockReturnValue({
entities: mockEntities,
loading: false,
error: null,
});
render(
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups
groups={[{ title: 'all', filter: e => e.metadata.name === 't1' }]}
/>
</TestApiProvider>,
);
expect(TemplateGroup).toHaveBeenCalledWith(
expect.objectContaining({ templates: [mockEntities[0]] }),
{},
);
});
});
@@ -0,0 +1,80 @@
/*
* Copyright 2022 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 { TemplateGroup } from './TemplateGroup';
import { Entity } from '@backstage/catalog-model';
import { useEntityList } from '@backstage/plugin-catalog-react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { Progress, Link } from '@backstage/core-components';
import { Typography } from '@material-ui/core';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
/**
* @alpha
*/
export type TemplateGroupFilter = {
title?: React.ReactNode;
filter: (entity: Entity) => boolean;
};
export interface TemplateGroupsProps {
groups: TemplateGroupFilter[];
TemplateCardComponent?: React.ComponentType<{
template: TemplateEntityV1beta3;
}>;
}
export const TemplateGroups = (props: TemplateGroupsProps) => {
const { loading, error, entities } = useEntityList();
const { groups, TemplateCardComponent } = props;
const errorApi = useApi(errorApiRef);
if (loading) {
return <Progress />;
}
if (error) {
errorApi.post(error);
return null;
}
if (!entities || !entities.length) {
return (
<Typography variant="body2">
No templates found that match your filter. Learn more about{' '}
<Link to="https://backstage.io/docs/features/software-templates/adding-templates">
adding templates
</Link>
.
</Typography>
);
}
return (
<>
{groups.map(({ title, filter }, index) => (
<TemplateGroup
key={index}
templates={entities.filter((e): e is TemplateEntityV1beta3 =>
filter(e),
)}
title={title}
components={{ CardComponent: TemplateCardComponent }}
/>
))}
</>
);
};
@@ -0,0 +1,138 @@
/*
* Copyright 2022 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 { DefaultStarredEntitiesApi } from '@backstage/plugin-catalog';
import {
catalogApiRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import {
MockStorageApi,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import React from 'react';
import { rootRouteRef } from '../../routes';
import { TemplateListPage } from './TemplateListPage';
describe('TemplateListPage', () => {
const mockCatalogApi = {
getEntities: async () => ({
items: [
{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'blob', tags: ['blob'] },
spec: {
type: 'service',
},
},
],
}),
getEntityFacets: async () => ({
facets: { 'spec.type': [{ value: 'service', count: 1 }] },
}),
};
it('should render the search bar for templates', async () => {
const { getByPlaceholderText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
[permissionApiRef, {}],
]}
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByPlaceholderText('Search')).toBeInTheDocument();
});
it('should render the all and starred filters', async () => {
const { getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
[permissionApiRef, {}],
]}
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByRole('menuitem', { name: 'All' })).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'Starred' })).toBeInTheDocument();
});
it('should render the category picker', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
[permissionApiRef, {}],
]}
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
);
expect(getByText('Categories')).toBeInTheDocument();
});
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should render the EntityTag picker', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
[permissionApiRef, {}],
]}
>
<TemplateListPage />
</TestApiProvider>,
);
expect(getByText('Tags')).toBeInTheDocument();
});
});
@@ -0,0 +1,100 @@
/*
* Copyright 2022 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 { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import {
Content,
ContentHeader,
Header,
Page,
SupportButton,
} from '@backstage/core-components';
import {
EntityKindPicker,
EntityListProvider,
EntitySearchBar,
EntityTagPicker,
CatalogFilterLayout,
UserListPicker,
} from '@backstage/plugin-catalog-react';
import { CategoryPicker } from './CategoryPicker';
import { RegisterExistingButton } from './RegisterExistingButton';
import { useRouteRef } from '@backstage/core-plugin-api';
import { registerComponentRouteRef } from '../../routes';
import { TemplateGroupFilter, TemplateGroups } from './TemplateGroups';
export type TemplateListPageProps = {
TemplateCardComponent?: React.ComponentType<{
template: TemplateEntityV1beta3;
}>;
groups?: TemplateGroupFilter[];
};
const defaultGroup: TemplateGroupFilter = {
title: 'All Templates',
filter: () => true,
};
export const TemplateListPage = (props: TemplateListPageProps) => {
const registerComponentLink = useRouteRef(registerComponentRouteRef);
const { TemplateCardComponent, groups = [defaultGroup] } = props;
return (
<EntityListProvider>
<Page themeId="home">
<Header
pageTitleOverride="Create a New Component"
title="Create a New Component"
subtitle="Create new software components using standard templates"
/>
<Content>
<ContentHeader title="Available Templates">
<RegisterExistingButton
title="Register Existing Component"
to={registerComponentLink && registerComponentLink()}
/>
<SupportButton>
Create new software components using standard templates. Different
templates create different kinds of components (services,
websites, documentation, ...).
</SupportButton>
</ContentHeader>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<EntitySearchBar />
<EntityKindPicker initialFilter="template" hidden />
<UserListPicker
initialFilter="all"
availableFilters={['all', 'starred']}
/>
<CategoryPicker />
<EntityTagPicker />
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<TemplateGroups
groups={groups}
TemplateCardComponent={TemplateCardComponent}
/>
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
</Content>
</Page>
</EntityListProvider>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { TemplateListPage } from './TemplateListPage';
export type { TemplateGroupFilter } from './TemplateGroups';
@@ -0,0 +1,43 @@
/*
* Copyright 2022 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, { useContext } from 'react';
import {
useTemplateSecrets,
SecretsContextProvider,
SecretsContext,
} from './SecretsContext';
import { renderHook, act } from '@testing-library/react-hooks';
describe('SecretsContext', () => {
it('should allow the setting of secrets in the context', async () => {
const { result } = renderHook(
() => ({
hook: useTemplateSecrets(),
context: useContext(SecretsContext),
}),
{
wrapper: ({ children }) => (
<SecretsContextProvider>{children}</SecretsContextProvider>
),
},
);
expect(result.current.context?.secrets.foo).toEqual(undefined);
act(() => result.current.hook.setSecret({ foo: 'bar' }));
expect(result.current.context?.secrets.foo).toEqual('bar');
});
});
@@ -0,0 +1,73 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
useState,
useCallback,
useContext,
createContext,
PropsWithChildren,
} from 'react';
type SecretsContextContents = {
secrets: Record<string, string>;
setSecrets: React.Dispatch<React.SetStateAction<Record<string, string>>>;
};
/**
* The actual context object.
*/
export const SecretsContext = createContext<SecretsContextContents | undefined>(
undefined,
);
/**
* The Context Provider that holds the state for the secrets.
*
* @alpha
*/
export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => {
const [secrets, setSecrets] = useState<Record<string, string>>({});
return (
<SecretsContext.Provider value={{ secrets, setSecrets }}>
{children}
</SecretsContext.Provider>
);
};
/**
* Hook to access the secrets context.
* @alpha
*/
export const useTemplateSecrets = () => {
const value = useContext(SecretsContext);
if (!value) {
throw new Error(
'useTemplateSecrets must be used within a SecretsContextProvider',
);
}
const { setSecrets } = value;
const setSecret = useCallback(
(input: Record<string, string>) => {
setSecrets(currentSecrets => ({ ...currentSecrets, ...input }));
},
[setSecrets],
);
return { setSecret };
};
@@ -0,0 +1,20 @@
/*
* Copyright 2022 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 {
useTemplateSecrets,
SecretsContext,
SecretsContextProvider,
} from './SecretsContext';
@@ -0,0 +1,25 @@
/*
* Copyright 2022 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 { FieldExtensionOptions } from '../../extensions';
export interface TemplateWizardPageProps {
customFieldExtensions: FieldExtensionOptions<any, any>[];
}
export const TemplateWizardPage = (_props: TemplateWizardPageProps) => {
return null;
};
@@ -0,0 +1,16 @@
/*
* Copyright 2022 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 { TemplateWizardPage } from './TemplateWizardPage';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2022 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 * from './Router';
export * from './TemplateListPage';
export * from './TemplateWizardPage';
+12
View File
@@ -150,3 +150,15 @@ export const EntityTagsPickerFieldExtension = scaffolderPlugin.provide(
name: 'EntityTagsPicker',
}),
);
/**
* @alpha
* The Router and main entrypoint to the Alpha Scaffolder plugin.
*/
export const NextScaffolderPage = scaffolderPlugin.provide(
createRoutableExtension({
name: 'NextScaffolderPage',
component: () => import('./next/Router').then(m => m.Router),
mountPoint: rootRouteRef,
}),
);
@@ -39,7 +39,7 @@
"@backstage/errors": "^1.0.0",
"@backstage/plugin-tech-insights-common": "^0.2.4",
"@backstage/plugin-tech-insights-node": "^0.2.8",
"ajv": "^7.0.3",
"ajv": "^8.10.0",
"json-rules-engine": "^6.1.2",
"lodash": "^4.17.21",
"luxon": "^2.0.2",
+129 -2
View File
@@ -92,8 +92,8 @@ FactRetrieverRegistration also accepts an optional `lifecycle` configuration val
```ts
const maxItems = { maxItems: 7 }; // Deletes all but 7 latest facts for each id/entity pair
const ttl = { ttl: 1209600000 }; // (2 weeks) Deletes items older than 2 weeks
const ttlWithAHumanReadableValue = { ttl: { weeks: 2 } }; // Deletes items older than 2 weeks
const ttl = { timeToLive: 1209600000 }; // (2 weeks) Deletes items older than 2 weeks
const ttlWithAHumanReadableValue = { timeToLive: { weeks: 2 } }; // Deletes items older than 2 weeks
```
To register these fact retrievers to your application you can modify the example `techInsights.ts` file shown above like this:
@@ -235,3 +235,130 @@ const myFactCheckerFactory = new JsonRulesEngineFactCheckerFactory({
}),
```
## Included FactRetrievers
There are three FactRetrievers that come out of the box with Tech Insights:
- `entityMetadataFactRetriever`: Generates facts which indicate the completeness of entity metadata
- `entityOwnershipFactRetriever`: Generates facts which indicate the quality of data in the spec.owner field
- `techdocsFactRetriever`: Generates facts related to the completeness of techdocs configuration for entities
## Backend Example
Here's an example backend setup that will use the three included fact retrievers so you can get an idea of how this all works. This will be the entire contents of your `techInsights.ts` file found at `\packages\backend\src\plugins` as per [Adding the plugin to your `packages/backend`](#adding-the-plugin-to-your-packagesbackend)
```ts
import {
createRouter,
buildTechInsightsContext,
createFactRetrieverRegistration,
entityOwnershipFactRetriever,
entityMetadataFactRetriever,
techdocsFactRetriever,
} from '@backstage/plugin-tech-insights-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
import {
JsonRulesEngineFactCheckerFactory,
JSON_RULE_ENGINE_CHECK_TYPE,
} from '@backstage/plugin-tech-insights-backend-module-jsonfc';
const ttlTwoWeeks = { timeToLive: { weeks: 2 } };
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const techInsightsContext = await buildTechInsightsContext({
logger: env.logger,
config: env.config,
database: env.database,
discovery: env.discovery,
factRetrievers: [
createFactRetrieverRegistration({
cadence: '0 */6 * * *', // Run every 6 hours - https://crontab.guru/#0_*/6_*_*_*
factRetriever: entityOwnershipFactRetriever,
lifecycle: ttlTwoWeeks,
}),
createFactRetrieverRegistration({
cadence: '0 */6 * * *',
factRetriever: entityMetadataFactRetriever,
lifecycle: ttlTwoWeeks,
}),
createFactRetrieverRegistration({
cadence: '0 */6 * * *',
factRetriever: techdocsFactRetriever,
lifecycle: ttlTwoWeeks,
}),
],
factCheckerFactory: new JsonRulesEngineFactCheckerFactory({
logger: env.logger,
checks: [
{
id: 'groupOwnerCheck',
type: JSON_RULE_ENGINE_CHECK_TYPE,
name: 'Group Owner Check',
description:
'Verifies that a Group has been set as the owner for this entity',
factIds: ['entityOwnershipFactRetriever'],
rule: {
conditions: {
all: [
{
fact: 'hasGroupOwner',
operator: 'equal',
value: true,
},
],
},
},
},
{
id: 'titleCheck',
type: JSON_RULE_ENGINE_CHECK_TYPE,
name: 'Title Check',
description:
'Verifies that a Title, used to improve readability, has been set for this entity',
factIds: ['entityMetadataFactRetriever'],
rule: {
conditions: {
all: [
{
fact: 'hasTitle',
operator: 'equal',
value: true,
},
],
},
},
},
{
id: 'techDocsCheck',
type: JSON_RULE_ENGINE_CHECK_TYPE,
name: 'TechDocs Check',
description:
'Verifies that TechDocs has been enabled for this entity',
factIds: ['techdocsFactRetriever'],
rule: {
conditions: {
all: [
{
fact: 'hasAnnotationBackstageIoTechdocsRef',
operator: 'equal',
value: true,
},
],
},
},
},
],
}),
});
return await createRouter({
...techInsightsContext,
logger: env.logger,
config: env.config,
});
}
```
@@ -144,7 +144,7 @@ describe('FactRetrieverEngine', () => {
engine.schedule();
const job: any = engine.getJob('test-factretriever');
job.triggerScheduledJobNow();
expect(job.cadence!!).toEqual(cadence);
expect(job.cadence!).toEqual(cadence);
expect(testFactRetriever.handler).toHaveBeenCalledWith(
expect.objectContaining({ entityFilter: testFactRetriever.entityFilter }),
);
@@ -53,7 +53,7 @@ export const entityMetadataFactRetriever: FactRetriever = {
return entities.items.map((entity: Entity) => {
return {
entity: {
namespace: entity.metadata.namespace!!,
namespace: entity.metadata.namespace!,
kind: entity.kind,
name: entity.metadata.name,
},
@@ -51,7 +51,7 @@ export const entityOwnershipFactRetriever: FactRetriever = {
return entities.items.map((entity: Entity) => {
return {
entity: {
namespace: entity.metadata.namespace!!,
namespace: entity.metadata.namespace!,
kind: entity.kind,
name: entity.metadata.name,
},
@@ -49,7 +49,7 @@ export const techdocsFactRetriever: FactRetriever = {
return entities.items.map((entity: Entity) => {
return {
entity: {
namespace: entity.metadata.namespace!!,
namespace: entity.metadata.namespace!,
kind: entity.kind,
name: entity.metadata.name,
},
@@ -62,7 +62,7 @@ export class TechInsightsDatabase implements TechInsightsStore {
return Object.values(groupedSchemas)
.map(schemas => {
const sorted = rsort(schemas.map(it => it.version));
return schemas.find(it => it.version === sorted[0])!!;
return schemas.find(it => it.version === sorted[0])!;
})
.map((it: RawDbFactSchemaRow) => ({
...omit(it, 'schema'),
@@ -188,7 +188,7 @@ export class TechInsightsDatabase implements TechInsightsStore {
throw new Error(`No schema found for ${id}. `);
}
const sorted = rsort(existingSchemas.map(it => it.version));
return existingSchemas.find(it => it.version === sorted[0])!!;
return existingSchemas.find(it => it.version === sorted[0])!;
}
private async deleteExpiredFactsByDate(
+3 -7
View File
@@ -47,12 +47,8 @@ const serviceEntityPage = (
It is not obligatory to pass title and description props to `EntityTechInsightsScorecardContent`. If those are left out, default values from `defaultCheckResultRenderers` in `CheckResultRenderer` will be taken, hence `Boolean scorecard` and `This card represents an overview of default boolean Backstage checks`.
### Customize scorecards overview title and description:
## Boolean Scorecard Example
```tsx
// packages/app/src/components/catalog/EntityPage.tsx
If you follow the [Backend Example](https://github.com/backstage/backstage/tree/master/plugins/tech-insights-backend#backend-example), once the needed facts have been generated the boolean scorecard will look like this:
## Links
- [The Backstage homepage](https://backstage.io)
```
![Boolean Scorecard Example](./docs/boolean-scorecard-example.png)

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