Merge pull request #11173 from backstage/rugvip/dry-run
scaffolder: add dry-run + Template Editor
This commit is contained in:
@@ -40,6 +40,7 @@ export type ActionContext<Input extends JsonObject> = {
|
||||
output(name: string, value: JsonValue): void;
|
||||
createTemporaryDirectory(): Promise<string>;
|
||||
templateInfo?: TemplateInfo;
|
||||
isDryRun?: boolean;
|
||||
};
|
||||
|
||||
// @public
|
||||
@@ -550,6 +551,8 @@ export interface TaskContext {
|
||||
// (undocumented)
|
||||
getWorkspaceName(): Promise<string>;
|
||||
// (undocumented)
|
||||
isDryRun?: boolean;
|
||||
// (undocumented)
|
||||
secrets?: TaskSecrets;
|
||||
// (undocumented)
|
||||
spec: TaskSpec;
|
||||
@@ -665,6 +668,7 @@ export class TaskWorker {
|
||||
export type TemplateAction<Input extends JsonObject> = {
|
||||
id: string;
|
||||
description?: string;
|
||||
supportsDryRun?: boolean;
|
||||
schema?: {
|
||||
input?: Schema;
|
||||
output?: Schema;
|
||||
|
||||
@@ -67,11 +67,13 @@
|
||||
"nunjucks": "^3.2.3",
|
||||
"octokit": "^1.7.1",
|
||||
"octokit-plugin-create-pull-request": "^3.10.0",
|
||||
"p-limit": "^3.1.0",
|
||||
"uuid": "^8.2.0",
|
||||
"winston": "^3.2.1",
|
||||
"yaml": "^1.10.0",
|
||||
"vm2": "^3.9.6",
|
||||
"zen-observable": "^0.8.15"
|
||||
"zen-observable": "^0.8.15",
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "^0.1.25-next.0",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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 mockFs from 'mock-fs';
|
||||
import { deserializeDirectoryContents } from './deserializeDirectoryContents';
|
||||
import { serializeDirectoryContents } from './serializeDirectoryContents';
|
||||
|
||||
describe('deserializeDirectoryContents', () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
root: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('deserializes contents into a directory', async () => {
|
||||
await deserializeDirectoryContents('root', [
|
||||
{
|
||||
path: 'a.txt',
|
||||
content: Buffer.from('a', 'utf8'),
|
||||
},
|
||||
]);
|
||||
await expect(serializeDirectoryContents('root')).resolves.toEqual([
|
||||
{
|
||||
path: 'a.txt',
|
||||
content: Buffer.from('a', 'utf8'),
|
||||
executable: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('deserializes contents into a deep directory structure', async () => {
|
||||
await deserializeDirectoryContents('root', [
|
||||
{
|
||||
path: 'a.txt',
|
||||
content: Buffer.from('a', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'a/b.txt',
|
||||
content: Buffer.from('b', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'a/b/c.txt',
|
||||
content: Buffer.from('c', 'utf8'),
|
||||
},
|
||||
]);
|
||||
await expect(serializeDirectoryContents('root')).resolves.toEqual([
|
||||
{
|
||||
path: 'a.txt',
|
||||
content: Buffer.from('a', 'utf8'),
|
||||
executable: false,
|
||||
},
|
||||
{
|
||||
path: 'a/b.txt',
|
||||
content: Buffer.from('b', 'utf8'),
|
||||
executable: false,
|
||||
},
|
||||
{
|
||||
path: 'a/b/c.txt',
|
||||
content: Buffer.from('c', 'utf8'),
|
||||
executable: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import { dirname } from 'path';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
import { SerializedFile } from './types';
|
||||
|
||||
/**
|
||||
* Deserializes a list of serialized files into the target directory.
|
||||
*
|
||||
* This method uses `resolveSafeChildPath` to make sure that files are
|
||||
* not written outside of the target directory.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export async function deserializeDirectoryContents(
|
||||
targetPath: string,
|
||||
files: SerializedFile[],
|
||||
): Promise<void> {
|
||||
for (const file of files) {
|
||||
const filePath = resolveSafeChildPath(targetPath, file.path);
|
||||
await fs.ensureDir(dirname(filePath));
|
||||
await fs.writeFile(filePath, file.content); // Ignore file mode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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 { serializeDirectoryContents } from './serializeDirectoryContents';
|
||||
export { deserializeDirectoryContents } from './deserializeDirectoryContents';
|
||||
export type { SerializedFile } from './types';
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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 { serializeDirectoryContents } from './serializeDirectoryContents';
|
||||
import mockFs from 'mock-fs';
|
||||
|
||||
describe('serializeDirectoryContents', () => {
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should list files in this directory', async () => {
|
||||
await expect(serializeDirectoryContents(__dirname)).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'index.ts',
|
||||
executable: false,
|
||||
content: expect.any(Buffer),
|
||||
},
|
||||
{
|
||||
path: 'types.ts',
|
||||
executable: false,
|
||||
content: expect.any(Buffer),
|
||||
},
|
||||
{
|
||||
path: 'serializeDirectoryContents.ts',
|
||||
executable: false,
|
||||
content: expect.any(Buffer),
|
||||
},
|
||||
{
|
||||
path: 'serializeDirectoryContents.test.ts',
|
||||
executable: false,
|
||||
content: expect.any(Buffer),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should list files in a mock directory', async () => {
|
||||
mockFs({
|
||||
root: {
|
||||
'a.txt': 'a',
|
||||
b: {
|
||||
'b1.txt': 'b1',
|
||||
'b2.txt': 'b2',
|
||||
},
|
||||
c: {
|
||||
c1: {
|
||||
'c11.txt': 'c11',
|
||||
c11: {
|
||||
'c111.txt': 'c111',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(serializeDirectoryContents('root')).resolves.toEqual([
|
||||
{
|
||||
path: 'a.txt',
|
||||
executable: false,
|
||||
content: Buffer.from('a', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'b/b1.txt',
|
||||
executable: false,
|
||||
content: Buffer.from('b1', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'b/b2.txt',
|
||||
executable: false,
|
||||
content: Buffer.from('b2', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'c/c1/c11.txt',
|
||||
executable: false,
|
||||
content: Buffer.from('c11', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'c/c1/c11/c111.txt',
|
||||
executable: false,
|
||||
content: Buffer.from('c111', 'utf8'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore gitignored files', async () => {
|
||||
mockFs({
|
||||
root: {
|
||||
'.gitignore': '*.txt',
|
||||
'a.txt': 'a',
|
||||
'a.log': 'a',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
serializeDirectoryContents('root', {
|
||||
gitignore: true,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
path: '.gitignore',
|
||||
executable: false,
|
||||
content: Buffer.from('*.txt', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'a.log',
|
||||
executable: false,
|
||||
content: Buffer.from('a', 'utf8'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use custom glob patterns', async () => {
|
||||
mockFs({
|
||||
root: {
|
||||
'.a': 'a',
|
||||
'a.log': 'a',
|
||||
'a.txt': 'a',
|
||||
b: {
|
||||
'.b': 'b',
|
||||
'b.log': 'b',
|
||||
'b.txt': 'b',
|
||||
},
|
||||
c: {
|
||||
'.c': 'c',
|
||||
'c.log': 'c',
|
||||
'c.txt': 'c',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
serializeDirectoryContents('root', {
|
||||
gitignore: true,
|
||||
globPatterns: ['**/*.txt', '*/.?', '*/*.log', '!c/**/.*', '!b/*.log'],
|
||||
}).then(files => files.sort((a, b) => a.path.localeCompare(b.path))),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
path: 'a.txt',
|
||||
executable: false,
|
||||
content: Buffer.from('a', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'b/.b',
|
||||
executable: false,
|
||||
content: Buffer.from('b', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'b/b.txt',
|
||||
executable: false,
|
||||
content: Buffer.from('b', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'c/c.log',
|
||||
executable: false,
|
||||
content: Buffer.from('c', 'utf8'),
|
||||
},
|
||||
{
|
||||
path: 'c/c.txt',
|
||||
executable: false,
|
||||
content: Buffer.from('c', 'utf8'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import globby from 'globby';
|
||||
import limiterFactory from 'p-limit';
|
||||
import { join as joinPath } from 'path';
|
||||
import { SerializedFile } from './types';
|
||||
|
||||
const DEFAULT_GLOB_PATTERNS = ['./**', '!.git'];
|
||||
|
||||
export const isExecutable = (fileMode: number | undefined) => {
|
||||
if (!fileMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const executeBitMask = 0o000111;
|
||||
const res = fileMode & executeBitMask;
|
||||
return res > 0;
|
||||
};
|
||||
|
||||
export async function serializeDirectoryContents(
|
||||
sourcePath: string,
|
||||
options?: {
|
||||
gitignore?: boolean;
|
||||
globPatterns?: string[];
|
||||
},
|
||||
): Promise<SerializedFile[]> {
|
||||
const paths = await globby(options?.globPatterns ?? DEFAULT_GLOB_PATTERNS, {
|
||||
cwd: sourcePath,
|
||||
dot: true,
|
||||
gitignore: options?.gitignore,
|
||||
followSymbolicLinks: false,
|
||||
objectMode: true,
|
||||
stats: true,
|
||||
});
|
||||
|
||||
const limiter = limiterFactory(10);
|
||||
|
||||
return Promise.all(
|
||||
paths.map(async ({ path, stats }) => ({
|
||||
path,
|
||||
content: await limiter(async () =>
|
||||
fs.readFile(joinPath(sourcePath, path)),
|
||||
),
|
||||
executable: isExecutable(stats?.mode),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 interface SerializedFile {
|
||||
path: string;
|
||||
content: Buffer;
|
||||
executable?: boolean;
|
||||
}
|
||||
@@ -84,6 +84,18 @@ export function createCatalogRegisterAction(options: {
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
required: ['catalogInfoUrl'],
|
||||
properties: {
|
||||
entityRef: {
|
||||
type: 'string',
|
||||
},
|
||||
catalogInfoUrl: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { input } = ctx;
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createCatalogWriteAction() {
|
||||
},
|
||||
},
|
||||
},
|
||||
supportsDryRun: true,
|
||||
async handler(ctx) {
|
||||
ctx.logStream.write(`Writing catalog-info.yaml`);
|
||||
const { filePath, entity } = ctx.input;
|
||||
|
||||
@@ -51,6 +51,7 @@ export function createDebugLogAction() {
|
||||
},
|
||||
},
|
||||
},
|
||||
supportsDryRun: true,
|
||||
async handler(ctx) {
|
||||
ctx.logger.info(JSON.stringify(ctx.input, null, 2));
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export function createFetchPlainAction(options: {
|
||||
},
|
||||
},
|
||||
},
|
||||
supportsDryRun: true,
|
||||
async handler(ctx) {
|
||||
ctx.logger.info('Fetching plain content from remote URL');
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export function createFetchTemplateAction(options: {
|
||||
},
|
||||
},
|
||||
},
|
||||
supportsDryRun: true,
|
||||
async handler(ctx) {
|
||||
ctx.logger.info('Fetching template content from remote URL');
|
||||
|
||||
@@ -153,6 +154,7 @@ export function createFetchTemplateAction(options: {
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
markDirectories: true,
|
||||
followSymbolicLinks: false,
|
||||
});
|
||||
|
||||
const nonTemplatedEntries = new Set(
|
||||
@@ -164,6 +166,7 @@ export function createFetchTemplateAction(options: {
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
markDirectories: true,
|
||||
followSymbolicLinks: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ export const createFilesystemDeleteAction = () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
supportsDryRun: true,
|
||||
async handler(ctx) {
|
||||
if (!Array.isArray(ctx.input?.files)) {
|
||||
throw new InputError('files must be an Array');
|
||||
|
||||
@@ -66,6 +66,7 @@ export const createFilesystemRenameAction = () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
supportsDryRun: true,
|
||||
async handler(ctx) {
|
||||
if (!Array.isArray(ctx.input?.files)) {
|
||||
throw new InputError('files must be an Array');
|
||||
|
||||
+27
-46
@@ -14,21 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { parseRepoUrl, isExecutable } from './util';
|
||||
|
||||
import path from 'path';
|
||||
import { parseRepoUrl } from './util';
|
||||
import {
|
||||
GithubCredentialsProvider,
|
||||
ScmIntegrationRegistry,
|
||||
} from '@backstage/integration';
|
||||
import { zipObject } from 'lodash';
|
||||
import { createTemplateAction } from '../../createTemplateAction';
|
||||
import { Octokit } from 'octokit';
|
||||
import { InputError, CustomErrorBase } from '@backstage/errors';
|
||||
import { createPullRequest } from 'octokit-plugin-create-pull-request';
|
||||
import globby from 'globby';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
import { getOctokitOptions } from '../github/helpers';
|
||||
import { serializeDirectoryContents } from '../../../../lib/files';
|
||||
|
||||
export type Encoding = 'utf-8' | 'base64';
|
||||
|
||||
@@ -219,56 +217,39 @@ export const createPublishGithubPullRequestAction = ({
|
||||
? resolveSafeChildPath(ctx.workspacePath, sourcePath)
|
||||
: ctx.workspacePath;
|
||||
|
||||
const localFilePaths = await globby(['./**', './**/.*', '!.git'], {
|
||||
cwd: fileRoot,
|
||||
const directoryContents = await serializeDirectoryContents(fileRoot, {
|
||||
gitignore: true,
|
||||
dot: true,
|
||||
});
|
||||
|
||||
const fileContents = await Promise.all(
|
||||
localFilePaths.map(filePath => {
|
||||
const absPath = resolveSafeChildPath(fileRoot, filePath);
|
||||
const base64EncodedContent = fs
|
||||
.readFileSync(absPath)
|
||||
.toString('base64');
|
||||
const fileStat = fs.statSync(absPath);
|
||||
// See the properties of tree items
|
||||
// in https://docs.github.com/en/rest/reference/git#trees
|
||||
const githubTreeItemMode = isExecutable(fileStat.mode)
|
||||
? '100755'
|
||||
: '100644';
|
||||
// Always use base64 encoding to avoid doubling a binary file in size
|
||||
// due to interpreting a binary file as utf-8 and sending github
|
||||
// the utf-8 encoded content.
|
||||
//
|
||||
// For example, the original gradle-wrapper.jar is 57.8k in https://github.com/kennethzfeng/pull-request-test/pull/5/files.
|
||||
// Its size could be doubled to 98.3K (See https://github.com/kennethzfeng/pull-request-test/pull/4/files)
|
||||
const encoding: Encoding = 'base64';
|
||||
return {
|
||||
encoding: encoding,
|
||||
content: base64EncodedContent,
|
||||
mode: githubTreeItemMode,
|
||||
};
|
||||
}),
|
||||
const files = Object.fromEntries(
|
||||
directoryContents.map(file => [
|
||||
targetPath ? path.posix.join(targetPath, file.path) : file.path,
|
||||
{
|
||||
// See the properties of tree items
|
||||
// in https://docs.github.com/en/rest/reference/git#trees
|
||||
mode: file.executable ? '100755' : '100644',
|
||||
// Always use base64 encoding to avoid doubling a binary file in size
|
||||
// due to interpreting a binary file as utf-8 and sending github
|
||||
// the utf-8 encoded content.
|
||||
//
|
||||
// For example, the original gradle-wrapper.jar is 57.8k in https://github.com/kennethzfeng/pull-request-test/pull/5/files.
|
||||
// Its size could be doubled to 98.3K (See https://github.com/kennethzfeng/pull-request-test/pull/4/files)
|
||||
encoding: 'base64' as const,
|
||||
content: file.content.toString('base64'),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const repoFilePaths = localFilePaths.map(repoFilePath => {
|
||||
return targetPath ? `${targetPath}/${repoFilePath}` : repoFilePath;
|
||||
});
|
||||
|
||||
const changes = [
|
||||
{
|
||||
files: zipObject(repoFilePaths, fileContents),
|
||||
commit: title,
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await client.createPullRequest({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
changes,
|
||||
changes: [
|
||||
{
|
||||
files,
|
||||
commit: title,
|
||||
},
|
||||
],
|
||||
body: description,
|
||||
head: branchName,
|
||||
draft,
|
||||
|
||||
+14
-24
@@ -14,15 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createTemplateAction } from '../../createTemplateAction';
|
||||
import { readFile } from 'fs-extra';
|
||||
import { Gitlab } from '@gitbeaker/node';
|
||||
import globby from 'globby';
|
||||
import { Types } from '@gitbeaker/core';
|
||||
|
||||
import path from 'path';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { parseRepoUrl } from './util';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
import { serializeDirectoryContents } from '../../../../lib/files';
|
||||
|
||||
/**
|
||||
* Create a new action that creates a gitlab merge request.
|
||||
@@ -106,8 +105,6 @@ export const createPublishGitlabMergeRequestAction = (options: {
|
||||
const { host } = parseRepoUrl(repoUrl, integrations);
|
||||
const integrationConfig = integrations.gitlab.byHost(host);
|
||||
|
||||
const actions: Types.CommitAction[] = [];
|
||||
|
||||
const destinationBranch = ctx.input.branchName;
|
||||
|
||||
if (!integrationConfig) {
|
||||
@@ -128,28 +125,21 @@ export const createPublishGitlabMergeRequestAction = (options: {
|
||||
[tokenType]: token,
|
||||
});
|
||||
|
||||
const fileRoot = ctx.workspacePath;
|
||||
const localFilePaths = await globby([`${ctx.input.targetPath}/**`], {
|
||||
cwd: fileRoot,
|
||||
gitignore: true,
|
||||
dot: true,
|
||||
});
|
||||
|
||||
const fileContents = await Promise.all(
|
||||
localFilePaths.map(p => readFile(resolveSafeChildPath(fileRoot, p))),
|
||||
const targetPath = resolveSafeChildPath(
|
||||
ctx.workspacePath,
|
||||
ctx.input.targetPath,
|
||||
);
|
||||
|
||||
const repoFilePaths = localFilePaths.map(repoFilePath => {
|
||||
return repoFilePath;
|
||||
const fileContents = await serializeDirectoryContents(targetPath, {
|
||||
gitignore: true,
|
||||
});
|
||||
|
||||
for (let i = 0; i < repoFilePaths.length; i++) {
|
||||
actions.push({
|
||||
action: 'create',
|
||||
filePath: repoFilePaths[i],
|
||||
content: fileContents[i].toString(),
|
||||
});
|
||||
}
|
||||
const actions: Types.CommitAction[] = fileContents.map(file => ({
|
||||
action: 'create',
|
||||
filePath: path.posix.join(ctx.input.targetPath, file.path),
|
||||
encoding: 'base64',
|
||||
content: file.content.toString('base64'),
|
||||
execute_filemode: file.executable,
|
||||
}));
|
||||
|
||||
const projects = await api.Projects.show(ctx.input.projectid);
|
||||
|
||||
|
||||
@@ -39,12 +39,19 @@ export type ActionContext<Input extends JsonObject> = {
|
||||
createTemporaryDirectory(): Promise<string>;
|
||||
|
||||
templateInfo?: TemplateInfo;
|
||||
|
||||
/**
|
||||
* Whether this action invocation is a dry-run or not.
|
||||
* This will only ever be true if the actions as marked as supporting dry-runs.
|
||||
*/
|
||||
isDryRun?: boolean;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type TemplateAction<Input extends JsonObject> = {
|
||||
id: string;
|
||||
description?: string;
|
||||
supportsDryRun?: boolean;
|
||||
schema?: {
|
||||
input?: Schema;
|
||||
output?: Schema;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 { JsonObject } from '@backstage/types';
|
||||
import { TemplateAction, TemplateActionRegistry } from '../actions';
|
||||
|
||||
/** @internal */
|
||||
export class DecoratedActionsRegistry extends TemplateActionRegistry {
|
||||
constructor(
|
||||
private readonly innerRegistry: TemplateActionRegistry,
|
||||
extraActions: Array<TemplateAction<JsonObject>>,
|
||||
) {
|
||||
super();
|
||||
for (const action of extraActions) {
|
||||
this.register(action);
|
||||
}
|
||||
}
|
||||
|
||||
get(actionId: string): TemplateAction<JsonObject> {
|
||||
try {
|
||||
return super.get(actionId);
|
||||
} catch {
|
||||
return this.innerRegistry.get(actionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* 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 { ScmIntegrations } from '@backstage/integration';
|
||||
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
deserializeDirectoryContents,
|
||||
SerializedFile,
|
||||
serializeDirectoryContents,
|
||||
} from '../../lib/files';
|
||||
import { TemplateFilter } from '../../lib/templating';
|
||||
import { createTemplateAction, TemplateActionRegistry } from '../actions';
|
||||
import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner';
|
||||
import { TaskSecrets } from '../tasks/types';
|
||||
import { DecoratedActionsRegistry } from './DecoratedActionsRegistry';
|
||||
import fs from 'fs-extra';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
|
||||
interface DryRunInput {
|
||||
spec: TaskSpec;
|
||||
secrets?: TaskSecrets;
|
||||
directoryContents: SerializedFile[];
|
||||
}
|
||||
|
||||
interface DryRunResult {
|
||||
log: Array<{ body: JsonObject }>;
|
||||
directoryContents: SerializedFile[];
|
||||
output: JsonObject;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type TemplateTesterCreateOptions = {
|
||||
logger: Logger;
|
||||
integrations: ScmIntegrations;
|
||||
actionRegistry: TemplateActionRegistry;
|
||||
workingDirectory: string;
|
||||
additionalTemplateFilters?: Record<string, TemplateFilter>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes a dry-run of the provided template.
|
||||
*
|
||||
* The provided content will be extracted into a temporary directory
|
||||
* which is then use as the base for any relative file fetch paths.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function createDryRunner(options: TemplateTesterCreateOptions) {
|
||||
return async function dryRun(input: DryRunInput): Promise<DryRunResult> {
|
||||
let contentPromise;
|
||||
|
||||
const workflowRunner = new NunjucksWorkflowRunner({
|
||||
...options,
|
||||
actionRegistry: new DecoratedActionsRegistry(options.actionRegistry, [
|
||||
createTemplateAction({
|
||||
id: 'dry-run:extract',
|
||||
supportsDryRun: true,
|
||||
async handler(ctx) {
|
||||
contentPromise = serializeDirectoryContents(ctx.workspacePath);
|
||||
await contentPromise.catch(() => {});
|
||||
},
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const dryRunId = uuid();
|
||||
const log = new Array<{ body: JsonObject }>();
|
||||
const contentsPath = resolveSafeChildPath(
|
||||
options.workingDirectory,
|
||||
`dry-run-content-${dryRunId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await deserializeDirectoryContents(contentsPath, input.directoryContents);
|
||||
|
||||
const result = await workflowRunner.execute({
|
||||
spec: {
|
||||
...input.spec,
|
||||
steps: [
|
||||
...input.spec.steps,
|
||||
{
|
||||
id: dryRunId,
|
||||
name: 'dry-run:extract',
|
||||
action: 'dry-run:extract',
|
||||
},
|
||||
],
|
||||
templateInfo: {
|
||||
entityRef: 'template:default/dry-run',
|
||||
baseUrl: pathToFileURL(
|
||||
resolveSafeChildPath(contentsPath, 'template.yaml'),
|
||||
).toString(),
|
||||
},
|
||||
},
|
||||
secrets: input.secrets,
|
||||
// No need to update this at the end of the run, so just hard-code it
|
||||
done: false,
|
||||
isDryRun: true,
|
||||
getWorkspaceName: async () => `dry-run-${dryRunId}`,
|
||||
async emitLog(message: string, logMetadata?: JsonObject) {
|
||||
if (logMetadata?.stepId === dryRunId) {
|
||||
return;
|
||||
}
|
||||
log.push({
|
||||
body: {
|
||||
...logMetadata,
|
||||
message,
|
||||
},
|
||||
});
|
||||
},
|
||||
async complete() {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
});
|
||||
|
||||
if (!contentPromise) {
|
||||
throw new Error('Content extraction step was skipped');
|
||||
}
|
||||
const directoryContents = await contentPromise;
|
||||
|
||||
return {
|
||||
log,
|
||||
directoryContents,
|
||||
output: result.output,
|
||||
};
|
||||
} finally {
|
||||
await fs.remove(contentsPath);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 { createDryRunner } from './createDryRunner';
|
||||
@@ -23,7 +23,7 @@ import nunjucks from 'nunjucks';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { PassThrough } from 'stream';
|
||||
import { isTruthy } from './helper';
|
||||
import { generateExampleOutput, isTruthy } from './helper';
|
||||
import { validate as validateJsonSchema } from 'jsonschema';
|
||||
import { parseRepoUrl } from '../actions/builtin/publish/util';
|
||||
import { TemplateActionRegistry } from '../actions';
|
||||
@@ -236,6 +236,27 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
const action = this.options.actionRegistry.get(step.action);
|
||||
const { taskLogger, streamLogger } = createStepLogger({ task, step });
|
||||
|
||||
if (task.isDryRun && !action.supportsDryRun) {
|
||||
task.emitLog(
|
||||
`Skipping because ${action.id} does not support dry-run`,
|
||||
{
|
||||
stepId: step.id,
|
||||
status: 'skipped',
|
||||
},
|
||||
);
|
||||
const outputSchema = action.schema?.output;
|
||||
if (outputSchema) {
|
||||
context.steps[step.id] = {
|
||||
output: generateExampleOutput(outputSchema) as {
|
||||
[name in string]: JsonValue;
|
||||
},
|
||||
};
|
||||
} else {
|
||||
context.steps[step.id] = { output: {} };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Secrets are only passed when templating the input to actions for security reasons
|
||||
const input =
|
||||
(step.input &&
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { isArray } from 'lodash';
|
||||
import { Schema } from 'jsonschema';
|
||||
|
||||
/**
|
||||
* Returns true if the input is not `false`, `undefined`, `null`, `""`, `0`, or
|
||||
@@ -24,3 +25,31 @@ import { isArray } from 'lodash';
|
||||
export function isTruthy(value: any): boolean {
|
||||
return isArray(value) ? value.length > 0 : !!value;
|
||||
}
|
||||
|
||||
export function generateExampleOutput(schema: Schema): unknown {
|
||||
const { examples } = schema as { examples?: unknown };
|
||||
if (examples && Array.isArray(examples)) {
|
||||
return examples[0];
|
||||
}
|
||||
if (schema.type === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(schema.properties ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
generateExampleOutput(value),
|
||||
]),
|
||||
);
|
||||
} else if (schema.type === 'array') {
|
||||
const [firstSchema] = [schema.items]?.flat();
|
||||
if (firstSchema) {
|
||||
return [generateExampleOutput(firstSchema)];
|
||||
}
|
||||
return [];
|
||||
} else if (schema.type === 'string') {
|
||||
return '<example>';
|
||||
} else if (schema.type === 'number') {
|
||||
return 0;
|
||||
} else if (schema.type === 'boolean') {
|
||||
return false;
|
||||
}
|
||||
return '<unknown>';
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ export interface TaskContext {
|
||||
secrets?: TaskSecrets;
|
||||
createdBy?: string;
|
||||
done: boolean;
|
||||
isDryRun?: boolean;
|
||||
emitLog(message: string, logMetadata?: JsonObject): Promise<void>;
|
||||
complete(result: TaskCompletionState, metadata?: JsonObject): Promise<void>;
|
||||
getWorkspaceName(): Promise<string>;
|
||||
|
||||
@@ -22,18 +22,20 @@ import {
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Config, JsonObject } from '@backstage/config';
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import {
|
||||
TemplateEntityV1beta3,
|
||||
TaskSpec,
|
||||
templateEntityV1beta3Validator,
|
||||
} from '@backstage/plugin-scaffolder-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { validate } from 'jsonschema';
|
||||
import { Logger } from 'winston';
|
||||
import { TemplateFilter } from '../lib';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createBuiltinActions,
|
||||
DatabaseTaskStore,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
TemplateAction,
|
||||
TemplateActionRegistry,
|
||||
} from '../scaffolder';
|
||||
import { createDryRunner } from '../scaffolder/dryrun';
|
||||
import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker';
|
||||
import { getEntityBaseUrl, getWorkingDirectory, findTemplate } from './helpers';
|
||||
|
||||
@@ -129,6 +132,14 @@ export async function createRouter(
|
||||
actionsToRegister.forEach(action => actionRegistry.register(action));
|
||||
workers.forEach(worker => worker.start());
|
||||
|
||||
const dryRunner = createDryRunner({
|
||||
actionRegistry,
|
||||
integrations,
|
||||
logger,
|
||||
workingDirectory,
|
||||
additionalTemplateFilters,
|
||||
});
|
||||
|
||||
router
|
||||
.get(
|
||||
'/v2/templates/:namespace/:kind/:name/parameter-schema',
|
||||
@@ -324,6 +335,67 @@ export async function createRouter(
|
||||
subscription.unsubscribe();
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
})
|
||||
.post('/v2/dry-run', async (req, res) => {
|
||||
const bodySchema = z.object({
|
||||
template: z.unknown(),
|
||||
values: z.record(z.unknown()),
|
||||
secrets: z.record(z.string()).optional(),
|
||||
directoryContents: z.array(
|
||||
z.object({ path: z.string(), base64Content: z.string() }),
|
||||
),
|
||||
});
|
||||
const body = await bodySchema.parseAsync(req.body).catch(e => {
|
||||
throw new InputError(`Malformed request: ${e}`);
|
||||
});
|
||||
|
||||
const template = body.template as TemplateEntityV1beta3;
|
||||
if (!(await templateEntityV1beta3Validator.check(template))) {
|
||||
throw new InputError('Input template is not a template');
|
||||
}
|
||||
|
||||
const { token } = parseBearerToken(req.headers.authorization);
|
||||
|
||||
for (const parameters of [template.spec.parameters ?? []].flat()) {
|
||||
const result = validate(body.values, parameters);
|
||||
if (!result.valid) {
|
||||
res.status(400).json({ errors: result.errors });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const steps = template.spec.steps.map((step, index) => ({
|
||||
...step,
|
||||
id: step.id ?? `step-${index + 1}`,
|
||||
name: step.name ?? step.action,
|
||||
}));
|
||||
|
||||
const result = await dryRunner({
|
||||
spec: {
|
||||
apiVersion: template.apiVersion,
|
||||
steps,
|
||||
output: template.spec.output ?? {},
|
||||
parameters: body.values as JsonObject,
|
||||
},
|
||||
directoryContents: (body.directoryContents ?? []).map(file => ({
|
||||
path: file.path,
|
||||
content: Buffer.from(file.base64Content, 'base64'),
|
||||
})),
|
||||
secrets: {
|
||||
...body.secrets,
|
||||
...(token && { backstageToken: token }),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
...result,
|
||||
steps,
|
||||
directoryContents: result.directoryContents.map(file => ({
|
||||
path: file.path,
|
||||
executable: file.executable,
|
||||
base64Content: file.content.toString('base64'),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -25,6 +25,7 @@ import { default as React_2 } from 'react';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
|
||||
import { TaskStep } from '@backstage/plugin-scaffolder-common';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
|
||||
// @public
|
||||
@@ -226,6 +227,8 @@ export type RouterProps = {
|
||||
|
||||
// @public
|
||||
export interface ScaffolderApi {
|
||||
// (undocumented)
|
||||
dryRun?(options: ScaffolderDryRunOptions): Promise<ScaffolderDryRunResponse>;
|
||||
// (undocumented)
|
||||
getIntegrationsList(
|
||||
options: ScaffolderGetIntegrationsListOptions,
|
||||
@@ -256,6 +259,8 @@ export class ScaffolderClient implements ScaffolderApi {
|
||||
useLongPollingLogs?: boolean;
|
||||
});
|
||||
// (undocumented)
|
||||
dryRun(options: ScaffolderDryRunOptions): Promise<ScaffolderDryRunResponse>;
|
||||
// (undocumented)
|
||||
getIntegrationsList(
|
||||
options: ScaffolderGetIntegrationsListOptions,
|
||||
): Promise<ScaffolderGetIntegrationsListResponse>;
|
||||
@@ -274,6 +279,37 @@ export class ScaffolderClient implements ScaffolderApi {
|
||||
streamLogs(options: ScaffolderStreamLogsOptions): Observable<LogEvent>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ScaffolderDryRunOptions {
|
||||
// (undocumented)
|
||||
directoryContents: {
|
||||
path: string;
|
||||
base64Content: string;
|
||||
}[];
|
||||
// (undocumented)
|
||||
secrets?: Record<string, string>;
|
||||
// (undocumented)
|
||||
template: JsonValue;
|
||||
// (undocumented)
|
||||
values: JsonObject;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ScaffolderDryRunResponse {
|
||||
// (undocumented)
|
||||
directoryContents: Array<{
|
||||
path: string;
|
||||
base64Content: string;
|
||||
executable: boolean;
|
||||
}>;
|
||||
// (undocumented)
|
||||
log: Array<Pick<LogEvent, 'body'>>;
|
||||
// (undocumented)
|
||||
output: ScaffolderTaskOutput;
|
||||
// (undocumented)
|
||||
steps: TaskStep[];
|
||||
}
|
||||
|
||||
// @public
|
||||
export const ScaffolderFieldExtensions: React_2.ComponentType;
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@react-hookz/web": "^13.0.0",
|
||||
"@rjsf/core": "^3.2.1",
|
||||
"@rjsf/material-ui": "^3.2.1",
|
||||
"@types/json-schema": "^7.0.9",
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
ScaffolderGetIntegrationsListOptions,
|
||||
ScaffolderGetIntegrationsListResponse,
|
||||
ScaffolderTask,
|
||||
ScaffolderDryRunOptions,
|
||||
ScaffolderDryRunResponse,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -172,6 +174,30 @@ export class ScaffolderClient implements ScaffolderApi {
|
||||
return this.streamLogsEventStream(options);
|
||||
}
|
||||
|
||||
async dryRun(
|
||||
options: ScaffolderDryRunOptions,
|
||||
): Promise<ScaffolderDryRunResponse> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
|
||||
const response = await this.fetchApi.fetch(`${baseUrl}/v2/dry-run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
template: options.template,
|
||||
values: options.values,
|
||||
secrets: options.secrets,
|
||||
directoryContents: options.directoryContents,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private streamLogsEventStream({
|
||||
taskId,
|
||||
after,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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 { FileEntry, parseFileEntires } from './FileBrowser';
|
||||
|
||||
function dir(path: string, ...children: FileEntry[]): FileEntry {
|
||||
return {
|
||||
type: 'directory',
|
||||
path: path,
|
||||
name: path.split('/').pop()!,
|
||||
children: children,
|
||||
};
|
||||
}
|
||||
|
||||
function file(path: string): FileEntry {
|
||||
return {
|
||||
type: 'file',
|
||||
path: path,
|
||||
name: path.split('/').pop()!,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseFileEntires', () => {
|
||||
it('parses an empty list', () => {
|
||||
expect(parseFileEntires([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a single file', () => {
|
||||
expect(parseFileEntires(['a.txt'])).toEqual([file('a.txt')]);
|
||||
expect(parseFileEntires(['a/b.txt'])).toEqual([dir('a', file('a/b.txt'))]);
|
||||
expect(parseFileEntires(['a/b/c.txt'])).toEqual([
|
||||
dir('a', dir('a/b', file('a/b/c.txt'))),
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses multiple files', () => {
|
||||
expect(parseFileEntires(['a.txt', 'b.txt'])).toEqual([
|
||||
file('a.txt'),
|
||||
file('b.txt'),
|
||||
]);
|
||||
expect(parseFileEntires(['a.txt', 'a/b.txt'])).toEqual([
|
||||
dir('a', file('a/b.txt')),
|
||||
file('a.txt'),
|
||||
]);
|
||||
expect(parseFileEntires(['a.txt', 'a/b.txt', 'a/c.txt'])).toEqual([
|
||||
dir('a', file('a/b.txt'), file('a/c.txt')),
|
||||
file('a.txt'),
|
||||
]);
|
||||
expect(parseFileEntires(['a.txt', 'a/b/c.txt', 'a/b/d.txt'])).toEqual([
|
||||
dir('a', dir('a/b', file('a/b/c.txt'), file('a/b/d.txt'))),
|
||||
file('a.txt'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws an error on invalid filenames', () => {
|
||||
expect(() => parseFileEntires([''])).toThrow(`Invalid path part: ''`);
|
||||
expect(() => parseFileEntires(['/'])).toThrow(`Invalid path part: ''`);
|
||||
expect(() => parseFileEntires(['a/'])).toThrow(`Invalid path part: ''`);
|
||||
expect(() => parseFileEntires(['/a.txt'])).toThrow(`Invalid path part: ''`);
|
||||
expect(() => parseFileEntires(['a//a.txt'])).toThrow(
|
||||
`Invalid path part: ''`,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error on conflicting directory and filenames', () => {
|
||||
expect(() => parseFileEntires(['a', 'a'])).toThrow(
|
||||
`Duplicate filename at 'a'`,
|
||||
);
|
||||
expect(() => parseFileEntires(['a', 'a/b'])).toThrow(
|
||||
`Duplicate filename at 'a'`,
|
||||
);
|
||||
expect(() => parseFileEntires(['a/b', 'a'])).toThrow(
|
||||
`Duplicate filename at 'a'`,
|
||||
);
|
||||
expect(() => parseFileEntires(['a/b', 'a/b/c'])).toThrow(
|
||||
`Duplicate filename at 'a/b'`,
|
||||
);
|
||||
expect(() => parseFileEntires(['a/b/c', 'a/b/c'])).toThrow(
|
||||
`Duplicate filename at 'a/b/c'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import TreeView from '@material-ui/lab/TreeView';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
|
||||
import TreeItem from '@material-ui/lab/TreeItem';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
whiteSpace: 'nowrap',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
export type FileEntry =
|
||||
| {
|
||||
type: 'file';
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
type: 'directory';
|
||||
name: string;
|
||||
path: string;
|
||||
children: FileEntry[];
|
||||
};
|
||||
|
||||
export function parseFileEntires(paths: string[]): FileEntry[] {
|
||||
const root: FileEntry = {
|
||||
type: 'directory',
|
||||
name: '',
|
||||
path: '',
|
||||
children: [],
|
||||
};
|
||||
|
||||
for (const path of paths.slice().sort()) {
|
||||
const parts = path.split('/');
|
||||
|
||||
let current = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (part === '') {
|
||||
throw new Error(`Invalid path part: ''`);
|
||||
}
|
||||
|
||||
const entryPath = parts.slice(0, i + 1).join('/');
|
||||
|
||||
const existing = current.children.find(child => child.name === part);
|
||||
if (existing?.type === 'file') {
|
||||
throw new Error(`Duplicate filename at '${entryPath}'`);
|
||||
} else if (existing) {
|
||||
current = existing;
|
||||
} else {
|
||||
if (i < parts.length - 1) {
|
||||
const newEntry: FileEntry = {
|
||||
type: 'directory',
|
||||
name: part,
|
||||
path: entryPath,
|
||||
children: [],
|
||||
};
|
||||
const firstFileIndex = current.children.findIndex(
|
||||
child => child.type === 'file',
|
||||
);
|
||||
current.children.splice(firstFileIndex, 0, newEntry);
|
||||
current = newEntry;
|
||||
} else {
|
||||
current.children.push({
|
||||
type: 'file',
|
||||
name: part,
|
||||
path: entryPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root.children;
|
||||
}
|
||||
|
||||
function FileTreeItem({ entry }: { entry: FileEntry }) {
|
||||
if (entry.type === 'file') {
|
||||
return <TreeItem nodeId={entry.path} label={entry.name} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeItem nodeId={entry.path} label={entry.name}>
|
||||
{entry.children.map(child => (
|
||||
<FileTreeItem key={child.path} entry={child} />
|
||||
))}
|
||||
</TreeItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileBrowserProps {
|
||||
/** A list of all filepaths to show, directories are separated with a `/` */
|
||||
filePaths: string[];
|
||||
/** The currently selected file */
|
||||
selected?: string;
|
||||
/** Callback for when a file is selected */
|
||||
onSelect?(filePath: string): void;
|
||||
}
|
||||
|
||||
/** A simple file browser that allows you to select individual files */
|
||||
export function FileBrowser(props: FileBrowserProps) {
|
||||
const classes = useStyles();
|
||||
|
||||
const fileTree = useMemo(
|
||||
() => parseFileEntires(props.filePaths),
|
||||
[props.filePaths],
|
||||
);
|
||||
|
||||
return (
|
||||
<TreeView
|
||||
selected={props.selected}
|
||||
className={classes.root}
|
||||
defaultCollapseIcon={<ExpandMoreIcon />}
|
||||
defaultExpandIcon={<ChevronRightIcon />}
|
||||
onNodeSelect={(_e: unknown, nodeId: string) => {
|
||||
if (props.onSelect && props.filePaths.includes(nodeId)) {
|
||||
props.onSelect(nodeId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{fileTree.map(entry => (
|
||||
<FileTreeItem key={entry.path} entry={entry} />
|
||||
))}
|
||||
</TreeView>
|
||||
);
|
||||
}
|
||||
@@ -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 { FileBrowser } from './FileBrowser';
|
||||
@@ -54,6 +54,7 @@ type Props = {
|
||||
onFinish?: () => Promise<void>;
|
||||
widgets?: FormProps<any>['widgets'];
|
||||
fields?: FormProps<any>['fields'];
|
||||
finishButtonLabel?: string;
|
||||
};
|
||||
|
||||
export function getUiSchemasFromSteps(steps: Step[]): UiSchema[] {
|
||||
@@ -110,7 +111,15 @@ export function getReviewData(formData: Record<string, any>, steps: Step[]) {
|
||||
}
|
||||
|
||||
export const MultistepJsonForm = (props: Props) => {
|
||||
const { formData, onChange, onReset, onFinish, fields, widgets } = props;
|
||||
const {
|
||||
formData,
|
||||
onChange,
|
||||
onReset,
|
||||
onFinish,
|
||||
fields,
|
||||
widgets,
|
||||
finishButtonLabel,
|
||||
} = props;
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [disableButtons, setDisableButtons] = useState(false);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
@@ -171,8 +180,9 @@ export const MultistepJsonForm = (props: Props) => {
|
||||
try {
|
||||
await onFinish();
|
||||
} catch (err) {
|
||||
setDisableButtons(false);
|
||||
errorApi.post(err);
|
||||
} finally {
|
||||
setDisableButtons(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,7 +249,7 @@ export const MultistepJsonForm = (props: Props) => {
|
||||
onClick={handleCreate}
|
||||
disabled={!onFinish || disableButtons}
|
||||
>
|
||||
Create
|
||||
{finishButtonLabel ?? 'Create'}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Content>
|
||||
|
||||
@@ -161,16 +161,16 @@ function TaskStepIconComponent(props: StepIconProps) {
|
||||
}
|
||||
|
||||
export const TaskStatusStepper = memo(
|
||||
({
|
||||
steps,
|
||||
currentStepId,
|
||||
onUserStepChange,
|
||||
}: {
|
||||
(props: {
|
||||
steps: TaskStep[];
|
||||
currentStepId: string | undefined;
|
||||
onUserStepChange: (id: string) => void;
|
||||
classes?: {
|
||||
root?: string;
|
||||
};
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const { steps, currentStepId, onUserStepChange } = props;
|
||||
const classes = useStyles(props);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
|
||||
@@ -41,7 +41,10 @@ export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
|
||||
.filter(({ url, entityRef }) => url || entityRef)
|
||||
.map(({ url, entityRef, title, icon }) => {
|
||||
if (entityRef) {
|
||||
const entityName = parseEntityRef(entityRef);
|
||||
const entityName = parseEntityRef(entityRef, {
|
||||
defaultKind: '<unknown>',
|
||||
defaultNamespace: '<unknown>',
|
||||
});
|
||||
const target = entityRoute(entityName);
|
||||
return { title, icon, url: target };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* 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 { ErrorPanel, Progress } from '@backstage/core-components';
|
||||
import { useAsync, useRerender } from '@react-hookz/web';
|
||||
import React, { createContext, ReactNode, useContext, useEffect } from 'react';
|
||||
import {
|
||||
TemplateDirectoryAccess,
|
||||
TemplateFileAccess,
|
||||
} from '../../lib/filesystem';
|
||||
|
||||
const MAX_SIZE = 1024 * 1024;
|
||||
const MAX_SIZE_MESSAGE = 'This file is too large to be displayed';
|
||||
|
||||
interface DirectoryEditorFile {
|
||||
/** The path of the file relative to the root directory */
|
||||
path: string;
|
||||
/** The staged content of the file */
|
||||
content: string;
|
||||
/** Whether the staged content matches what is on disk */
|
||||
dirty: boolean;
|
||||
|
||||
/** Update the staged content of the file without saving */
|
||||
updateContent(content: string): void;
|
||||
/** Save the staged content of the file to disk */
|
||||
save(): Promise<void>;
|
||||
/** Reload the staged content of the file from disk */
|
||||
reload(): Promise<void>;
|
||||
}
|
||||
|
||||
interface DirectoryEditor {
|
||||
/** A list of all files in the edited directory */
|
||||
files: Array<DirectoryEditorFile>;
|
||||
|
||||
/** The currently selected file */
|
||||
selectedFile: DirectoryEditorFile | undefined;
|
||||
/** Switch the selected file */
|
||||
setSelectedFile(path: string | undefined): void;
|
||||
|
||||
/** Save all files to disk */
|
||||
save(): Promise<void>;
|
||||
/** Reload all files from disk */
|
||||
reload(): Promise<void>;
|
||||
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
class DirectoryEditorFileManager implements DirectoryEditorFile {
|
||||
readonly #access: TemplateFileAccess;
|
||||
readonly #signalUpdate: () => void;
|
||||
|
||||
#content?: string;
|
||||
#savedContent?: string;
|
||||
|
||||
constructor(access: TemplateFileAccess, signalUpdate: () => void) {
|
||||
this.#access = access;
|
||||
this.#signalUpdate = signalUpdate;
|
||||
}
|
||||
|
||||
get path() {
|
||||
return this.#access.path;
|
||||
}
|
||||
|
||||
get content() {
|
||||
return this.#content ?? MAX_SIZE_MESSAGE;
|
||||
}
|
||||
|
||||
updateContent(content: string): void {
|
||||
if (this.#content === undefined) {
|
||||
return;
|
||||
}
|
||||
this.#content = content;
|
||||
this.#signalUpdate();
|
||||
}
|
||||
|
||||
get dirty() {
|
||||
return this.#content !== this.#savedContent;
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (this.#content !== undefined) {
|
||||
await this.#access.save(this.#content);
|
||||
this.#savedContent = this.#content;
|
||||
this.#signalUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
const file = await this.#access.file();
|
||||
if (file.size > MAX_SIZE) {
|
||||
if (this.#content !== undefined) {
|
||||
this.#content = undefined;
|
||||
this.#savedContent = undefined;
|
||||
this.#signalUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
if (this.#content !== content) {
|
||||
this.#content = content;
|
||||
this.#savedContent = content;
|
||||
this.#signalUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DirectoryEditorManager implements DirectoryEditor {
|
||||
readonly #access: TemplateDirectoryAccess;
|
||||
readonly #listeners = new Set<() => void>();
|
||||
|
||||
#files: DirectoryEditorFile[] = [];
|
||||
#selectedFile: DirectoryEditorFile | undefined;
|
||||
|
||||
constructor(access: TemplateDirectoryAccess) {
|
||||
this.#access = access;
|
||||
}
|
||||
|
||||
get files() {
|
||||
return this.#files;
|
||||
}
|
||||
|
||||
get selectedFile() {
|
||||
return this.#selectedFile;
|
||||
}
|
||||
|
||||
setSelectedFile = (path: string | undefined): void => {
|
||||
const prev = this.#selectedFile;
|
||||
const next = this.#files.find(file => file.path === path);
|
||||
if (prev !== next) {
|
||||
this.#selectedFile = next;
|
||||
this.#signalUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
get dirty() {
|
||||
return this.#files.some(file => file.dirty);
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
await Promise.all(this.#files.map(file => file.save()));
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
const selectedPath = this.#selectedFile?.path;
|
||||
|
||||
const files = await this.#access.listFiles();
|
||||
const fileManagers = await Promise.all(
|
||||
files.map(async file => {
|
||||
const manager = new DirectoryEditorFileManager(
|
||||
file,
|
||||
this.#signalUpdate,
|
||||
);
|
||||
await manager.reload();
|
||||
return manager;
|
||||
}),
|
||||
);
|
||||
this.#files.length = 0;
|
||||
this.#files.push(...fileManagers);
|
||||
|
||||
this.setSelectedFile(selectedPath);
|
||||
this.#signalUpdate();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.#listeners.add(listener);
|
||||
return () => {
|
||||
this.#listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
#signalUpdate = () => {
|
||||
this.#listeners.forEach(listener => listener());
|
||||
};
|
||||
}
|
||||
|
||||
const DirectoryEditorContext = createContext<DirectoryEditor | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function useDirectoryEditor(): DirectoryEditor {
|
||||
const value = useContext(DirectoryEditorContext);
|
||||
const rerender = useRerender();
|
||||
|
||||
useEffect(() => value?.subscribe(rerender), [value, rerender]);
|
||||
|
||||
if (!value) {
|
||||
throw new Error('must be used within a DirectoryEditorProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
interface DirectoryEditorProviderProps {
|
||||
directory: TemplateDirectoryAccess;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function DirectoryEditorProvider(props: DirectoryEditorProviderProps) {
|
||||
const { directory } = props;
|
||||
|
||||
const [{ result, error }, { execute }] = useAsync(
|
||||
async (dir: TemplateDirectoryAccess) => {
|
||||
const manager = new DirectoryEditorManager(dir);
|
||||
await manager.reload();
|
||||
|
||||
const firstYaml = manager.files.find(file => file.path.match(/\.ya?ml$/));
|
||||
if (firstYaml) {
|
||||
manager.setSelectedFile(firstYaml.path);
|
||||
}
|
||||
|
||||
return manager;
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
execute(directory);
|
||||
}, [execute, directory]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorPanel error={error} />;
|
||||
} else if (!result) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DirectoryEditorContext.Provider value={result}>
|
||||
{props.children}
|
||||
</DirectoryEditorContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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 yaml from 'yaml';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { scaffolderApiRef } from '../../api';
|
||||
import { ScaffolderDryRunResponse } from '../../types';
|
||||
|
||||
interface DryRunOptions {
|
||||
templateContent: string;
|
||||
values: JsonObject;
|
||||
files: Array<{ path: string; content: string }>;
|
||||
}
|
||||
|
||||
interface DryRunResult extends ScaffolderDryRunResponse {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface DryRun {
|
||||
results: DryRunResult[];
|
||||
selectedResult: DryRunResult | undefined;
|
||||
|
||||
selectResult(id: number): void;
|
||||
deleteResult(id: number): void;
|
||||
execute(options: DryRunOptions): Promise<void>;
|
||||
}
|
||||
|
||||
const DryRunContext = createContext<DryRun | undefined>(undefined);
|
||||
|
||||
interface DryRunProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DryRunProvider(props: DryRunProviderProps) {
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
|
||||
const [state, setState] = useState<
|
||||
Pick<DryRun, 'results' | 'selectedResult'>
|
||||
>({
|
||||
results: [],
|
||||
selectedResult: undefined,
|
||||
});
|
||||
const idRef = useRef(1);
|
||||
|
||||
const selectResult = useCallback((id: number) => {
|
||||
setState(prevState => {
|
||||
const result = prevState.results.find(r => r.id === id);
|
||||
if (result === prevState.selectedResult) {
|
||||
return prevState;
|
||||
}
|
||||
return {
|
||||
results: prevState.results,
|
||||
selectedResult: result,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deleteResult = useCallback((id: number) => {
|
||||
setState(prevState => {
|
||||
const index = prevState.results.findIndex(r => r.id === id);
|
||||
if (index === -1) {
|
||||
return prevState;
|
||||
}
|
||||
const newResults = prevState.results.slice();
|
||||
const [deleted] = newResults.splice(index, 1);
|
||||
return {
|
||||
results: newResults,
|
||||
selectedResult:
|
||||
prevState.selectedResult?.id === deleted.id
|
||||
? newResults[0]
|
||||
: prevState.selectedResult,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(
|
||||
async (options: DryRunOptions) => {
|
||||
if (!scaffolderApi.dryRun) {
|
||||
throw new Error('Scaffolder API does not support dry-run');
|
||||
}
|
||||
|
||||
const parsed = yaml.parse(options.templateContent);
|
||||
|
||||
const response = await scaffolderApi.dryRun({
|
||||
template: parsed,
|
||||
values: options.values,
|
||||
secrets: {},
|
||||
directoryContents: options.files.map(file => ({
|
||||
path: file.path,
|
||||
base64Content: btoa(file.content),
|
||||
})),
|
||||
});
|
||||
|
||||
const result = {
|
||||
...response,
|
||||
id: idRef.current++,
|
||||
};
|
||||
|
||||
setState(prevState => ({
|
||||
results: [...prevState.results, result],
|
||||
selectedResult: prevState.selectedResult ?? result,
|
||||
}));
|
||||
},
|
||||
[scaffolderApi],
|
||||
);
|
||||
|
||||
const dryRun = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
selectResult,
|
||||
deleteResult,
|
||||
execute,
|
||||
}),
|
||||
[state, selectResult, deleteResult, execute],
|
||||
);
|
||||
|
||||
return (
|
||||
<DryRunContext.Provider value={dryRun}>
|
||||
{props.children}
|
||||
</DryRunContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDryRun(): DryRun {
|
||||
const value = useContext(DryRunContext);
|
||||
if (!value) {
|
||||
throw new Error('must be used within a DryRunProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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, TestApiProvider } from '@backstage/test-utils';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React, { useEffect } from 'react';
|
||||
import { scaffolderApiRef } from '../../../api';
|
||||
import { DryRunProvider, useDryRun } from '../DryRunContext';
|
||||
import { DryRunResults } from './DryRunResults';
|
||||
|
||||
function DryRunRemote({
|
||||
execute,
|
||||
remove,
|
||||
}: {
|
||||
execute?: boolean;
|
||||
remove?: boolean;
|
||||
}) {
|
||||
const dryRun = useDryRun();
|
||||
|
||||
useEffect(() => {
|
||||
if (execute) {
|
||||
dryRun.execute({
|
||||
templateContent: '',
|
||||
values: {},
|
||||
files: [],
|
||||
});
|
||||
}
|
||||
if (remove) {
|
||||
if (dryRun.selectedResult) {
|
||||
dryRun.deleteResult(dryRun.selectedResult.id);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [execute, remove]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const mockScaffolderApi = {
|
||||
dryRun: async () => ({
|
||||
directoryContents: [],
|
||||
log: [],
|
||||
output: {},
|
||||
steps: [],
|
||||
}),
|
||||
};
|
||||
|
||||
describe('DryRunResults', () => {
|
||||
it('renders without exploding', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<DryRunProvider>
|
||||
<DryRunResults />
|
||||
</DryRunProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
expect(screen.getByText('Dry-run results')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands when dry-run result is added and toggles on click, and disappears when results are gone', async () => {
|
||||
const { rerender } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<DryRunProvider>
|
||||
<DryRunRemote />
|
||||
<DryRunResults />
|
||||
</DryRunProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Files')).not.toBeVisible();
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<DryRunProvider>
|
||||
<DryRunRemote execute />
|
||||
<DryRunResults />
|
||||
</DryRunProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Files')).toBeVisible();
|
||||
|
||||
await userEvent.click(screen.getByText('Dry-run results'));
|
||||
await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible());
|
||||
|
||||
await userEvent.click(screen.getByText('Dry-run results'));
|
||||
expect(screen.getByText('Files')).toBeVisible();
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<DryRunProvider>
|
||||
<DryRunRemote remove />
|
||||
<DryRunResults />
|
||||
</DryRunProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 { BackstageTheme } from '@backstage/theme';
|
||||
import Accordion from '@material-ui/core/Accordion';
|
||||
import AccordionDetails from '@material-ui/core/AccordionDetails';
|
||||
import AccordionSummary from '@material-ui/core/AccordionSummary';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandLess';
|
||||
import { usePrevious } from '@react-hookz/web';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDryRun } from '../DryRunContext';
|
||||
import { DryRunResultsList } from './DryRunResultsList';
|
||||
import { DryRunResultsView } from './DryRunResultsView';
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
header: {
|
||||
height: 48,
|
||||
minHeight: 0,
|
||||
'&.Mui-expanded': {
|
||||
height: 48,
|
||||
minHeight: 0,
|
||||
},
|
||||
},
|
||||
content: {
|
||||
display: 'grid',
|
||||
background: theme.palette.background.default,
|
||||
gridTemplateColumns: '180px auto 1fr',
|
||||
gridTemplateRows: '1fr',
|
||||
padding: 0,
|
||||
height: 400,
|
||||
},
|
||||
}));
|
||||
|
||||
export function DryRunResults() {
|
||||
const classes = useStyles();
|
||||
const dryRun = useDryRun();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [hidden, setHidden] = useState(true);
|
||||
|
||||
const resultsLength = dryRun.results.length;
|
||||
const prevResultsLength = usePrevious(resultsLength);
|
||||
useEffect(() => {
|
||||
if (prevResultsLength === 0 && resultsLength === 1) {
|
||||
setHidden(false);
|
||||
setExpanded(true);
|
||||
} else if (prevResultsLength === 1 && resultsLength === 0) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [prevResultsLength, resultsLength]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion
|
||||
variant="outlined"
|
||||
expanded={expanded}
|
||||
hidden={resultsLength === 0 && hidden}
|
||||
onChange={(_, exp) => setExpanded(exp)}
|
||||
onTransitionEnd={() => resultsLength === 0 && setHidden(true)}
|
||||
>
|
||||
<AccordionSummary
|
||||
className={classes.header}
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
>
|
||||
<Typography>Dry-run results</Typography>
|
||||
</AccordionSummary>
|
||||
<Divider orientation="horizontal" />
|
||||
<AccordionDetails className={classes.content}>
|
||||
<DryRunResultsList />
|
||||
<Divider orientation="horizontal" />
|
||||
<DryRunResultsView />
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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, TestApiProvider } from '@backstage/test-utils';
|
||||
import { act, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React, { useEffect } from 'react';
|
||||
import { scaffolderApiRef } from '../../../api';
|
||||
import { DryRunProvider, useDryRun } from '../DryRunContext';
|
||||
import { DryRunResultsList } from './DryRunResultsList';
|
||||
|
||||
function DryRunRemote({ execute }: { execute?: number }) {
|
||||
const dryRun = useDryRun();
|
||||
|
||||
useEffect(() => {
|
||||
if (execute) {
|
||||
dryRun.execute({
|
||||
templateContent: '',
|
||||
values: {},
|
||||
files: [],
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [execute]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const mockScaffolderApi = {
|
||||
dryRun: async () => ({
|
||||
directoryContents: [],
|
||||
log: [],
|
||||
output: {},
|
||||
steps: [],
|
||||
}),
|
||||
};
|
||||
|
||||
describe('DryRunResultsList', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<DryRunProvider>
|
||||
<DryRunResultsList />
|
||||
</DryRunProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
expect(rendered.baseElement.querySelector('ul')).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('adds new result items and deletes them', async () => {
|
||||
const { rerender } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<DryRunProvider>
|
||||
<DryRunRemote execute={1} />
|
||||
<DryRunResultsList />
|
||||
</DryRunProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Result 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Result 2')).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<DryRunProvider>
|
||||
<DryRunRemote execute={2} />
|
||||
<DryRunResultsList />
|
||||
</DryRunProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Result 1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Result 2')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getAllByLabelText('delete')[0]);
|
||||
|
||||
expect(screen.queryByText('Result 1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Result 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+83
@@ -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 { BackstageTheme } from '@backstage/theme';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon';
|
||||
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import CancelIcon from '@material-ui/icons/Cancel';
|
||||
import CheckIcon from '@material-ui/icons/Check';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import React from 'react';
|
||||
import { useDryRun } from '../DryRunContext';
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
root: {
|
||||
overflowY: 'auto',
|
||||
background: theme.palette.background.default,
|
||||
},
|
||||
iconSuccess: {
|
||||
minWidth: 0,
|
||||
marginRight: theme.spacing(1),
|
||||
color: theme.palette.status.ok,
|
||||
},
|
||||
iconFailure: {
|
||||
minWidth: 0,
|
||||
marginRight: theme.spacing(1),
|
||||
color: theme.palette.status.error,
|
||||
},
|
||||
}));
|
||||
|
||||
export function DryRunResultsList() {
|
||||
const classes = useStyles();
|
||||
const dryRun = useDryRun();
|
||||
|
||||
return (
|
||||
<List className={classes.root} dense>
|
||||
{dryRun.results.map(result => {
|
||||
const failed = result.log.some(l => l.body.status === 'failed');
|
||||
return (
|
||||
<ListItem
|
||||
button
|
||||
key={result.id}
|
||||
selected={dryRun.selectedResult?.id === result.id}
|
||||
onClick={() => dryRun.selectResult(result.id)}
|
||||
>
|
||||
<ListItemIcon
|
||||
className={failed ? classes.iconFailure : classes.iconSuccess}
|
||||
>
|
||||
{failed ? <CancelIcon /> : <CheckIcon />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={`Result ${result.id}`} />
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete"
|
||||
onClick={() => dryRun.deleteResult(result.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 { makeStyles } from '@material-ui/core/styles';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import React, { Children, ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '280px auto 3fr',
|
||||
gridTemplateRows: '1fr',
|
||||
},
|
||||
child: {
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
},
|
||||
firstChild: {
|
||||
background: theme.palette.background.paper,
|
||||
},
|
||||
}));
|
||||
|
||||
export function DryRunResultsSplitView(props: { children: ReactNode }) {
|
||||
const classes = useStyles();
|
||||
const childArray = Children.toArray(props.children);
|
||||
|
||||
if (childArray.length !== 2) {
|
||||
throw new Error('must have exactly 2 children');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classNames(classes.child, classes.firstChild)}>
|
||||
{childArray[0]}
|
||||
</div>
|
||||
<Divider orientation="horizontal" />
|
||||
<div className={classes.child}>{childArray[1]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+99
@@ -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 { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { scaffolderApiRef } from '../../../api';
|
||||
import { DryRunProvider, useDryRun } from '../DryRunContext';
|
||||
import { DryRunResultsView } from './DryRunResultsView';
|
||||
|
||||
// The <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
|
||||
jest.mock('react-virtualized-auto-sizer', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
children: (size: { width: number; height: number }) => ReactNode;
|
||||
}) => <>{props.children({ width: 400, height: 200 })}</>,
|
||||
}));
|
||||
|
||||
function DryRunRemote() {
|
||||
const dryRun = useDryRun();
|
||||
|
||||
useEffect(() => {
|
||||
dryRun.execute({
|
||||
templateContent: '',
|
||||
values: {},
|
||||
files: [],
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('DryRunResultsView', () => {
|
||||
it('renders a result', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
scaffolderApiRef,
|
||||
{
|
||||
dryRun: async () => ({
|
||||
directoryContents: [
|
||||
{
|
||||
path: 'foo.txt',
|
||||
base64Content: btoa('Foo Content'),
|
||||
executable: false,
|
||||
},
|
||||
],
|
||||
log: [{ body: { message: 'Foo Message', stepId: 'foo' } }],
|
||||
output: {
|
||||
links: [{ title: 'Foo Link', url: 'http://example.com' }],
|
||||
},
|
||||
steps: [{ id: 'foo', action: 'foo', name: 'Foo' }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
]}
|
||||
>
|
||||
<DryRunProvider>
|
||||
<DryRunResultsView />
|
||||
<DryRunRemote />
|
||||
</DryRunProvider>
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:kind/:namespace/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('foo.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('Foo Content')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Foo Message')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Foo Link')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Log'));
|
||||
expect(screen.getByText('Foo Message')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Output'));
|
||||
expect(screen.getByText('Foo Link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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 { LogViewer } from '@backstage/core-components';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import Tab from '@material-ui/core/Tab';
|
||||
import Tabs from '@material-ui/core/Tabs';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { TaskStatusStepper } from '../../TaskPage/TaskPage';
|
||||
import { TaskPageLinks } from '../../TaskPage/TaskPageLinks';
|
||||
import { useDryRun } from '../DryRunContext';
|
||||
import { FileBrowser } from '../../FileBrowser';
|
||||
import { DryRunResultsSplitView } from './DryRunResultsSplitView';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexFlow: 'column nowrap',
|
||||
},
|
||||
contentWrapper: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
content: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
|
||||
display: 'flex',
|
||||
'& > *': {
|
||||
flex: 1,
|
||||
},
|
||||
},
|
||||
codeMirror: {
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
function FilesContent() {
|
||||
const classes = useStyles();
|
||||
const { selectedResult } = useDryRun();
|
||||
const [selectedPath, setSelectedPath] = useState<string>('');
|
||||
const selectedFile = selectedResult?.directoryContents.find(
|
||||
f => f.path === selectedPath,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedResult) {
|
||||
const [firstFile] = selectedResult.directoryContents;
|
||||
if (firstFile) {
|
||||
setSelectedPath(firstFile.path);
|
||||
} else {
|
||||
setSelectedPath('');
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [selectedResult]);
|
||||
|
||||
if (!selectedResult) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DryRunResultsSplitView>
|
||||
<FileBrowser
|
||||
selected={selectedPath}
|
||||
onSelect={setSelectedPath}
|
||||
filePaths={selectedResult.directoryContents.map(file => file.path)}
|
||||
/>
|
||||
<CodeMirror
|
||||
className={classes.codeMirror}
|
||||
theme="dark"
|
||||
height="100%"
|
||||
extensions={[StreamLanguage.define(yamlSupport)]}
|
||||
readOnly
|
||||
value={
|
||||
selectedFile?.base64Content ? atob(selectedFile.base64Content) : ''
|
||||
}
|
||||
/>
|
||||
</DryRunResultsSplitView>
|
||||
);
|
||||
}
|
||||
function LogContent() {
|
||||
const { selectedResult } = useDryRun();
|
||||
const [currentStepId, setUserSelectedStepId] = useState<string>();
|
||||
|
||||
const steps = useMemo(() => {
|
||||
if (!selectedResult) {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
selectedResult.steps.map(step => {
|
||||
const stepLog = selectedResult.log.filter(
|
||||
l => l.body.stepId === step.id,
|
||||
);
|
||||
return {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
logString: stepLog.map(l => l.body.message).join('\n'),
|
||||
status: stepLog[stepLog.length - 1]?.body.status ?? 'completed',
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
}, [selectedResult]);
|
||||
|
||||
if (!selectedResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedStep = steps.find(s => s.id === currentStepId) ?? steps[0];
|
||||
|
||||
return (
|
||||
<DryRunResultsSplitView>
|
||||
<TaskStatusStepper
|
||||
steps={steps}
|
||||
currentStepId={selectedStep.id}
|
||||
onUserStepChange={setUserSelectedStepId}
|
||||
/>
|
||||
<LogViewer text={selectedStep?.logString ?? ''} />
|
||||
</DryRunResultsSplitView>
|
||||
);
|
||||
}
|
||||
|
||||
function OutputContent() {
|
||||
const classes = useStyles();
|
||||
const { selectedResult } = useDryRun();
|
||||
|
||||
if (!selectedResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DryRunResultsSplitView>
|
||||
<Box pt={2}>
|
||||
{selectedResult.output?.links?.length && (
|
||||
<TaskPageLinks output={selectedResult.output} />
|
||||
)}
|
||||
</Box>
|
||||
<CodeMirror
|
||||
className={classes.codeMirror}
|
||||
theme="dark"
|
||||
height="100%"
|
||||
extensions={[StreamLanguage.define(yamlSupport)]}
|
||||
readOnly
|
||||
value={JSON.stringify(selectedResult.output, null, 2)}
|
||||
/>
|
||||
</DryRunResultsSplitView>
|
||||
);
|
||||
}
|
||||
|
||||
export function DryRunResultsView() {
|
||||
const classes = useStyles();
|
||||
const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>(
|
||||
'files',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Tabs value={selectedTab} onChange={(_, v) => setSelectedTab(v)}>
|
||||
<Tab value="files" label="Files" />
|
||||
<Tab value="log" label="Log" />
|
||||
<Tab value="output" label="Output" />
|
||||
</Tabs>
|
||||
<Divider />
|
||||
|
||||
<div className={classes.contentWrapper}>
|
||||
<div className={classes.content}>
|
||||
{selectedTab === 'files' && <FilesContent />}
|
||||
{selectedTab === 'log' && <LogContent />}
|
||||
{selectedTab === 'output' && <OutputContent />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 { DryRunResults } from './DryRunResults';
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 { makeStyles } from '@material-ui/core';
|
||||
import React, { useState } from 'react';
|
||||
import { FieldExtensionOptions } from '../../extensions';
|
||||
import { TemplateDirectoryAccess } from '../../lib/filesystem';
|
||||
import { DirectoryEditorProvider } from './DirectoryEditorContext';
|
||||
import { DryRunProvider } from './DryRunContext';
|
||||
import { DryRunResults } from './DryRunResults';
|
||||
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
|
||||
import { TemplateEditorForm } from './TemplateEditorForm';
|
||||
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
// Reset and fix sizing to make sure scrolling behaves correctly
|
||||
root: {
|
||||
gridArea: 'pageContent',
|
||||
|
||||
display: 'grid',
|
||||
gridTemplateAreas: `
|
||||
"browser editor preview"
|
||||
"results results results"
|
||||
`,
|
||||
gridTemplateColumns: '1fr 3fr 2fr',
|
||||
gridTemplateRows: '1fr auto',
|
||||
},
|
||||
browser: {
|
||||
gridArea: 'browser',
|
||||
overflow: 'auto',
|
||||
},
|
||||
editor: {
|
||||
gridArea: 'editor',
|
||||
overflow: 'auto',
|
||||
},
|
||||
preview: {
|
||||
gridArea: 'preview',
|
||||
overflow: 'auto',
|
||||
},
|
||||
results: {
|
||||
gridArea: 'results',
|
||||
},
|
||||
});
|
||||
|
||||
export const TemplateEditor = (props: {
|
||||
directory: TemplateDirectoryAccess;
|
||||
fieldExtensions?: FieldExtensionOptions<any, any>[];
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
|
||||
return (
|
||||
<DirectoryEditorProvider directory={props.directory}>
|
||||
<DryRunProvider>
|
||||
<main className={classes.root}>
|
||||
<section className={classes.browser}>
|
||||
<TemplateEditorBrowser onClose={props.onClose} />
|
||||
</section>
|
||||
<section className={classes.editor}>
|
||||
<TemplateEditorTextArea.DirectoryEditor errorText={errorText} />
|
||||
</section>
|
||||
<section className={classes.preview}>
|
||||
<TemplateEditorForm.DirectoryEditorDryRun
|
||||
setErrorText={setErrorText}
|
||||
fieldExtensions={props.fieldExtensions}
|
||||
/>
|
||||
</section>
|
||||
<section className={classes.results}>
|
||||
<DryRunResults />
|
||||
</section>
|
||||
</main>
|
||||
</DryRunProvider>
|
||||
</DirectoryEditorProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { MockFileSystemAccess } from '../../lib/filesystem/MockFileSystemAccess';
|
||||
import { DirectoryEditorProvider } from './DirectoryEditorContext';
|
||||
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
|
||||
|
||||
Blob.prototype.text = async function text() {
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsText(this);
|
||||
});
|
||||
};
|
||||
|
||||
describe('TemplateEditorBrowser', () => {
|
||||
it('should render files and expand dirs without exploding', async () => {
|
||||
await renderInTestApp(
|
||||
<DirectoryEditorProvider
|
||||
directory={MockFileSystemAccess.createMockDirectory({
|
||||
'foo.txt': 'le foo',
|
||||
'dir/bar.txt': 'le bar',
|
||||
'dir/baz.txt': 'le baz',
|
||||
})}
|
||||
>
|
||||
<TemplateEditorBrowser />
|
||||
</DirectoryEditorProvider>,
|
||||
);
|
||||
|
||||
await expect(screen.findByText('foo.txt')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('dir')).toBeInTheDocument();
|
||||
expect(screen.queryByText('bar.txt')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('baz.txt')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('dir'));
|
||||
expect(screen.getByText('bar.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('baz.txt')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 { Divider, IconButton, makeStyles, Tooltip } from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
import React from 'react';
|
||||
import { FileBrowser } from '../FileBrowser';
|
||||
import { useDirectoryEditor } from './DirectoryEditorContext';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
button: {
|
||||
padding: theme.spacing(1),
|
||||
},
|
||||
buttons: {
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
buttonsGap: {
|
||||
flex: '1 1 auto',
|
||||
},
|
||||
buttonsDivider: {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
/** The local file browser for the template editor */
|
||||
export function TemplateEditorBrowser(props: { onClose?: () => void }) {
|
||||
const classes = useStyles();
|
||||
const directoryEditor = useDirectoryEditor();
|
||||
const changedFiles = directoryEditor.files.filter(file => file.dirty);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!props.onClose) {
|
||||
return;
|
||||
}
|
||||
if (changedFiles.length > 0) {
|
||||
// eslint-disable-next-line no-alert
|
||||
const accepted = window.confirm(
|
||||
'Are you sure? Unsaved changes will be lost',
|
||||
);
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.buttons}>
|
||||
<Tooltip title="Save all files">
|
||||
<IconButton
|
||||
className={classes.button}
|
||||
disabled={directoryEditor.files.every(file => !file.dirty)}
|
||||
onClick={() => directoryEditor.save()}
|
||||
>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Reload directory">
|
||||
<IconButton
|
||||
className={classes.button}
|
||||
onClick={() => directoryEditor.reload()}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className={classes.buttonsGap} />
|
||||
<Tooltip title="Close directory">
|
||||
<IconButton className={classes.button} onClick={handleClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Divider className={classes.buttonsDivider} />
|
||||
<FileBrowser
|
||||
selected={directoryEditor.selectedFile?.path ?? ''}
|
||||
onSelect={directoryEditor.setSelectedFile}
|
||||
filePaths={directoryEditor.files.map(file => file.path)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* 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 { useApiHolder } from '@backstage/core-plugin-api';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import React, { Component, ReactNode, useMemo, useState } from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import yaml from 'yaml';
|
||||
import { FieldExtensionOptions } from '../../extensions';
|
||||
import { TemplateParameterSchema } from '../../types';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
import { createValidator } from '../TemplatePage';
|
||||
import { useDirectoryEditor } from './DirectoryEditorContext';
|
||||
import { useDryRun } from './DryRunContext';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
containerWrapper: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
overflow: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
invalidator: unknown;
|
||||
setErrorText(errorText: string | undefined): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
shouldRender: boolean;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state = {
|
||||
shouldRender: true,
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: { invalidator: unknown }) {
|
||||
if (prevProps.invalidator !== this.props.invalidator) {
|
||||
this.setState({ shouldRender: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.props.setErrorText(error.message);
|
||||
this.setState({ shouldRender: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.shouldRender ? this.props.children : null;
|
||||
}
|
||||
}
|
||||
|
||||
interface TemplateEditorFormProps {
|
||||
content?: string;
|
||||
/** Setting this to true will cause the content to be parsed as if it is the template entity spec */
|
||||
contentIsSpec?: boolean;
|
||||
data: JsonObject;
|
||||
onUpdate: (data: JsonObject) => void;
|
||||
setErrorText: (errorText?: string) => void;
|
||||
|
||||
onDryRun?: (data: JsonObject) => Promise<void>;
|
||||
fieldExtensions?: FieldExtensionOptions<any, any>[];
|
||||
}
|
||||
|
||||
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Shows the a template form that is parsed from the provided content */
|
||||
export function TemplateEditorForm(props: TemplateEditorFormProps) {
|
||||
const {
|
||||
content,
|
||||
contentIsSpec,
|
||||
data,
|
||||
onUpdate,
|
||||
onDryRun,
|
||||
setErrorText,
|
||||
fieldExtensions = [],
|
||||
} = props;
|
||||
const classes = useStyles();
|
||||
const apiHolder = useApiHolder();
|
||||
|
||||
const [steps, setSteps] = useState<TemplateParameterSchema['steps']>();
|
||||
|
||||
const fields = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
fieldExtensions.map(({ name, component }) => [name, component]),
|
||||
);
|
||||
}, [fieldExtensions]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
try {
|
||||
if (!content) {
|
||||
setSteps(undefined);
|
||||
return;
|
||||
}
|
||||
const parsed: JsonValue = yaml.parse(content);
|
||||
|
||||
if (!isJsonObject(parsed)) {
|
||||
setSteps(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let rootObj = parsed;
|
||||
if (!contentIsSpec) {
|
||||
const isTemplate =
|
||||
String(parsed.kind).toLocaleLowerCase('en-US') === 'template';
|
||||
if (!isTemplate) {
|
||||
setSteps(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
rootObj = isJsonObject(parsed.spec) ? parsed.spec : {};
|
||||
}
|
||||
|
||||
const { parameters } = rootObj;
|
||||
if (!Array.isArray(parameters)) {
|
||||
setErrorText('Template parameters must be an array');
|
||||
setSteps(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldValidators = Object.fromEntries(
|
||||
fieldExtensions.map(({ name, validation }) => [name, validation]),
|
||||
);
|
||||
|
||||
setErrorText();
|
||||
setSteps(
|
||||
parameters.flatMap(param =>
|
||||
isJsonObject(param)
|
||||
? [
|
||||
{
|
||||
title: String(param.title),
|
||||
schema: param,
|
||||
validate: createValidator(param, fieldValidators, {
|
||||
apiHolder,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setErrorText(e.message);
|
||||
}
|
||||
},
|
||||
250,
|
||||
[contentIsSpec, content, apiHolder],
|
||||
);
|
||||
|
||||
if (!steps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.containerWrapper}>
|
||||
<div className={classes.container}>
|
||||
<ErrorBoundary invalidator={steps} setErrorText={setErrorText}>
|
||||
<MultistepJsonForm
|
||||
steps={steps}
|
||||
fields={fields}
|
||||
formData={data}
|
||||
onChange={e => onUpdate(e.formData)}
|
||||
onReset={() => onUpdate({})}
|
||||
finishButtonLabel={onDryRun && 'Try It'}
|
||||
onFinish={onDryRun && (() => onDryRun(data))}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */
|
||||
export function TemplateEditorFormDirectoryEditorDryRun(
|
||||
props: Pick<TemplateEditorFormProps, 'setErrorText' | 'fieldExtensions'>,
|
||||
) {
|
||||
const { setErrorText, fieldExtensions = [] } = props;
|
||||
const dryRun = useDryRun();
|
||||
|
||||
const directoryEditor = useDirectoryEditor();
|
||||
const { selectedFile } = directoryEditor;
|
||||
|
||||
const [data, setData] = useState<JsonObject>({});
|
||||
|
||||
const handleDryRun = async () => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await dryRun.execute({
|
||||
templateContent: selectedFile.content,
|
||||
values: data,
|
||||
files: directoryEditor.files,
|
||||
});
|
||||
};
|
||||
|
||||
const content =
|
||||
selectedFile && selectedFile.path.match(/\.ya?ml$/)
|
||||
? selectedFile.content
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TemplateEditorForm
|
||||
onDryRun={handleDryRun}
|
||||
fieldExtensions={fieldExtensions}
|
||||
setErrorText={setErrorText}
|
||||
content={content}
|
||||
data={data}
|
||||
onUpdate={setData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TemplateEditorForm.DirectoryEditorDryRun =
|
||||
TemplateEditorFormDirectoryEditorDryRun;
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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 Card from '@material-ui/core/Card';
|
||||
import CardActionArea from '@material-ui/core/CardActionArea';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { WebFileSystemAccess } from '../../lib/filesystem';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
introText: {
|
||||
textAlign: 'center',
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
card: {
|
||||
position: 'relative',
|
||||
maxWidth: 340,
|
||||
marginTop: theme.spacing(4),
|
||||
margin: theme.spacing(0, 2),
|
||||
},
|
||||
infoIcon: {
|
||||
position: 'absolute',
|
||||
top: theme.spacing(1),
|
||||
right: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
interface EditorIntroProps {
|
||||
style?: JSX.IntrinsicElements['div']['style'];
|
||||
onSelect?: (option: 'local' | 'form') => void;
|
||||
}
|
||||
|
||||
export function TemplateEditorIntro(props: EditorIntroProps) {
|
||||
const classes = useStyles();
|
||||
const supportsLoad = WebFileSystemAccess.isSupported();
|
||||
|
||||
const cardLoadLocal = (
|
||||
<Card className={classes.card} elevation={4}>
|
||||
<CardActionArea
|
||||
disabled={!supportsLoad}
|
||||
onClick={() => props.onSelect?.('local')}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="h5"
|
||||
gutterBottom
|
||||
color={supportsLoad ? undefined : 'textSecondary'}
|
||||
style={{ display: 'flex', flexFlow: 'row nowrap' }}
|
||||
>
|
||||
Load Template Directory
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color={supportsLoad ? undefined : 'textSecondary'}
|
||||
>
|
||||
Load a local template directory, allowing you to both edit and try
|
||||
executing your own template.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
{!supportsLoad && (
|
||||
<div className={classes.infoIcon}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title="Only supported in some Chromium-based browsers"
|
||||
>
|
||||
<InfoOutlinedIcon />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const cardFormEditor = (
|
||||
<Card className={classes.card} elevation={4}>
|
||||
<CardActionArea onClick={() => props.onSelect?.('form')}>
|
||||
<CardContent>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Edit Template Form
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Preview and edit a template form, either using a sample template or
|
||||
by loading a template from the catalog.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={props.style}>
|
||||
<Typography variant="h6" className={classes.introText}>
|
||||
Get started by choosing one of the options below
|
||||
</Typography>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexFlow: 'row wrap',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
alignContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{supportsLoad && cardLoadLocal}
|
||||
{cardFormEditor}
|
||||
{!supportsLoad && cardLoadLocal}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { TemplateEditorPage } from './TemplateEditorPage';
|
||||
|
||||
describe('TemplateEditorPage', () => {
|
||||
it('renders without exploding', async () => {
|
||||
await renderInTestApp(<TemplateEditorPage />);
|
||||
|
||||
expect(screen.getByText('Load Template Directory')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edit Template Form')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('template directory loading should not be supported in Jest', async () => {
|
||||
await renderInTestApp(<TemplateEditorPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Load Template Directory/ }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be able to continue to form preview', async () => {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
catalogApiRef,
|
||||
{ getEntities: jest.fn().mockResolvedValue({ items: [] }) },
|
||||
],
|
||||
]}
|
||||
>
|
||||
<TemplateEditorPage />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Edit Template Form'));
|
||||
|
||||
expect(screen.getByLabelText('Load Existing Template')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,258 +13,76 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Content, Header, InfoCard, Page } from '@backstage/core-components';
|
||||
import { alertApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api';
|
||||
import React, { useState } from 'react';
|
||||
import { Content, Header, Page } from '@backstage/core-components';
|
||||
import {
|
||||
catalogApiRef,
|
||||
humanizeEntityRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { showPanel } from '@codemirror/view';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
Grid,
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
Select,
|
||||
} from '@material-ui/core';
|
||||
import { IChangeEvent } from '@rjsf/core';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import yaml from 'yaml';
|
||||
TemplateDirectoryAccess,
|
||||
WebFileSystemAccess,
|
||||
} from '../../lib/filesystem';
|
||||
import { TemplateEditorIntro } from './TemplateEditorIntro';
|
||||
import { TemplateEditor } from './TemplateEditor';
|
||||
import { TemplateFormPreviewer } from './TemplateFormPreviewer';
|
||||
import { FieldExtensionOptions } from '../../extensions';
|
||||
import { TemplateParameterSchema } from '../../types';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
import { createValidator } from '../TemplatePage';
|
||||
|
||||
const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
|
||||
parameters:
|
||||
- title: Fill in some steps
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
description: Unique name of the component
|
||||
owner:
|
||||
title: Owner
|
||||
type: string
|
||||
description: Owner of the component
|
||||
ui:field: OwnerPicker
|
||||
ui:options:
|
||||
allowedKinds:
|
||||
- Group
|
||||
- title: Choose a location
|
||||
required:
|
||||
- repoUrl
|
||||
properties:
|
||||
repoUrl:
|
||||
title: Repository Location
|
||||
type: string
|
||||
ui:field: RepoUrlPicker
|
||||
ui:options:
|
||||
allowedHosts:
|
||||
- github.com
|
||||
`;
|
||||
type Selection =
|
||||
| {
|
||||
type: 'local';
|
||||
directory: TemplateDirectoryAccess;
|
||||
}
|
||||
| {
|
||||
type: 'form';
|
||||
};
|
||||
|
||||
type TemplateOption = {
|
||||
label: string;
|
||||
value: Entity;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
templateSelect: {
|
||||
marginBottom: '10px',
|
||||
},
|
||||
grid: {
|
||||
height: '100%',
|
||||
},
|
||||
codeMirror: {
|
||||
height: '95%',
|
||||
},
|
||||
});
|
||||
|
||||
export const TemplateEditorPage = ({
|
||||
defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
|
||||
customFieldExtensions = [],
|
||||
}: {
|
||||
interface TemplateEditorPageProps {
|
||||
defaultPreviewTemplate?: string;
|
||||
customFieldExtensions?: FieldExtensionOptions<any, any>[];
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const apiHolder = useApiHolder();
|
||||
const [selectedTemplate, setSelectedTemplate] = useState('');
|
||||
const [schema, setSchema] = useState<TemplateParameterSchema>({
|
||||
title: '',
|
||||
steps: [],
|
||||
});
|
||||
const [templateOptions, setTemplateOptions] = useState<TemplateOption[]>([]);
|
||||
const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
|
||||
const [formState, setFormState] = useState({});
|
||||
}
|
||||
|
||||
const { loading } = useAsync(
|
||||
() =>
|
||||
catalogApi
|
||||
.getEntities({
|
||||
filter: { kind: 'template' },
|
||||
fields: [
|
||||
'kind',
|
||||
'metadata.namespace',
|
||||
'metadata.name',
|
||||
'metadata.title',
|
||||
'spec.parameters',
|
||||
],
|
||||
})
|
||||
.then(({ items }) =>
|
||||
setTemplateOptions(
|
||||
items.map(template => ({
|
||||
label:
|
||||
template.metadata.title ??
|
||||
humanizeEntityRef(template, { defaultKind: 'template' }),
|
||||
value: template,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(e =>
|
||||
alertApi.post({
|
||||
message: `Error loading exisiting templates: ${e.message}`,
|
||||
severity: 'error',
|
||||
}),
|
||||
),
|
||||
[catalogApi],
|
||||
);
|
||||
export function TemplateEditorPage(props: TemplateEditorPageProps) {
|
||||
const [selection, setSelection] = useState<Selection>();
|
||||
|
||||
const errorPanel = document.createElement('div');
|
||||
errorPanel.style.color = 'red';
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
try {
|
||||
const parsedTemplate = yaml.parse(templateYaml);
|
||||
|
||||
setSchema({
|
||||
title: 'Preview',
|
||||
steps: parsedTemplate.parameters.map((param: JsonObject) => ({
|
||||
title: param.title,
|
||||
schema: param,
|
||||
})),
|
||||
});
|
||||
setFormState({});
|
||||
} catch (e) {
|
||||
errorPanel.textContent = e.message;
|
||||
}
|
||||
},
|
||||
250,
|
||||
[setFormState, setSchema, templateYaml],
|
||||
);
|
||||
|
||||
const handleSelectChange = useCallback(
|
||||
selected => {
|
||||
setSelectedTemplate(selected);
|
||||
setTemplateYaml(yaml.stringify(selected.spec));
|
||||
},
|
||||
[setTemplateYaml],
|
||||
);
|
||||
|
||||
const handleFormReset = () => setFormState({});
|
||||
const handleFormChange = useCallback(
|
||||
(e: IChangeEvent) => setFormState(e.formData),
|
||||
[setFormState],
|
||||
);
|
||||
|
||||
const handleCodeChange = useCallback(
|
||||
(code: string) => {
|
||||
setTemplateYaml(code);
|
||||
},
|
||||
[setTemplateYaml],
|
||||
);
|
||||
|
||||
const customFieldComponents = Object.fromEntries(
|
||||
customFieldExtensions.map(({ name, component }) => [name, component]),
|
||||
);
|
||||
|
||||
const customFieldValidators = Object.fromEntries(
|
||||
customFieldExtensions.map(({ name, validation }) => [name, validation]),
|
||||
);
|
||||
let content: JSX.Element | null = null;
|
||||
if (selection?.type === 'local') {
|
||||
content = (
|
||||
<TemplateEditor
|
||||
directory={selection.directory}
|
||||
fieldExtensions={props.customFieldExtensions}
|
||||
onClose={() => setSelection(undefined)}
|
||||
/>
|
||||
);
|
||||
} else if (selection?.type === 'form') {
|
||||
content = (
|
||||
<TemplateFormPreviewer
|
||||
defaultPreviewTemplate={props.defaultPreviewTemplate}
|
||||
customFieldExtensions={props.customFieldExtensions}
|
||||
onClose={() => setSelection(undefined)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Content>
|
||||
<TemplateEditorIntro
|
||||
onSelect={option => {
|
||||
if (option === 'local') {
|
||||
WebFileSystemAccess.requestDirectoryAccess()
|
||||
.then(directory => setSelection({ type: 'local', directory }))
|
||||
.catch(() => {});
|
||||
} else if (option === 'form') {
|
||||
setSelection({ type: 'form' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
title="Template Editor"
|
||||
subtitle="Preview your template parameter UI"
|
||||
subtitle="Edit, preview, and try out templates and template forms"
|
||||
/>
|
||||
<Content>
|
||||
{loading && <LinearProgress />}
|
||||
<Grid container className={classes.grid}>
|
||||
<Grid item xs={6}>
|
||||
<FormControl
|
||||
className={classes.templateSelect}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
>
|
||||
<InputLabel id="select-template-label">
|
||||
Load Existing Template
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedTemplate}
|
||||
label="Load Existing Template"
|
||||
labelId="select-template-label"
|
||||
onChange={e => handleSelectChange(e.target.value)}
|
||||
>
|
||||
{templateOptions.map((option, idx) => (
|
||||
<MenuItem key={idx} value={option.value as any}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<CodeMirror
|
||||
className={classes.codeMirror}
|
||||
value={templateYaml}
|
||||
theme="dark"
|
||||
height="100%"
|
||||
extensions={[
|
||||
StreamLanguage.define(yamlSupport),
|
||||
showPanel.of(() => ({ dom: errorPanel, top: true })),
|
||||
]}
|
||||
onChange={handleCodeChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
{schema && (
|
||||
<InfoCard key={JSON.stringify(schema)}>
|
||||
<MultistepJsonForm
|
||||
formData={formState}
|
||||
fields={customFieldComponents}
|
||||
onChange={handleFormChange}
|
||||
onReset={handleFormReset}
|
||||
steps={schema.steps.map(step => {
|
||||
return {
|
||||
...step,
|
||||
validate: createValidator(
|
||||
step.schema,
|
||||
customFieldValidators,
|
||||
{ apiHolder },
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</InfoCard>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
{content}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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 { StreamLanguage } from '@codemirror/language';
|
||||
import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { showPanel } from '@codemirror/view';
|
||||
import { IconButton, makeStyles, Paper, Tooltip } from '@material-ui/core';
|
||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
import { useKeyboardEvent } from '@react-hookz/web';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDirectoryEditor } from './DirectoryEditorContext';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
codeMirror: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
errorPanel: {
|
||||
color: theme.palette.error.main,
|
||||
lineHeight: 2,
|
||||
margin: theme.spacing(0, 1),
|
||||
},
|
||||
floatingButtons: {
|
||||
position: 'absolute',
|
||||
top: theme.spacing(1),
|
||||
right: theme.spacing(3),
|
||||
},
|
||||
floatingButton: {
|
||||
padding: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
/** A wrapper around CodeMirror with an error panel and extra actions available */
|
||||
export function TemplateEditorTextArea(props: {
|
||||
content?: string;
|
||||
onUpdate?: (content: string) => void;
|
||||
errorText?: string;
|
||||
onSave?: () => void;
|
||||
onReload?: () => void;
|
||||
}) {
|
||||
const { errorText } = props;
|
||||
const classes = useStyles();
|
||||
|
||||
const panelExtension = useMemo(() => {
|
||||
if (!errorText) {
|
||||
return showPanel.of(null);
|
||||
}
|
||||
|
||||
const dom = document.createElement('div');
|
||||
dom.classList.add(classes.errorPanel);
|
||||
dom.textContent = errorText;
|
||||
return showPanel.of(() => ({ dom, bottom: true }));
|
||||
}, [classes, errorText]);
|
||||
|
||||
useKeyboardEvent(
|
||||
e => e.key === 's' && (e.ctrlKey || e.metaKey),
|
||||
e => {
|
||||
e.preventDefault();
|
||||
if (props.onSave) {
|
||||
props.onSave();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<CodeMirror
|
||||
className={classes.codeMirror}
|
||||
theme="dark"
|
||||
height="100%"
|
||||
extensions={[StreamLanguage.define(yamlSupport), panelExtension]}
|
||||
value={props.content}
|
||||
onChange={props.onUpdate}
|
||||
/>
|
||||
{(props.onSave || props.onReload) && (
|
||||
<div className={classes.floatingButtons}>
|
||||
<Paper>
|
||||
{props.onSave && (
|
||||
<Tooltip title="Save file">
|
||||
<IconButton
|
||||
className={classes.floatingButton}
|
||||
onClick={() => props.onSave?.()}
|
||||
>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{props.onReload && (
|
||||
<Tooltip title="Reload file">
|
||||
<IconButton
|
||||
className={classes.floatingButton}
|
||||
onClick={() => props.onReload?.()}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A version of the TemplateEditorTextArea that is connected to the DirectoryEditor context */
|
||||
export function TemplateEditorDirectoryEditorTextArea(props: {
|
||||
errorText?: string;
|
||||
}) {
|
||||
const directoryEditor = useDirectoryEditor();
|
||||
|
||||
const actions = directoryEditor.selectedFile?.dirty
|
||||
? {
|
||||
onSave: () => directoryEditor.save(),
|
||||
onReload: () => directoryEditor.reload(),
|
||||
}
|
||||
: {
|
||||
onReload: () => directoryEditor.reload(),
|
||||
};
|
||||
|
||||
return (
|
||||
<TemplateEditorTextArea
|
||||
errorText={props.errorText}
|
||||
content={directoryEditor.selectedFile?.content}
|
||||
onUpdate={content => directoryEditor.selectedFile?.updateContent(content)}
|
||||
{...actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TemplateEditorTextArea.DirectoryEditor = TemplateEditorDirectoryEditorTextArea;
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* 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 { Entity } from '@backstage/catalog-model';
|
||||
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
catalogApiRef,
|
||||
humanizeEntityRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
Select,
|
||||
} from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import yaml from 'yaml';
|
||||
import { FieldExtensionOptions } from '../../extensions';
|
||||
import { TemplateEditorForm } from './TemplateEditorForm';
|
||||
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
|
||||
|
||||
const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
|
||||
parameters:
|
||||
- title: Fill in some steps
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
description: Unique name of the component
|
||||
owner:
|
||||
title: Owner
|
||||
type: string
|
||||
description: Owner of the component
|
||||
ui:field: OwnerPicker
|
||||
ui:options:
|
||||
allowedKinds:
|
||||
- Group
|
||||
- title: Choose a location
|
||||
required:
|
||||
- repoUrl
|
||||
properties:
|
||||
repoUrl:
|
||||
title: Repository Location
|
||||
type: string
|
||||
ui:field: RepoUrlPicker
|
||||
ui:options:
|
||||
allowedHosts:
|
||||
- github.com
|
||||
steps:
|
||||
- id: fetch-base
|
||||
name: Fetch Base
|
||||
action: fetch:template
|
||||
input:
|
||||
url: ./template
|
||||
values:
|
||||
name: \${{parameters.name}}
|
||||
`;
|
||||
|
||||
type TemplateOption = {
|
||||
label: string;
|
||||
value: Entity;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
gridArea: 'pageContent',
|
||||
display: 'grid',
|
||||
gridTemplateAreas: `
|
||||
"controls controls"
|
||||
"textArea preview"
|
||||
`,
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
},
|
||||
controls: {
|
||||
gridArea: 'controls',
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
textArea: {
|
||||
gridArea: 'textArea',
|
||||
},
|
||||
preview: {
|
||||
gridArea: 'preview',
|
||||
},
|
||||
}));
|
||||
|
||||
export const TemplateFormPreviewer = ({
|
||||
defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
|
||||
customFieldExtensions = [],
|
||||
onClose,
|
||||
}: {
|
||||
defaultPreviewTemplate?: string;
|
||||
customFieldExtensions?: FieldExtensionOptions<any, any>[];
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState('');
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
const [templateOptions, setTemplateOptions] = useState<TemplateOption[]>([]);
|
||||
const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
|
||||
const [formState, setFormState] = useState({});
|
||||
|
||||
const { loading } = useAsync(
|
||||
() =>
|
||||
catalogApi
|
||||
.getEntities({
|
||||
filter: { kind: 'template' },
|
||||
fields: [
|
||||
'kind',
|
||||
'metadata.namespace',
|
||||
'metadata.name',
|
||||
'metadata.title',
|
||||
'spec.parameters',
|
||||
'spec.steps',
|
||||
'spec.output',
|
||||
],
|
||||
})
|
||||
.then(({ items }) =>
|
||||
setTemplateOptions(
|
||||
items.map(template => ({
|
||||
label:
|
||||
template.metadata.title ??
|
||||
humanizeEntityRef(template, { defaultKind: 'template' }),
|
||||
value: template,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(e =>
|
||||
alertApi.post({
|
||||
message: `Error loading exisiting templates: ${e.message}`,
|
||||
severity: 'error',
|
||||
}),
|
||||
),
|
||||
[catalogApi],
|
||||
);
|
||||
|
||||
const handleSelectChange = useCallback(
|
||||
selected => {
|
||||
setSelectedTemplate(selected);
|
||||
setTemplateYaml(yaml.stringify(selected.spec));
|
||||
},
|
||||
[setTemplateYaml],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <LinearProgress />}
|
||||
<main className={classes.root}>
|
||||
<div className={classes.controls}>
|
||||
<FormControl variant="outlined" size="small" fullWidth>
|
||||
<InputLabel id="select-template-label">
|
||||
Load Existing Template
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedTemplate}
|
||||
label="Load Existing Template"
|
||||
labelId="select-template-label"
|
||||
onChange={e => handleSelectChange(e.target.value)}
|
||||
>
|
||||
{templateOptions.map((option, idx) => (
|
||||
<MenuItem key={idx} value={option.value as any}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<IconButton size="medium" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={classes.textArea}>
|
||||
<TemplateEditorTextArea
|
||||
content={templateYaml}
|
||||
onUpdate={setTemplateYaml}
|
||||
errorText={errorText}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.preview}>
|
||||
<TemplateEditorForm
|
||||
content={templateYaml}
|
||||
contentIsSpec
|
||||
fieldExtensions={customFieldExtensions}
|
||||
data={formState}
|
||||
onUpdate={setFormState}
|
||||
setErrorText={setErrorText}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -13,4 +13,5 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { TemplateEditorPage } from './TemplateEditorPage';
|
||||
|
||||
@@ -25,6 +25,8 @@ export type {
|
||||
ListActionsResponse,
|
||||
LogEvent,
|
||||
ScaffolderApi,
|
||||
ScaffolderDryRunOptions,
|
||||
ScaffolderDryRunResponse,
|
||||
ScaffolderGetIntegrationsListOptions,
|
||||
ScaffolderGetIntegrationsListResponse,
|
||||
ScaffolderOutputLink,
|
||||
|
||||
@@ -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 { TemplateDirectoryAccess, TemplateFileAccess } from './types';
|
||||
|
||||
class MockFileAccess implements TemplateFileAccess {
|
||||
constructor(readonly path: string, private content: string) {}
|
||||
|
||||
async file(): Promise<File> {
|
||||
const blob = new Blob([this.content]);
|
||||
return Object.assign(blob, {
|
||||
name: this.path.split('/').pop()!,
|
||||
lastModified: Date.now(),
|
||||
webkitRelativePath: this.path,
|
||||
});
|
||||
}
|
||||
|
||||
async save(data: string | Blob | BufferSource): Promise<void> {
|
||||
this.content = await new Response(data).text();
|
||||
}
|
||||
}
|
||||
|
||||
class MockDirectoryAccess implements TemplateDirectoryAccess {
|
||||
private readonly files = new Array<TemplateFileAccess>();
|
||||
|
||||
constructor(inputFiles: Record<string, string>) {
|
||||
this.files = Object.entries(inputFiles).map(
|
||||
([path, content]) => new MockFileAccess(path, content),
|
||||
);
|
||||
}
|
||||
|
||||
async listFiles(): Promise<TemplateFileAccess[]> {
|
||||
return this.files;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class MockFileSystemAccess {
|
||||
private constructor() {}
|
||||
|
||||
static createMockDirectory(files: Record<string, string>) {
|
||||
return new MockDirectoryAccess(files);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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 { TemplateDirectoryAccess, TemplateFileAccess } from './types';
|
||||
|
||||
type WritableFileHandle = FileSystemFileHandle & {
|
||||
createWritable(): Promise<{
|
||||
write(data: string | Blob | BufferSource): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}>;
|
||||
};
|
||||
|
||||
// A nicer type than the one from the TS lib
|
||||
interface IterableDirectoryHandle extends FileSystemDirectoryHandle {
|
||||
values(): AsyncIterable<
|
||||
| ({ kind: 'file' } & WritableFileHandle)
|
||||
| ({ kind: 'directory' } & IterableDirectoryHandle)
|
||||
>;
|
||||
}
|
||||
|
||||
const showDirectoryPicker = (window as any).showDirectoryPicker as
|
||||
| (() => Promise<IterableDirectoryHandle>)
|
||||
| undefined;
|
||||
|
||||
class WebFileAccess implements TemplateFileAccess {
|
||||
constructor(
|
||||
readonly path: string,
|
||||
private readonly handle: WritableFileHandle,
|
||||
) {}
|
||||
|
||||
file(): Promise<File> {
|
||||
return this.handle.getFile();
|
||||
}
|
||||
|
||||
async save(data: string | Blob | BufferSource): Promise<void> {
|
||||
const writable = await this.handle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
}
|
||||
}
|
||||
|
||||
class WebDirectoryAccess implements TemplateDirectoryAccess {
|
||||
constructor(private readonly handle: IterableDirectoryHandle) {}
|
||||
|
||||
async listFiles(): Promise<TemplateFileAccess[]> {
|
||||
const content = [];
|
||||
for await (const entry of this.listDirectoryContents(this.handle)) {
|
||||
content.push(entry);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
private async *listDirectoryContents(
|
||||
dirHandle: IterableDirectoryHandle,
|
||||
basePath: string[] = [],
|
||||
): AsyncIterable<TemplateFileAccess> {
|
||||
for await (const handle of dirHandle.values()) {
|
||||
if (handle.kind === 'file') {
|
||||
yield new WebFileAccess([...basePath, handle.name].join('/'), handle);
|
||||
} else if (handle.kind === 'directory') {
|
||||
yield* this.listDirectoryContents(handle, [...basePath, handle.name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class WebFileSystemAccess {
|
||||
static isSupported(): boolean {
|
||||
return Boolean(showDirectoryPicker);
|
||||
}
|
||||
|
||||
static async requestDirectoryAccess(): Promise<TemplateDirectoryAccess> {
|
||||
if (!showDirectoryPicker) {
|
||||
throw new Error('File system access is not supported');
|
||||
}
|
||||
const handle = await showDirectoryPicker();
|
||||
return new WebDirectoryAccess(handle);
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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 async function blobToBase64(file: Blob) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const uri = reader.result as string;
|
||||
resolve(uri.slice(uri.indexOf(',') + 1));
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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 type { TemplateFileAccess, TemplateDirectoryAccess } from './types';
|
||||
export { blobToBase64 } from './helpers';
|
||||
export { WebFileSystemAccess } from './WebFileSystemAccess';
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
export interface TemplateFileAccess {
|
||||
path: string;
|
||||
file(): Promise<File>;
|
||||
save(data: string | BufferSource | Blob): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TemplateDirectoryAccess {
|
||||
listFiles(): Promise<Array<TemplateFileAccess>>;
|
||||
}
|
||||
@@ -19,3 +19,16 @@ import 'cross-fetch/polyfill';
|
||||
|
||||
const { EventSourcePolyfill } = jest.requireMock('event-source-polyfill');
|
||||
global.EventSource = EventSourcePolyfill;
|
||||
|
||||
// Patch jsdom to add feature used by CodeMirror
|
||||
document.createRange = () => {
|
||||
const range = new Range();
|
||||
|
||||
range.getClientRects = () => ({
|
||||
item: () => null,
|
||||
length: 0,
|
||||
[Symbol.iterator]: jest.fn(),
|
||||
});
|
||||
|
||||
return range;
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
|
||||
import { TaskSpec, TaskStep } from '@backstage/plugin-scaffolder-common';
|
||||
import { JsonObject, JsonValue, Observable } from '@backstage/types';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
|
||||
@@ -149,6 +149,27 @@ export interface ScaffolderStreamLogsOptions {
|
||||
taskId: string;
|
||||
after?: number;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ScaffolderDryRunOptions {
|
||||
template: JsonValue;
|
||||
values: JsonObject;
|
||||
secrets?: Record<string, string>;
|
||||
directoryContents: { path: string; base64Content: string }[];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ScaffolderDryRunResponse {
|
||||
directoryContents: Array<{
|
||||
path: string;
|
||||
base64Content: string;
|
||||
executable: boolean;
|
||||
}>;
|
||||
log: Array<Pick<LogEvent, 'body'>>;
|
||||
steps: TaskStep[];
|
||||
output: ScaffolderTaskOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* An API to interact with the scaffolder backend.
|
||||
*
|
||||
@@ -181,4 +202,6 @@ export interface ScaffolderApi {
|
||||
listActions(): Promise<ListActionsResponse>;
|
||||
|
||||
streamLogs(options: ScaffolderStreamLogsOptions): Observable<LogEvent>;
|
||||
|
||||
dryRun?(options: ScaffolderDryRunOptions): Promise<ScaffolderDryRunResponse>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user