Merge pull request #4869 from backstage/mob/actions-list

Scaffolder: Add page displaying available actions
This commit is contained in:
Johan Haals
2021-03-10 14:47:46 +01:00
committed by GitHub
21 changed files with 459 additions and 108 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-scaffolder-backend': patch
---
Introduce scaffolder actions page which lists all available actions along with documentation about their input/output.
Allow for actions to be extended with a description.
The list actions page is by default available at `/create/actions`.
@@ -4,112 +4,13 @@ title: Builtin actions
description: Documentation describing the built-in template actions.
---
# Built-in Actions
The scaffolder comes with several built-in actions for fetching content,
registering in the catalog and of course actions for creating and publishing a
git repository.
This is the list of built-in template actions
There are several repository providers supported out of the box such as GitHub,
Azure, GitLab and Bitbucket.
## `fetch:plain`
Downloads content and places it in the workspacePath or optionally in a
subdirectory specified by the `targetPath` input option.
input:
- `url` (required) Relative path or absolute URL pointing to the directory tree
to fetch.
- `targetPath` Target path within the working directory to download the contents
to.
output: nothing
## `fetch:cookiecutter`
Downloads template from `url` and templates with cookiecutter
input:
- `url` (required) Relative path or absolute URL pointing to the directory tree
to fetch.
- `targetPath` Target path within the working directory to download the contents
to.
- `values` Values to pass on to cookiecutter for templating.
output: nothing
## `catalog:register`
Registers entity in the software catalog.
input:
- `catalogInfoUrl` (required) An absolute URL pointing to the catalog info file
location.
output:
- `repoContentsUrl` An absolute URL pointing to the root of a repository
directory tree.
- `catalogInfoPath` A relative path from the repo root pointing to the catalog
info file, defaults to /catalog-info.yaml
## `publish:github`
Initializes a git repository of contents in workspacePath and publishes to
GitHub.
input:
- `repoUrl` (required) Repository location
- `repoVisibility` (optional, default: 'private') Possible values: 'private',
'public' or 'internal'.
output:
- `remoteUrl` A URL to the repository with the provider
- `repoContentsUrl` A URL to the root of the repository
## `publish:gitlab`
Initializes a git repository of contents in workspacePath and publishes to
GitLab.
input:
- `repoUrl` (required) Repository location
- `repoVisibility` (optional, default: 'private') Possible values: 'private',
'public' or 'internal'.
output:
- `remoteUrl` A URL to the repository with the provider
- `repoContentsUrl` A URL to the root of the repository
## `publish:bitbucket`
Initializes a git repository of contents in workspacePath and publishes to
Bitbucket.
input:
- `repoUrl` (required) Repository location
- `repoVisibility` (optional, default: 'private') Possible values: 'private',
'public'.
output:
- `remoteUrl` A URL to the repository with the provider
- `repoContentsUrl` A URL to the root of the repository
## `publish:azure`
Initializes a git repository of contents in workspacePath and publishes to
Azure.
input:
- `repoUrl` (required) Repository location
output:
- `remoteUrl` A URL to the repository with the provider
- `repoContentsUrl` A URL to the root of the repository
A list of all registered actions can be found under `/create/actions`. For local
development you should be able to reach them at
`http://localhost:3000/create/actions`.
@@ -38,4 +38,8 @@ export class TemplateActionRegistry {
}
return action;
}
list(): TemplateAction<any>[] {
return [...this.actions.values()];
}
}
@@ -31,6 +31,8 @@ export function createCatalogRegisterAction(options: {
| { repoContentsUrl: string; catalogInfoPath?: string }
>({
id: 'catalog:register',
description:
'Registers entities from a catalog descriptor file in the workspace into the software catalog.',
schema: {
input: {
oneOf: [
@@ -38,6 +38,8 @@ export function createFetchCookiecutterAction(options: {
values: JsonObject;
}>({
id: 'fetch:cookiecutter',
description:
'Downloads a template from the given URL into the workspace, and runs cookiecutter on it.',
schema: {
input: {
type: 'object',
@@ -28,6 +28,8 @@ export function createFetchPlainAction(options: {
return createTemplateAction<{ url: string; targetPath?: string }>({
id: 'fetch:plain',
description:
"Downloads content and places it in the workspace, or optionally in a subdirectory specified by the 'targetPath' input option.",
schema: {
input: {
type: 'object',
@@ -32,6 +32,8 @@ export function createPublishAzureAction(options: {
description?: string;
}>({
id: 'publish:azure',
description:
'Initializes a git repository of the content in the workspace, and publishes it to Azure.',
schema: {
input: {
type: 'object',
@@ -167,6 +167,8 @@ export function createPublishBitbucketAction(options: {
repoVisibility: 'private' | 'public';
}>({
id: 'publish:bitbucket',
description:
'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket.',
schema: {
input: {
type: 'object',
@@ -43,6 +43,8 @@ export function createPublishGithubAction(options: {
repoVisibility: 'private' | 'internal' | 'public';
}>({
id: 'publish:github',
description:
'Initializes a git repository of contents in workspace and publishes it to GitHub.',
schema: {
input: {
type: 'object',
@@ -31,6 +31,8 @@ export function createPublishGitlabAction(options: {
repoVisibility: 'private' | 'internal' | 'public';
}>({
id: 'publish:gitlab',
description:
'Initializes a git repository of the content in the workspace, and publishes it to GitLab.',
schema: {
input: {
type: 'object',
@@ -44,6 +44,7 @@ export type ActionContext<Input extends InputBase> = {
export type TemplateAction<Input extends InputBase> = {
id: string;
description?: string;
schema?: {
input?: Schema;
output?: Schema;
@@ -261,6 +261,15 @@ describe('createRouter', () => {
});
});
describe('GET /v2/actions', () => {
it('lists available actions', async () => {
const response = await request(app).get('/v2/actions').send();
expect(response.status).toEqual(200);
expect(response.body[0].id).toBeDefined();
expect(response.body.length).toBeGreaterThan(8);
});
});
describe('POST /v2/tasks', () => {
it('rejects template values which do not match the template schema definition', async () => {
const response = await request(app)
@@ -347,6 +347,16 @@ export async function createRouter(
}
},
)
.get('/v2/actions', async (_req, res) => {
const actionsList = actionRegistry.list().map(action => {
return {
id: action.id,
description: action.description,
schema: action.schema,
};
});
res.json(actionsList);
})
.post('/v2/tasks', async (req, res) => {
const templateName: string = req.body.templateName;
const values: TemplaterValues = req.body.values;
+1
View File
@@ -43,6 +43,7 @@
"@rjsf/core": "^2.4.0",
"@rjsf/material-ui": "^2.4.0",
"classnames": "^2.2.6",
"json-schema": "^0.2.5",
"git-url-parse": "^11.4.4",
"humanize-duration": "^3.25.1",
"luxon": "^1.25.0",
+13 -1
View File
@@ -25,7 +25,7 @@ import {
} from '@backstage/core';
import { ScmIntegrations } from '@backstage/integration';
import ObservableImpl from 'zen-observable';
import { ScaffolderTask, Status } from './types';
import { ListActionsResponse, ScaffolderTask, Status } from './types';
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
id: 'plugin.scaffolder.service',
@@ -72,6 +72,9 @@ export interface ScaffolderApi {
allowedHosts: string[];
}): Promise<{ type: string; title: string; host: string }[]>;
// Returns a list of all installed actions.
listActions(): Promise<ListActionsResponse>;
streamLogs({
taskId,
after,
@@ -227,4 +230,13 @@ export class ScaffolderClient implements ScaffolderApi {
);
});
}
/**
* @returns ListActionsResponse containing all registered actions.
*/
async listActions(): Promise<ListActionsResponse> {
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const response = await fetch(`${baseUrl}/v2/actions`);
return await response.json();
}
}
@@ -0,0 +1,167 @@
/*
* 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 { ApiRegistry, ApiProvider } from '@backstage/core';
import React from 'react';
import { ScaffolderApi, scaffolderApiRef } from '../../api';
import { ActionsPage } from './ActionsPage';
import { rootRouteRef } from '../../routes';
import { renderInTestApp } from '@backstage/test-utils';
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
};
const apis = ApiRegistry.from([[scaffolderApiRef, scaffolderApiMock]]);
describe('TemplatePage', () => {
beforeEach(() => jest.resetAllMocks());
it('renders action with input', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
required: ['foobar'],
properties: {
foobar: {
title: 'Test title',
type: 'string',
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.queryByText('Test title')).toBeInTheDocument();
expect(rendered.queryByText('example description')).toBeInTheDocument();
expect(rendered.queryByText('foobar')).toBeInTheDocument();
expect(rendered.queryByText('output')).not.toBeInTheDocument();
});
it('renders action with input and output', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
required: ['foobar'],
properties: {
foobar: {
title: 'Test title',
type: 'string',
},
},
},
output: {
type: 'object',
properties: {
buzz: {
title: 'Test output',
type: 'string',
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.queryByText('Test title')).toBeInTheDocument();
expect(rendered.queryByText('example description')).toBeInTheDocument();
expect(rendered.queryByText('foobar')).toBeInTheDocument();
expect(rendered.queryByText('Test output')).toBeInTheDocument();
});
it('renders action with oneOf input', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
oneOf: [
{
type: 'object',
required: ['foo'],
properties: {
foo: {
title: 'Foo title',
description: 'Foo description',
type: 'string',
},
},
},
{
type: 'object',
required: ['bar'],
properties: {
bar: {
title: 'Bar title',
description: 'Bar description',
type: 'string',
},
},
},
],
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.queryByText('oneOf')).toBeInTheDocument();
expect(rendered.queryByText('Foo title')).toBeInTheDocument();
expect(rendered.queryByText('Foo description')).toBeInTheDocument();
expect(rendered.queryByText('Bar title')).toBeInTheDocument();
expect(rendered.queryByText('Bar description')).toBeInTheDocument();
});
});
@@ -0,0 +1,192 @@
/*
* 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 React from 'react';
import { useAsync } from 'react-use';
import {
useApi,
Progress,
Content,
Header,
Page,
ErrorPage,
} from '@backstage/core';
import { scaffolderApiRef } from '../../api';
import {
Typography,
Paper,
Table,
TableBody,
Box,
TableCell,
TableContainer,
TableHead,
TableRow,
makeStyles,
} from '@material-ui/core';
import { JSONSchema } from '@backstage/catalog-model';
import { JSONSchema7Definition } from 'json-schema';
import classNames from 'classnames';
const useStyles = makeStyles(theme => ({
code: {
fontFamily: 'Menlo, monospace',
padding: theme.spacing(1),
backgroundColor:
theme.palette.type === 'dark'
? theme.palette.grey[700]
: theme.palette.grey[300],
display: 'inline-block',
borderRadius: 5,
border: `1px solid ${theme.palette.grey[500]}`,
position: 'relative',
},
codeRequired: {
'&::after': {
position: 'absolute',
content: '"*"',
top: 0,
right: theme.spacing(0.5),
fontWeight: 'bolder',
color: theme.palette.error.light,
},
},
}));
export const ActionsPage = () => {
const api = useApi(scaffolderApiRef);
const classes = useStyles();
const { loading, value, error } = useAsync(async () => {
return api.listActions();
});
if (loading) {
return <Progress />;
}
if (error) {
return (
<ErrorPage
statusMessage="Failed to load installed actions"
status="500"
/>
);
}
const formatRows = (input: JSONSchema) => {
const properties = input.properties;
if (!properties) {
return undefined;
}
return Object.entries(properties).map(entry => {
const [key] = entry;
const props = (entry[1] as unknown) as JSONSchema;
const codeClassname = classNames(classes.code, {
[classes.codeRequired]: input.required?.includes(key),
});
return (
<TableRow key={key}>
<TableCell>
<div className={codeClassname}>{key}</div>
</TableCell>
<TableCell>{props.title}</TableCell>
<TableCell>{props.description}</TableCell>
<TableCell>
<span className={classes.code}>{props.type}</span>
</TableCell>
</TableRow>
);
});
};
const renderTable = (input: JSONSchema) => {
if (!input.properties) {
return undefined;
}
return (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Title</TableCell>
<TableCell>Description</TableCell>
<TableCell>Type</TableCell>
</TableRow>
</TableHead>
<TableBody>{formatRows(input)}</TableBody>
</Table>
</TableContainer>
);
};
const renderTables = (name: string, input?: JSONSchema7Definition[]) => {
if (!input) {
return undefined;
}
return (
<>
<Typography variant="h6">{name}</Typography>
{input.map((i, index) => (
<div key={index}>{renderTable((i as unknown) as JSONSchema)}</div>
))}
</>
);
};
const items = value?.map(action => {
if (action.id.startsWith('legacy:')) {
return undefined;
}
const oneOf = renderTables('oneOf', action.schema?.input?.oneOf);
return (
<Box pb={4} key={action.id}>
<Typography variant="h4" className={classes.code}>
{action.id}
</Typography>
<Typography>{action.description}</Typography>
{action.schema?.input && (
<Box pb={2}>
<Typography variant="h5">Input</Typography>
{renderTable(action.schema.input)}
{oneOf}
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5">Output</Typography>
{renderTable(action.schema.output)}
</Box>
)}
</Box>
);
});
return (
<Page themeId="home">
<Header
pageTitleOverride="Create a New Component"
title="Installed actions"
subtitle="This is the collection of all installed actions"
/>
<Content>{items}</Content>
</Page>
);
};
@@ -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 { ActionsPage } from './ActionsPage';
@@ -19,11 +19,13 @@ import { Routes, Route } from 'react-router';
import { ScaffolderPage } from './ScaffolderPage';
import { TemplatePage } from './TemplatePage';
import { TaskPage } from './TaskPage';
import { ActionsPage } from './ActionsPage';
export const Router = () => (
<Routes>
<Route path="/" element={<ScaffolderPage />} />
<Route path="/templates/:templateName" element={<TemplatePage />} />
<Route path="/tasks/:taskId" element={<TaskPage />} />
<Route path="/actions" element={<ActionsPage />} />
</Routes>
);
@@ -39,6 +39,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
getIntegrationsList: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
};
const errorApiMock = { post: jest.fn(), error$: jest.fn() };
+10
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JSONSchema } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/config';
export type Status = 'open' | 'processing' | 'failed' | 'completed';
@@ -54,3 +55,12 @@ export type ScaffolderTask = {
lastHeartbeatAt: string;
createdAt: string;
};
export type ListActionsResponse = Array<{
id: string;
description?: string;
schema?: {
input?: JSONSchema;
output?: JSONSchema;
};
}>;