Merge branch 'community-page' of github.com:suuus/backstage into community-page
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
fixed empty body issue for POST requests using FetchAPI with 'plugin://' prefix
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Enable internal batching of very large deletions, to not run into SQL binding limits
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Added a new `NextScaffolderRouter` which will eventually replace the exiting router
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-jenkins-backend': patch
|
||||
---
|
||||
|
||||
Make `resourceRef` required in `JenkinsApi` to match usage.
|
||||
@@ -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
|
||||
});
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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: >-
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+28
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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: [
|
||||
|
||||
@@ -35,6 +35,7 @@ export type AwsS3IntegrationConfig = {
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
roleArn?: string;
|
||||
externalId?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
Vendored
+6
@@ -225,6 +225,12 @@ export interface Config {
|
||||
* @visibility backend
|
||||
*/
|
||||
roleArn?: string;
|
||||
|
||||
/**
|
||||
* External ID to use when assuming role
|
||||
* @visibility backend
|
||||
*/
|
||||
externalId?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
+1
-1
@@ -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,
|
||||
},
|
||||
|
||||
+1
-1
@@ -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,
|
||||
},
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
```
|
||||

|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user