Merge pull request #5849 from SDA-SE/feat/scaffolder-conditional

Conditional scaffolder steps
This commit is contained in:
Fredrik Adelöw
2021-06-08 14:25:49 +02:00
committed by GitHub
26 changed files with 554 additions and 53 deletions
+23
View File
@@ -0,0 +1,23 @@
---
'@backstage/catalog-model': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-scaffolder-backend': patch
---
Introduce conditional steps in scaffolder templates.
A step can now include an `if` property that only executes a step if the
condition is truthy. The condition can include handlebar templates.
```yaml
- id: register
if: '{{ not parameters.dryRun }}'
name: Register
action: catalog:register
input:
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
catalogInfoPath: '/catalog-info.yaml'
```
Also introduces a `not` helper in handlebar templates that allows to negate
boolean expressions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Add `debug:log` action for debugging.
+1
View File
@@ -262,6 +262,7 @@ tooltips
touchpoints
transpilation
transpiled
truthy
ui
unmanaged
unregister
@@ -722,7 +722,7 @@ You can find out more about the `steps` key
### `spec.owner` [optional]
An [entity reference](#string-references) to the owner of the component, e.g.
An [entity reference](#string-references) to the owner of the template, e.g.
`artist-relations-team`. This field is required.
In Backstage, the owner of a Template is the singular entity (commonly a team)
@@ -288,6 +288,7 @@ template. These follow the same standard format:
```yaml
- id: fetch-base # A unique id for the step
name: Fetch Base # A title displayed in the frontend
if: '{{ parameters.name }}' # Optional condition, skip the step if not truthy
action: fetch:cookiecutter # an action to call
input: # input that is passed as arguments to the action handler
url: ./template
+1
View File
@@ -534,6 +534,7 @@ export interface TemplateEntityV1beta2 extends Entity {
name?: string;
action: string;
input?: JsonObject;
if?: string | boolean;
}>;
output?: {
[name: string]: string;
@@ -54,6 +54,7 @@ describe('templateEntityV1beta2Validator', () => {
input: {
url: './template',
},
if: '{{ parameters.owner }}',
},
],
output: {
@@ -122,16 +123,39 @@ describe('templateEntityV1beta2Validator', () => {
delete (entity as any).spec.steps[0].action;
await expect(validator.check(entity)).rejects.toThrow(/action/);
});
it('accepts missing owner', async () => {
delete (entity as any).spec.owner;
await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects empty owner', async () => {
(entity as any).spec.owner = '';
await expect(validator.check(entity)).rejects.toThrow(/owner/);
});
it('rejects wrong type owner', async () => {
(entity as any).spec.owner = 5;
await expect(validator.check(entity)).rejects.toThrow(/owner/);
});
it('accepts missing if', async () => {
delete (entity as any).spec.steps[0].if;
await expect(validator.check(entity)).resolves.toBe(true);
});
it('accepts boolean in if', async () => {
(entity as any).spec.steps[0].if = true;
await expect(validator.check(entity)).resolves.toBe(true);
});
it('accepts empty if', async () => {
(entity as any).spec.steps[0].if = '';
await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects wrong type if', async () => {
(entity as any).spec.steps[0].if = 5;
await expect(validator.check(entity)).rejects.toThrow(/if/);
});
});
@@ -33,6 +33,7 @@ export interface TemplateEntityV1beta2 extends Entity {
name?: string;
action: string;
input?: JsonObject;
if?: string | boolean;
}>;
output?: { [name: string]: string };
owner?: string;
@@ -1,10 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "TemplateV1beta1",
"$id": "TemplateV1beta2",
"description": "A Template describes a scaffolding task for use with the Scaffolder. It describes the required parameters as well as a series of steps that will be taken to execute the scaffolding task.",
"examples": [
{
"apiVersion": "backstage.io/v1beta1",
"apiVersion": "backstage.io/v1beta2",
"kind": "Template",
"metadata": {
"name": "react-ssr-template",
@@ -45,7 +45,8 @@
"action": "publish:github",
"parameters": {
"repoUrl": "{{ parameters.repoUrl }}"
}
},
"if": "{{ parameters.repoUrl }}"
}
],
"output": {
@@ -128,6 +129,10 @@
"input": {
"type": "object",
"description": "A templated object describing the inputs to the action."
},
"if": {
"type": ["string", "boolean"],
"description": "A templated condition that skips the step when evaluated to false. If the condition is true or not defined, the step is executed. The condition is true, if the input is not `false`, `undefined`, `null`, `\"\"`, `0`, or `[]`."
}
}
}
@@ -141,7 +146,7 @@
"description": "A list of external hyperlinks, typically pointing to resources created or updated by the template",
"items": {
"type": "object",
"required": ["url"],
"required": [],
"properties": {
"url": {
"type": "string",
@@ -149,6 +154,12 @@
"examples": ["https://github.com/my-org/my-new-repo"],
"minLength": 1
},
"entityRef": {
"type": "string",
"description": "An entity reference to an entity in the catalog.",
"examples": ["Component:default/my-app"],
"minLength": 1
},
"title": {
"type": "string",
"description": "A user friendly display name for the link.",
@@ -35,13 +35,14 @@ spec:
description: System of the component
ui:field: EntityPicker
ui:options:
allowedKinds:
allowedKinds:
- System
defaultKind: System
- title: Choose a location
required:
- repoUrl
- dryRun
properties:
repoUrl:
title: Repository Location
@@ -50,6 +51,10 @@ spec:
ui:options:
allowedHosts:
- github.com
dryRun:
title: Only perform a dry run, don't publish anything
type: boolean
default: false
steps:
- id: fetch-base
@@ -59,7 +64,8 @@ spec:
url: ./template
values:
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
owner: '{{ parameters.owner }}'
system: '{{ parameters.system }}'
destination: '{{ parseRepoUrl parameters.repoUrl }}'
- id: fetch-docs
@@ -70,6 +76,7 @@ spec:
url: https://github.com/backstage/community/tree/main/backstage-community-sessions
- id: publish
if: '{{ not parameters.dryRun }}'
name: Publish
action: publish:github
input:
@@ -78,12 +85,23 @@ spec:
repoUrl: '{{ parameters.repoUrl }}'
- id: register
if: '{{ not parameters.dryRun }}'
name: Register
action: catalog:register
input:
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
catalogInfoPath: '/catalog-info.yaml'
- name: Results
if: '{{ parameters.dryRun }}'
action: debug:log
input:
listWorkspace: true
output:
remoteUrl: '{{ steps.publish.output.remoteUrl }}'
entityRef: '{{ steps.register.output.entityRef }}'
links:
- title: Repository
url: '{{ steps.publish.output.remoteUrl }}'
- title: Open in catalog
icon: 'catalog'
entityRef: '{{ steps.register.output.entityRef }}'
@@ -7,6 +7,6 @@ spec:
type: website
lifecycle: experimental
owner: {{cookiecutter.owner | jsonify}}
{%- if cookiecutter.backstage_system != "" %}
{%- if 'system' in cookiecutter %}
system: {{ cookiecutter.system | jsonify }}
{%- endif %}
@@ -19,6 +19,7 @@ import { CatalogApi } from '@backstage/catalog-client';
import { ScmIntegrations } from '@backstage/integration';
import { TemplaterBuilder } from '../../stages';
import { createCatalogRegisterAction } from './catalog';
import { createDebugLogAction } from './debug';
import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch';
import {
createPublishAzureAction,
@@ -61,6 +62,7 @@ export const createBuiltinActions = (options: {
createPublishAzureAction({
integrations,
}),
createDebugLogAction(),
createCatalogRegisterAction({ catalogClient, integrations }),
];
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { createDebugLogAction } from './log';
@@ -0,0 +1,94 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { getVoidLogger } from '@backstage/backend-common';
import mock from 'mock-fs';
import os from 'os';
import { Writable } from 'stream';
import { createDebugLogAction } from './log';
import { join } from 'path';
describe('debug:log', () => {
const logStream = ({
write: jest.fn(),
} as jest.Mocked<Partial<Writable>>) as jest.Mocked<Writable>;
const mockTmpDir = os.tmpdir();
const mockContext = {
input: {},
baseUrl: 'somebase',
workspacePath: mockTmpDir,
logger: getVoidLogger(),
logStream,
output: jest.fn(),
createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir),
};
const action = createDebugLogAction();
beforeEach(() => {
mock({
[`${mockContext.workspacePath}/README.md`]: '',
[`${mockContext.workspacePath}/a-directory/index.md`]: '',
});
jest.resetAllMocks();
});
afterEach(() => {
mock.restore();
});
it('should do nothing', async () => {
await action.handler(mockContext);
expect(logStream.write).toBeCalledTimes(0);
});
it('should log the workspace content, if active', async () => {
const context = {
...mockContext,
input: {
listWorkspace: 'true',
},
};
await action.handler(context);
expect(logStream.write).toBeCalledTimes(1);
expect(logStream.write).toBeCalledWith(
expect.stringContaining('README.md'),
);
expect(logStream.write).toBeCalledWith(
expect.stringContaining(join('a-directory', 'index.md')),
);
});
it('should log message', async () => {
const context = {
...mockContext,
input: {
message: 'Hello Backstage!',
},
};
await action.handler(context);
expect(logStream.write).toBeCalledTimes(1);
expect(logStream.write).toBeCalledWith(
expect.stringContaining('Hello Backstage!'),
);
});
});
@@ -0,0 +1,71 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { readdir, stat } from 'fs-extra';
import { relative, resolve } from 'path';
import { createTemplateAction } from '../../createTemplateAction';
/**
* This task is useful for local development and testing of both the scaffolder
* and scaffolder templates.
*/
export function createDebugLogAction() {
return createTemplateAction<{ message?: string; listWorkspace?: boolean }>({
id: 'debug:log',
description:
'Writes a message into the log or list all files in the workspace.',
schema: {
input: {
type: 'object',
properties: {
message: {
title: 'Message to output.',
type: 'string',
},
listWorkspace: {
title: 'List all files in the workspace, if true.',
type: 'boolean',
},
},
},
},
async handler(ctx) {
if (ctx.input?.message) {
ctx.logStream.write(ctx.input.message);
}
if (ctx.input?.listWorkspace) {
const files = await recursiveReadDir(ctx.workspacePath);
ctx.logStream.write(
`Workspace:\n${files
.map(f => ` - ${relative(ctx.workspacePath, f)}`)
.join('\n')}`,
);
}
},
});
}
export async function recursiveReadDir(dir: string): Promise<string[]> {
const subdirs = await readdir(dir);
const files = await Promise.all(
subdirs.map(async subdir => {
const res = resolve(dir, subdir);
return (await stat(res)).isDirectory() ? recursiveReadDir(res) : [res];
}),
);
return files.reduce((a, f) => a.concat(f), []);
}
@@ -15,6 +15,7 @@
*/
export * from './catalog';
export { createBuiltinActions } from './createBuiltinActions';
export * from './debug';
export * from './fetch';
export * from './publish';
export { createBuiltinActions } from './createBuiltinActions';
@@ -14,9 +14,9 @@
* limitations under the License.
*/
export { createPublishGithubAction } from './github';
export { createPublishGithubPullRequestAction } from './githubPullRequest';
export { createPublishAzureAction } from './azure';
export { createPublishGitlabAction } from './gitlab';
export { createPublishBitbucketAction } from './bitbucket';
export { createPublishFileAction } from './file';
export { createPublishGithubAction } from './github';
export { createPublishGithubPullRequestAction } from './githubPullRequest';
export { createPublishGitlabAction } from './gitlab';
@@ -18,13 +18,13 @@ import {
getVoidLogger,
SingleConnectionDatabaseManager,
} from '@backstage/backend-common';
import { TaskWorker } from './TaskWorker';
import os from 'os';
import { ConfigReader, JsonObject } from '@backstage/config';
import { StorageTaskBroker } from './StorageTaskBroker';
import { DatabaseTaskStore } from './DatabaseTaskStore';
import os from 'os';
import { createTemplateAction, TemplateActionRegistry } from '../actions';
import { RepoSpec } from '../actions/builtin/publish/util';
import { DatabaseTaskStore } from './DatabaseTaskStore';
import { StorageTaskBroker } from './StorageTaskBroker';
import { TaskWorker } from './TaskWorker';
async function createStore(): Promise<DatabaseTaskStore> {
const manager = SingleConnectionDatabaseManager.fromConfig(
@@ -54,6 +54,7 @@ describe('TaskWorker', () => {
id: 'test-action',
handler: async ctx => {
ctx.output('testOutput', 'winning');
ctx.output('badOutput', false);
},
});
});
@@ -172,6 +173,72 @@ describe('TaskWorker', () => {
expect((event?.body?.output as JsonObject).result).toBe('winning');
});
it('should execute steps conditionally', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
});
const { taskId } = await broker.dispatch({
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'conditional',
name: 'conditional',
action: 'test-action',
if: '{{ steps.test.output.testOutput }}',
},
],
output: {
result: '{{ steps.conditional.output.testOutput }}',
},
values: {},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBe('winning');
});
it('should skip steps conditionally', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
});
const { taskId } = await broker.dispatch({
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'conditional',
name: 'conditional',
action: 'test-action',
if: '{{ steps.test.output.badOutput }}',
},
],
output: {
result: '{{ steps.conditional.output.testOutput }}',
},
values: {},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBeUndefined();
});
it('should parse strings as objects if possible', async () => {
const inputAction = createTemplateAction<{
address: { line1: string };
@@ -14,18 +14,19 @@
* limitations under the License.
*/
import { PassThrough } from 'stream';
import { Logger } from 'winston';
import * as winston from 'winston';
import { JsonValue, JsonObject } from '@backstage/config';
import { validate as validateJsonSchema } from 'jsonschema';
import { TaskBroker, Task } from './types';
import fs from 'fs-extra';
import path from 'path';
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
import * as Handlebars from 'handlebars';
import { JsonObject, JsonValue } from '@backstage/config';
import { InputError } from '@backstage/errors';
import fs from 'fs-extra';
import * as Handlebars from 'handlebars';
import { validate as validateJsonSchema } from 'jsonschema';
import path from 'path';
import { PassThrough } from 'stream';
import * as winston from 'winston';
import { Logger } from 'winston';
import { parseRepoUrl } from '../actions/builtin/publish/util';
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
import { isTruthy } from './helper';
import { Task, TaskBroker } from './types';
type Options = {
logger: Logger;
@@ -48,6 +49,8 @@ export class TaskWorker {
});
this.handlebars.registerHelper('json', obj => JSON.stringify(obj));
this.handlebars.registerHelper('not', value => !isTruthy(value));
}
start() {
@@ -102,6 +105,51 @@ export class TaskWorker {
});
taskLogger.add(new winston.transports.Stream({ stream }));
if (step.if !== undefined) {
// Support passing values like false to disable steps
let skip = !step.if;
// Evaluate strings as handlebar templates
if (typeof step.if === 'string') {
const condition = JSON.parse(
JSON.stringify(step.if),
(_key, value) => {
if (typeof value === 'string') {
const templated = this.handlebars.compile(value, {
noEscape: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it's just an empty string, treat it as undefined
if (templated === '') {
return undefined;
}
try {
return JSON.parse(templated);
} catch {
return templated;
}
}
return value;
},
);
skip = !isTruthy(condition);
}
if (skip) {
await task.emitLog(`Skipped step ${step.name}`, {
...metadata,
status: 'skipped',
});
continue;
}
}
await task.emitLog(`Beginning step ${step.name}`, {
...metadata,
status: 'processing',
@@ -118,13 +166,18 @@ export class TaskWorker {
if (typeof value === 'string') {
const templated = this.handlebars.compile(value, {
noEscape: true,
strict: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it's just an empty string, treat it as undefined
if (templated === '') {
return undefined;
}
// If it smells like a JSON object then give it a parse as an object and if it fails return the string
if (
(templated.startsWith('"') && templated.endsWith('"')) ||
(templated.startsWith('{') && templated.endsWith('}')) ||
(templated.startsWith('[') && templated.endsWith(']'))
) {
@@ -203,12 +256,32 @@ export class TaskWorker {
JSON.stringify(task.spec.output),
(_key, value) => {
if (typeof value === 'string') {
return this.handlebars.compile(value, {
const templated = this.handlebars.compile(value, {
noEscape: true,
strict: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it's just an empty string, treat it as undefined
if (templated === '') {
return undefined;
}
// If it smells like a JSON object then give it a parse as an object and if it fails return the string
if (
(templated.startsWith('"') && templated.endsWith('"')) ||
(templated.startsWith('{') && templated.endsWith('}')) ||
(templated.startsWith('[') && templated.endsWith(']'))
) {
try {
// Don't recursively JSON parse the values of this string.
// Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else
return JSON.parse(templated);
} catch {
return templated;
}
}
return templated;
}
return value;
},
@@ -0,0 +1,36 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { isTruthy } from './helper';
describe('isTruthy', () => {
it.each`
value | result
${'string'} | ${true}
${true} | ${true}
${1} | ${true}
${['1']} | ${true}
${{}} | ${true}
${false} | ${false}
${''} | ${false}
${undefined} | ${false}
${null} | ${false}
${0} | ${false}
${[]} | ${false}
`('should be $result for $value', async ({ value, result }) => {
expect(isTruthy(value)).toEqual(result);
});
});
@@ -0,0 +1,26 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { isArray } from 'lodash';
/**
* Returns true if the input is not `false`, `undefined`, `null`, `""`, `0`, or
* `[]`. This behavior is based on the behavior of handlebars, see
* https://handlebarsjs.com/guide/builtin-helpers.html#if
*/
export function isTruthy(value: any): boolean {
return isArray(value) ? value.length > 0 : !!value;
}
@@ -51,6 +51,7 @@ export type TaskSpec = {
name: string;
action: string;
input?: JsonObject;
if?: string | boolean;
}>;
output: { [name: string]: string };
};
@@ -172,6 +172,8 @@ export const TaskStatusStepper = memo(
const isCompleted = step.status === 'completed';
const isFailed = step.status === 'failed';
const isActive = step.status === 'processing';
const isSkipped = step.status === 'skipped';
return (
<Step key={String(index)} expanded>
<StepButton onClick={() => onUserStepChange(step.id)}>
@@ -186,7 +188,11 @@ export const TaskStatusStepper = memo(
>
<div className={classes.labelWrapper}>
<Typography variant="subtitle2">{step.name}</Typography>
<StepTimeTicker step={step} />
{isSkipped ? (
<Typography variant="caption">Skipped</Typography>
) : (
<StepTimeTicker step={step} />
)}
</div>
</StepLabel>
</StepButton>
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { TaskPageLinks } from './TaskPageLinks';
import { renderInTestApp } from '@backstage/test-utils';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
describe('TaskPageLinks', () => {
beforeEach(() => {});
@@ -68,9 +68,11 @@ describe('TaskPageLinks', () => {
links: [
{ url: 'https://first.url', title: 'Cool link 1' },
{ url: 'https://second.url', title: 'Cool link 2' },
{ entityRef: 'Component:default/my-app', title: 'Open in catalog' },
{ title: 'Skipped' },
],
};
const { findByText } = await renderInTestApp(
const { findByText, queryByText } = await renderInTestApp(
<TaskPageLinks output={output} />,
{
mountedRoutes: {
@@ -88,5 +90,15 @@ describe('TaskPageLinks', () => {
expect(element).toBeInTheDocument();
expect(element).toHaveAttribute('href', 'https://second.url');
element = await findByText('Open in catalog');
expect(element).toBeInTheDocument();
expect(element).toHaveAttribute(
'href',
'/catalog/default/Component/my-app',
);
expect(queryByText('Skipped')).not.toBeInTheDocument();
});
});
@@ -14,21 +14,21 @@
* limitations under the License.
*/
import React from 'react';
import { parseEntityName } from '@backstage/catalog-model';
import { IconComponent, IconKey, useApp, useRouteRef } from '@backstage/core';
import { IconLink } from './IconLink';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import { Box } from '@material-ui/core';
import LanguageIcon from '@material-ui/icons/Language';
import React from 'react';
import { TaskOutput } from '../../types';
import { IconLink } from './IconLink';
type TaskPageLinksProps = {
output: TaskOutput;
};
export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
const { entityRef, remoteUrl } = output;
const { entityRef: entityRefOutput, remoteUrl } = output;
let { links = [] } = output;
const app = useApp();
const entityRoute = useRouteRef(entityRouteRef);
@@ -40,12 +40,10 @@ export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
links = [{ url: remoteUrl, title: 'Repo' }, ...links];
}
if (entityRef) {
const entityName = parseEntityName(entityRef);
const target = entityRoute(entityName);
if (entityRefOutput) {
links = [
{
url: target,
entityRef: entityRefOutput,
title: 'Open in catalog',
icon: 'catalog',
},
@@ -55,15 +53,25 @@ export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
return (
<Box px={3} pb={3}>
{links.map(({ url, title, icon }, i) => (
<IconLink
key={`output-link-${i}`}
href={url}
text={title ?? url}
Icon={iconResolver(icon)}
target="_blank"
/>
))}
{links
.filter(({ url, entityRef }) => url || entityRef)
.map(({ url, entityRef, title, icon }) => {
if (entityRef) {
const entityName = parseEntityName(entityRef);
const target = entityRoute(entityName);
return { title, icon, url: target };
}
return { title, icon, url: url! };
})
.map(({ url, title, icon }, i) => (
<IconLink
key={`output-link-${i}`}
href={url}
text={title ?? url}
Icon={iconResolver(icon)}
target="_blank"
/>
))}
</Box>
);
};
+4 -2
View File
@@ -16,7 +16,7 @@
import { JSONSchema } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/config';
export type Status = 'open' | 'processing' | 'failed' | 'completed';
export type Status = 'open' | 'processing' | 'failed' | 'completed' | 'skipped';
export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
export type Job = {
id: string;
@@ -66,12 +66,14 @@ export type ListActionsResponse = Array<{
}>;
type OutputLink = {
url: string;
title?: string;
icon?: string;
url?: string;
entityRef?: string;
};
export type TaskOutput = {
/** @deprecated use the `links` property to link out to relevant resources */
entityRef?: string;
/** @deprecated use the `links` property to link out to relevant resources */
remoteUrl?: string;