From 91ae2f46d29e8d10b953c996d5a7b8293fff27d7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Apr 2022 10:09:05 +0200 Subject: [PATCH 01/52] scaffolder-backend: add directory serialization utilities Signed-off-by: Patrik Oldsberg --- plugins/scaffolder-backend/package.json | 1 + .../deserializeDirectoryContents.test.ts | 81 ++++++++ .../lib/files/deserializeDirectoryContents.ts | 39 ++++ .../scaffolder-backend/src/lib/files/index.ts | 19 ++ .../files/serializeDirectoryContents.test.ts | 179 ++++++++++++++++++ .../lib/files/serializeDirectoryContents.ts | 62 ++++++ .../scaffolder-backend/src/lib/files/types.ts | 21 ++ 7 files changed, 402 insertions(+) create mode 100644 plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.test.ts create mode 100644 plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.ts create mode 100644 plugins/scaffolder-backend/src/lib/files/index.ts create mode 100644 plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts create mode 100644 plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.ts create mode 100644 plugins/scaffolder-backend/src/lib/files/types.ts diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 4fed67cc54..528d84a299 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -67,6 +67,7 @@ "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", diff --git a/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.test.ts b/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.test.ts new file mode 100644 index 0000000000..0e7de7f88d --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.test.ts @@ -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, + }, + ]); + }); +}); diff --git a/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.ts b/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.ts new file mode 100644 index 0000000000..cb567eed9d --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/deserializeDirectoryContents.ts @@ -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 { + 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 + } +} diff --git a/plugins/scaffolder-backend/src/lib/files/index.ts b/plugins/scaffolder-backend/src/lib/files/index.ts new file mode 100644 index 0000000000..ab6975532f --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts new file mode 100644 index 0000000000..a58274d694 --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts @@ -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 life 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'), + }, + ]); + }); +}); diff --git a/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.ts b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.ts new file mode 100644 index 0000000000..608b9e71a5 --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.ts @@ -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 { + 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), + })), + ); +} diff --git a/plugins/scaffolder-backend/src/lib/files/types.ts b/plugins/scaffolder-backend/src/lib/files/types.ts new file mode 100644 index 0000000000..390d6791ee --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/files/types.ts @@ -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; +} From 10b7b2d9c79da96c59cac9a7ed78277ca47e3b6a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Apr 2022 10:11:10 +0200 Subject: [PATCH 02/52] scaffolder-backend: add dry-run support for actions and runner Signed-off-by: Patrik Oldsberg --- .../src/scaffolder/actions/types.ts | 7 +++++ .../tasks/NunjucksWorkflowRunner.ts | 19 +++++++++++- .../src/scaffolder/tasks/helper.ts | 29 +++++++++++++++++++ .../src/scaffolder/tasks/types.ts | 1 + 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts index 9a4ae8b2bf..ff15300b4a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts @@ -39,12 +39,19 @@ export type ActionContext = { createTemporaryDirectory(): Promise; 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 = { id: string; description?: string; + supportsDryRun?: boolean; schema?: { input?: Schema; output?: Schema; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index c9d0714392..3a066494b1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -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,23 @@ 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`, + ); + 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 && diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts index 2058ce917f..76951afde4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts @@ -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 ''; + } else if (schema.type === 'number') { + return 0; + } else if (schema.type === 'boolean') { + return false; + } + return ''; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index a3a9ef84d1..4a9d43aea6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -111,6 +111,7 @@ export interface TaskContext { secrets?: TaskSecrets; createdBy?: string; done: boolean; + isDryRun?: boolean; emitLog(message: string, logMetadata?: JsonObject): Promise; complete(result: TaskCompletionState, metadata?: JsonObject): Promise; getWorkspaceName(): Promise; From fe9a710adbccfbc2f8c201be4d373b26d5ec0471 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Apr 2022 10:13:09 +0200 Subject: [PATCH 03/52] scaffolder-backend: enable dry-run support in some actions Signed-off-by: Patrik Oldsberg --- .../scaffolder/actions/builtin/catalog/register.ts | 12 ++++++++++++ .../src/scaffolder/actions/builtin/catalog/write.ts | 1 + .../src/scaffolder/actions/builtin/debug/log.ts | 1 + .../src/scaffolder/actions/builtin/fetch/plain.ts | 1 + .../src/scaffolder/actions/builtin/fetch/template.ts | 1 + .../scaffolder/actions/builtin/filesystem/delete.ts | 1 + .../scaffolder/actions/builtin/filesystem/rename.ts | 1 + 7 files changed, 18 insertions(+) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts index 1b30268d9d..4b9f883706 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts @@ -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; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts index b55e521784..da758898dc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts @@ -46,6 +46,7 @@ export function createCatalogWriteAction() { }, }, }, + supportsDryRun: true, async handler(ctx) { ctx.logStream.write(`Writing catalog-info.yaml`); const { filePath, entity } = ctx.input; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts index 59d4c333c6..99d030b772 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts @@ -51,6 +51,7 @@ export function createDebugLogAction() { }, }, }, + supportsDryRun: true, async handler(ctx) { ctx.logger.info(JSON.stringify(ctx.input, null, 2)); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts index 6dbe08f61b..9dc82068c0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/plain.ts @@ -54,6 +54,7 @@ export function createFetchPlainAction(options: { }, }, }, + supportsDryRun: true, async handler(ctx) { ctx.logger.info('Fetching plain content from remote URL'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index 3d3d319c57..041263c90c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -101,6 +101,7 @@ export function createFetchTemplateAction(options: { }, }, }, + supportsDryRun: true, async handler(ctx) { ctx.logger.info('Fetching template content from remote URL'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts index bf2953e75a..757e9c682e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/delete.ts @@ -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'); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts index 7c8dad7559..654ee7b150 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/rename.ts @@ -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'); From 22318e7c98a29db22b558a29d6b110107ec45e81 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Apr 2022 10:13:41 +0200 Subject: [PATCH 04/52] scaffolder-backend: hook up initial dry-run route Signed-off-by: Patrik Oldsberg --- .../scaffolder-backend/src/service/router.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 5453dcd234..09ad4f4360 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -42,6 +42,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 +130,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 +333,46 @@ export async function createRouter( subscription.unsubscribe(); clearTimeout(timeout); }); + }) + .post('/v2/dryrun', async (req, res) => { + const template = req.body.template as TemplateEntityV1beta3; + const values = req.body.values; + const token = getBearerToken(req.headers.authorization); + const content = req.body.content as { + path: string; + base64Content: string; + }[]; + + for (const parameters of [template.spec.parameters ?? []].flat()) { + const result = validate(values, parameters); + if (!result.valid) { + res.status(400).json({ errors: result.errors }); + return; + } + } + + const result = await dryRunner({ + spec: { + apiVersion: template.apiVersion, + steps: template.spec.steps.map((step, index) => ({ + ...step, + id: step.id ?? `step-${index + 1}`, + name: step.name ?? step.action, + })), + output: template.spec.output ?? {}, + parameters: values, + }, + content: (content ?? []).map(file => ({ + path: file.path, + content: Buffer.from(file.base64Content, 'base64'), + })), + secrets: { + ...req.body.secrets, + backstageToken: token, + }, + }); + + res.status(200).json(result); }); const app = express(); From f45a2abcf2895c41e1de28d9cbddee8d42d905aa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Apr 2022 10:14:41 +0200 Subject: [PATCH 05/52] scaffolder-backend: add dry-run implementation Signed-off-by: Patrik Oldsberg --- .../dryrun/DecoratedActionsRegistry.ts | 39 +++++ .../src/scaffolder/dryrun/createDryRunner.ts | 143 ++++++++++++++++++ .../src/scaffolder/dryrun/index.ts | 17 +++ 3 files changed, 199 insertions(+) create mode 100644 plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/dryrun/index.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts new file mode 100644 index 0000000000..6f11d3b6e8 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts @@ -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>, + ) { + super(); + for (const action of extraActions) { + this.register(action); + } + } + + get(actionId: string): TemplateAction { + try { + return super.get(actionId); + } catch { + return this.innerRegistry.get(actionId); + } + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts new file mode 100644 index 0000000000..de3590c3ac --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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; + content: SerializedFile[]; +} + +interface DryRunResult { + log: JsonObject[]; + content: SerializedFile[]; + output: JsonObject; +} + +/** @internal */ +export type TemplateTesterCreateOptions = { + logger: Logger; + integrations: ScmIntegrations; + actionRegistry: TemplateActionRegistry; + workingDirectory: string; + additionalTemplateFilters?: Record; +}; + +/** + * 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 { + 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(); + const contentsPath = resolveSafeChildPath( + options.workingDirectory, + `dry-run-content-${dryRunId}`, + ); + + try { + await deserializeDirectoryContents(contentsPath, input.content); + + 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, + done: false, + isDryRun: true, + getWorkspaceName: async () => `dry-run-${dryRunId}`, + async emitLog(message: string, logMetadata?: JsonObject) { + if (logMetadata?.stepId === dryRunId) { + return; + } + log.push({ + ...logMetadata, + message, + }); + }, + async complete() { + throw new Error('Not implemented'); + }, + }); + + if (!contentPromise) { + throw new Error('Content extraction step was skipped'); + } + const content = await contentPromise; + + return { + log, + content, + output: result.output, + }; + } finally { + await fs.remove(contentsPath); + } + }; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/index.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/index.ts new file mode 100644 index 0000000000..9ae38e6df2 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/index.ts @@ -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'; From c3a24968b31bd158aea92a12c9c5a83cf4a70734 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Apr 2022 10:15:27 +0200 Subject: [PATCH 06/52] scaffolder: add filesystem access helpers Signed-off-by: Patrik Oldsberg --- .../src/lib/filesystem/WebFileSystemAccess.ts | 105 ++++++++++++++++++ .../scaffolder/src/lib/filesystem/helpers.ts | 27 +++++ .../scaffolder/src/lib/filesystem/index.ts | 23 ++++ .../scaffolder/src/lib/filesystem/types.ts | 29 +++++ 4 files changed, 184 insertions(+) create mode 100644 plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts create mode 100644 plugins/scaffolder/src/lib/filesystem/helpers.ts create mode 100644 plugins/scaffolder/src/lib/filesystem/index.ts create mode 100644 plugins/scaffolder/src/lib/filesystem/types.ts diff --git a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts new file mode 100644 index 0000000000..2de3bd296f --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts @@ -0,0 +1,105 @@ +/* + * 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 { + TemplateFileSystemAccess, + TemplateDirectoryAccess, + TemplateFileAccess, +} from './types'; + +type WritableFileHandle = FileSystemFileHandle & { + createWritable(): Promise<{ + write(data: string | Blob | BufferSource): Promise; + close(): Promise; + }>; +}; + +// 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) + | undefined; + +class WebFileAccess implements TemplateFileAccess { + constructor( + readonly path: string, + private readonly handle: WritableFileHandle, + ) {} + + file(): Promise { + return this.handle.getFile(); + } + + async save(data: string | Blob | BufferSource): Promise { + 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 { + const content = []; + for await (const entry of this.listDirectoryContents(this.handle)) { + content.push(entry); + } + return content; + } + + private async *listDirectoryContents( + dirHandle: IterableDirectoryHandle, + basePath: string[] = [], + ): AsyncIterable { + 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 implements TemplateFileSystemAccess { + private static instance = new WebFileSystemAccess(); + + static get(): TemplateFileSystemAccess { + return this.instance; + } + + static isSupported(): boolean { + return Boolean(showDirectoryPicker); + } + + private constructor() {} + + async requestDirectoryAccess(): Promise { + if (!showDirectoryPicker) { + throw new Error('File system access is not supported'); + } + const handle = await showDirectoryPicker(); + return new WebDirectoryAccess(handle); + } +} diff --git a/plugins/scaffolder/src/lib/filesystem/helpers.ts b/plugins/scaffolder/src/lib/filesystem/helpers.ts new file mode 100644 index 0000000000..530cf87277 --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/helpers.ts @@ -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((resolve, reject) => { + reader.onload = () => { + const uri = reader.result as string; + resolve(uri.slice(uri.indexOf(',') + 1)); + }; + reader.onerror = reject; + }); +} diff --git a/plugins/scaffolder/src/lib/filesystem/index.ts b/plugins/scaffolder/src/lib/filesystem/index.ts new file mode 100644 index 0000000000..c57fc35756 --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/index.ts @@ -0,0 +1,23 @@ +/* + * 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, + TemplateFileSystemAccess, +} from './types'; +export { blobToBase64 } from './helpers'; +export { WebFileSystemAccess } from './WebFileSystemAccess'; diff --git a/plugins/scaffolder/src/lib/filesystem/types.ts b/plugins/scaffolder/src/lib/filesystem/types.ts new file mode 100644 index 0000000000..820ae5f99d --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/types.ts @@ -0,0 +1,29 @@ +/* + * 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; + save(data: string | BufferSource | Blob): Promise; +} + +export interface TemplateDirectoryAccess { + listFiles(): Promise>; +} + +export interface TemplateFileSystemAccess { + requestDirectoryAccess(): Promise; +} From 16f11352ab50dd71c65aac00135be5c72388da47 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Apr 2022 10:16:22 +0200 Subject: [PATCH 07/52] scaffolder: allow tweaking of the MultiStepJsonForm finish button label Signed-off-by: Patrik Oldsberg --- .../MultistepJsonForm/MultistepJsonForm.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index fb1de5f46d..af817739ed 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -54,6 +54,7 @@ type Props = { onFinish?: () => Promise; widgets?: FormProps['widgets']; fields?: FormProps['fields']; + finishButtonLabel?: string; }; export function getUiSchemasFromSteps(steps: Step[]): UiSchema[] { @@ -110,7 +111,15 @@ export function getReviewData(formData: Record, 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); @@ -239,7 +248,7 @@ export const MultistepJsonForm = (props: Props) => { onClick={handleCreate} disabled={!onFinish || disableButtons} > - Create + {finishButtonLabel ?? 'Create'} From df4497ecd7a0fe80ca104a8ad0faf889c75fe9ae Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 8 Apr 2022 10:28:03 +0200 Subject: [PATCH 08/52] scaffolder: initial e2e editor + dry-run Signed-off-by: Patrik Oldsberg --- .../tasks/NunjucksWorkflowRunner.ts | 4 + .../scaffolder-backend/src/service/router.ts | 22 +- plugins/scaffolder/package.json | 1 + plugins/scaffolder/src/api.ts | 30 +- .../MultistepJsonForm/MultistepJsonForm.tsx | 3 +- .../src/components/TaskPage/TaskPage.tsx | 12 +- .../src/components/TaskPage/TaskPageLinks.tsx | 5 +- .../DirectoryEditorContext.tsx | 243 ++++++++++ .../TemplateEditorPage/DirectoryLoader.tsx | 53 +++ .../TemplateEditorPage/DryRunContext.tsx | 448 ++++++++++++++++++ .../TemplateEditorPage/EditorIntro.tsx | 127 +++++ .../TemplateEditorPage/FileBrowser.test.tsx | 95 ++++ .../TemplateEditorPage/FileBrowser.tsx | 185 ++++++++ .../TemplateEditorPage/TemplateEditor.tsx | 416 ++++++++++++++++ .../TemplateEditorDryRunResults.tsx | 364 ++++++++++++++ .../TemplateEditorPage/TemplateEditorPage.tsx | 367 +++++--------- .../TemplateEditorPage/TemplateFormEditor.tsx | 282 +++++++++++ .../components/TemplateEditorPage/index.ts | 1 + .../lib/filesystem/MockFileSystemAccess.ts | 57 +++ plugins/scaffolder/src/types.ts | 23 +- 20 files changed, 2483 insertions(+), 255 deletions(-) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/DirectoryLoader.tsx create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/EditorIntro.tsx create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.test.tsx create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.tsx create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormEditor.tsx create mode 100644 plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 3a066494b1..97e37fd3eb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -239,6 +239,10 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { 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) { diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 09ad4f4360..dcef37f8e4 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -351,14 +351,16 @@ export async function createRouter( } } + 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: template.spec.steps.map((step, index) => ({ - ...step, - id: step.id ?? `step-${index + 1}`, - name: step.name ?? step.action, - })), + steps, output: template.spec.output ?? {}, parameters: values, }, @@ -372,7 +374,15 @@ export async function createRouter( }, }); - res.status(200).json(result); + res.status(200).json({ + ...result, + steps, + content: result.content.map(file => ({ + path: file.path, + executable: file.executable, + base64Content: file.content.toString('base64'), + })), + }); }); const app = express(); diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 91f52d9df0..19d6e84399 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -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", diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 04103b9f8a..0c2dee8f7f 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -22,7 +22,8 @@ import { } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import { Observable } from '@backstage/types'; +import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; +import { JsonObject, Observable } from '@backstage/types'; import qs from 'qs'; import ObservableImpl from 'zen-observable'; import { @@ -172,6 +173,33 @@ export class ScaffolderClient implements ScaffolderApi { return this.streamLogsEventStream(options); } + async dryRun(options: { + template: TemplateEntityV1beta3; + values: JsonObject; + secrets: JsonObject; + content: { path: string; base64Content: string }[]; + }): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const res = await this.fetchApi.fetch(`${baseUrl}/v2/dryrun`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + template: options.template, + values: options.values, + secrets: options.secrets, + content: options.content, + }), + }); + + if (!res.ok) { + throw await ResponseError.fromResponse(res); + } + + return res.json(); + } + private streamLogsEventStream({ taskId, after, diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index af817739ed..33e274934e 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -180,8 +180,9 @@ export const MultistepJsonForm = (props: Props) => { try { await onFinish(); } catch (err) { - setDisableButtons(false); errorApi.post(err); + } finally { + setDisableButtons(false); } }; diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index 27d89cb6a4..0662b0300f 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -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 (
diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx index cb77689b6e..78d6a452ea 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx @@ -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: '', + defaultNamespace: '', + }); const target = entityRoute(entityName); return { title, icon, url: target }; } diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx new file mode 100644 index 0000000000..0edf390bfe --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx @@ -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; + /** Reload the staged content of the file from disk */ + reload(): Promise; +} + +interface DirectoryEditor { + /** A list of all files in the edited directory */ + files: Array; + + /** The currently selected file */ + selectedFile: DirectoryEditorFile | undefined; + /** Switch the selected file */ + setSelectedFile(path: string | undefined): void; + + /** Save all files to disk */ + save(): Promise; + /** Reload all files from disk */ + reload(): Promise; + + 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 { + if (this.#content !== undefined) { + await this.#access.save(this.#content); + this.#savedContent = this.#content; + this.#signalUpdate(); + } + } + + async reload(): Promise { + 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 { + await Promise.all(this.#files.map(file => file.save())); + } + + async reload(): Promise { + 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( + 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 ; + } else if (!result) { + return ; + } + + return ( + + {props.children} + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryLoader.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryLoader.tsx new file mode 100644 index 0000000000..65f0c21e32 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryLoader.tsx @@ -0,0 +1,53 @@ +/* + * 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 Button from '@material-ui/core/Button'; +import React from 'react'; +import { useAsync } from '@react-hookz/web'; +import { + TemplateDirectoryAccess, + WebFileSystemAccess, +} from '../../lib/filesystem'; + +interface DirectoryLoaderProps { + onLoad(directory: TemplateDirectoryAccess): void; +} + +/** @internal */ +export function DirectoryLoader(props: DirectoryLoaderProps): JSX.Element { + const supportsWebAccess = WebFileSystemAccess.isSupported(); + + const [{ status, error }, { execute }] = useAsync(async () => { + if (!supportsWebAccess) { + return; + } + const directory = await WebFileSystemAccess.get().requestDirectoryAccess(); + props.onLoad(directory); + }); + + if (error) { + return
Fail: {error.message}
; + } + + return ( + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx new file mode 100644 index 0000000000..1b81b517d3 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx @@ -0,0 +1,448 @@ +/* + * 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, + useContext, + 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; +} + +const DryRunContext = createContext(undefined); + +interface DryRunProviderProps { + children: ReactNode; +} + +const fakeResults: DryRunResult[] = [ + { + id: 1, + content: [ + { + path: 'catalog-info.yaml', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml1', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml2', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml3', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml4', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml5', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml6', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml7', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml8', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml9', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml10', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml11', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml12', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml13', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + { + path: 'catalog-info.yaml14', + executable: false, + base64Content: + 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', + }, + ], + steps: [ + { + id: 'fetch-base', + name: 'Fetch stuff', + action: 'fetch:plain', + }, + { + id: 'fetch-base2', + name: 'Fetch stuff', + action: 'fetch:plain', + }, + { + id: 'fetch-base3', + name: 'Fetch stuff', + action: 'fetch:plain', + }, + { + id: 'fetch-base4', + name: 'Fetch stuff', + action: 'fetch:plain', + }, + { + id: 'fetch-base5', + name: 'Fetch stuff', + action: 'fetch:plain', + }, + { + id: 'publish', + name: 'Publish Stuff', + action: 'publish:github', + }, + { + id: 'register', + name: 'Register Stuff', + action: 'catalog:register', + }, + ], + log: [ + { + message: 'Starting up task with 4 steps', + }, + { + stepId: 'fetch-base', + status: 'processing', + message: 'Beginning step Fetch Base', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Fetching template content from remote URL {"timestamp":"2022-04-18T11:25:46.888Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Listing files and directories in template {"timestamp":"2022-04-18T11:25:46.889Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Processing 1 template files/directories with input values {"name":"asd","timestamp":"2022-04-18T11:25:46.890Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Writing file catalog-info.yaml to template output path with mode 33188. {"timestamp":"2022-04-18T11:25:46.932Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + message: + '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', + }, + { + stepId: 'fetch-base', + status: 'completed', + message: 'Finished step Fetch Base', + }, + { + stepId: 'fetch-base2', + status: 'completed', + message: 'Finished step Fetch Base', + }, + { + stepId: 'fetch-base3', + status: 'completed', + message: 'Finished step Fetch Base', + }, + { + stepId: 'fetch-base4', + status: 'completed', + message: 'Finished step Fetch Base', + }, + { + stepId: 'fetch-base5', + status: 'completed', + message: 'Finished step Fetch Base', + }, + { + stepId: 'publish', + status: 'processing', + message: 'Beginning step Publish', + }, + { + stepId: 'publish', + status: 'completed', + message: 'Skipping because publish:bitbucket does not support dry-run', + }, + { + stepId: 'register', + status: 'processing', + message: 'Beginning step Register', + }, + { + stepId: 'register', + status: 'completed', + message: 'Skipping because catalog:register does not support dry-run', + }, + ], + output: { + links: [ + { + title: 'Repository', + url: '', + }, + { + title: 'Open in catalog', + icon: 'catalog', + entityRef: 'entity', + }, + ], + }, + }, +]; + +export function DryRunProvider(props: DryRunProviderProps) { + const scaffolderApi = useApi(scaffolderApiRef); + + const [state, setState] = useState< + Pick + >({ + results: [ + { ...fakeResults[0], id: 1 }, + { ...fakeResults[0], id: 2 }, + { ...fakeResults[0], id: 3 }, + { ...fakeResults[0], id: 4 }, + { ...fakeResults[0], id: 5 }, + ], + selectedResult: fakeResults[0], + }); + const idRef = useRef(1); + + const dryRun = { + ...state, + selectResult: (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, + }; + }); + }, + deleteResult: (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, + }; + }); + }, + execute: 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: {}, + content: 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, + })); + }, + }; + + return ( + + {props.children} + + ); +} + +export function useDryRun(): DryRun { + const value = useContext(DryRunContext); + if (!value) { + throw new Error('must be used within a DryRunProvider'); + } + return value; +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/EditorIntro.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/EditorIntro.tsx new file mode 100644 index 0000000000..1c07a78c9e --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/EditorIntro.tsx @@ -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 const EditorIntro = (props: EditorIntroProps) => { + const classes = useStyles(); + const supportsLoad = WebFileSystemAccess.isSupported(); + + const cardLoadLocal = ( + + props.onSelect?.('local')} + > + + + Load Template Directory + + + Load a local template directory, allowing you to both edit and try + executing your own template. + + + + {!supportsLoad && ( +
+ + + +
+ )} +
+ ); + + const cardFormEditor = ( + + props.onSelect?.('form')}> + + + Edit Template Form + + + Preview and edit a template form, either using a sample template or + by loading a template from the catalog. + + + + + ); + + return ( +
+ + Get started by choosing one of the options below + +
+ {supportsLoad && cardLoadLocal} + {cardFormEditor} + {!supportsLoad && cardLoadLocal} +
+
+ ); +}; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.test.tsx new file mode 100644 index 0000000000..67d0034ba3 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.test.tsx @@ -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'`, + ); + }); +}); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.tsx new file mode 100644 index 0000000000..705f5776c9 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.tsx @@ -0,0 +1,185 @@ +/* + * 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'; +import { + TemplateDirectoryAccess, + TemplateFileAccess, +} from '../../lib/filesystem'; +import { useAsync, useMountEffect } from '@react-hookz/web'; +import { ErrorPanel, Progress } from '@backstage/core-components'; + +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 ; + } + + return ( + + {entry.children.map(child => ( + + ))} + + ); +} + +interface FileBrowserProps { + selected?: string; + filePaths: string[]; + onSelect?(filePath: string): void; +} + +export function FileBrowser(props: FileBrowserProps) { + const classes = useStyles(); + + const fileTree = useMemo( + () => parseFileEntires(props.filePaths), + [props.filePaths], + ); + + return ( + } + defaultExpandIcon={} + onNodeSelect={(_e: unknown, nodeId: string) => { + if (props.onSelect && props.filePaths.includes(nodeId)) { + props.onSelect(nodeId); + } + }} + > + {fileTree.map(entry => ( + + ))} + + ); +} + +interface TemplateDirectoryAccessBrowserProps { + directory: TemplateDirectoryAccess; + onSelect?(file: TemplateFileAccess): void; +} + +function TemplateDirectoryAccessBrowser( + props: TemplateDirectoryAccessBrowserProps, +) { + const [state, { execute }] = useAsync(async () => { + const files = await props.directory.listFiles(); + return { + filePaths: files.map(file => file.path), + getFile: (path: string) => files.find(file => file.path === path), + }; + }); + + useMountEffect(execute); + + if (state.error) { + return ; + } else if (!state.result) { + return ; + } + + const handleSelect = (path: string) => { + const file = state.result?.getFile(path); + if (file) { + props.onSelect?.(file); + } + }; + + return ( + + ); +} + +FileBrowser.TemplateDirectoryAccess = TemplateDirectoryAccessBrowser; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx new file mode 100644 index 0000000000..3361e3ab5a --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx @@ -0,0 +1,416 @@ +/* + * 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, { + Component, + ReactNode, + useEffect, + useMemo, + useReducer, + useState, +} from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { useApiHolder } from '@backstage/core-plugin-api'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; +import { showPanel } from '@codemirror/view'; +import { StreamLanguage } from '@codemirror/language'; +import { IconButton, makeStyles, Divider } from '@material-ui/core'; +import SaveIcon from '@material-ui/icons/Save'; +import RefreshIcon from '@material-ui/icons/Refresh'; +import CloseIcon from '@material-ui/icons/Close'; +import CodeMirror from '@uiw/react-codemirror'; +import yaml from 'yaml'; +import { FieldExtensionOptions } from '../../extensions'; +import { TemplateParameterSchema } from '../../types'; +import { MultistepJsonForm } from '../MultistepJsonForm'; +import { createValidator } from '../TemplatePage'; +import { TemplateDirectoryAccess } from '../../lib/filesystem'; +import { FileBrowser } from './FileBrowser'; +import { + DirectoryEditorProvider, + useDirectoryEditor, +} from './DirectoryEditorContext'; +import { DryRunProvider, useDryRun } from './DryRunContext'; +import { TemplateEditorDryRunResults } from './TemplateEditorDryRunResults'; + +const useStyles = makeStyles(theme => ({ + // Reset and fix sizing to make sure scrolling behaves correctly + rootWrapper: { + gridArea: 'pageContent', + position: 'relative', + width: '100%', + }, + root: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + + display: 'grid', + gridTemplateAreas: ` + "browser editor preview" + "results results results" + `, + gridTemplateColumns: '1fr 3fr 2fr', + gridTemplateRows: '1fr auto', + }, + browser: { + gridArea: 'browser', + overflow: 'auto', + }, + browserButton: { + padding: theme.spacing(1), + }, + browserButtons: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + justifyContent: 'flex-start', + }, + browserButtonsGap: { + flex: '1 1 auto', + }, + browserButtonsDivider: { + marginBottom: theme.spacing(1), + }, + editor: { + position: 'relative', + gridArea: 'editor', + overflow: 'auto', + }, + editorCodeMirror: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + preview: { + gridArea: 'preview', + overflow: 'auto', + }, + results: { + gridArea: 'results', + }, +})); + +export const TemplateEditor = ({ + directory, + fieldExtensions = [], + onClose, +}: { + directory: TemplateDirectoryAccess; + fieldExtensions?: FieldExtensionOptions[]; + onClose?: () => void; +}) => { + const classes = useStyles(); + + const [errorText, setErrorText] = useState(); + + return ( + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ ); +}; + +function TemplateEditorBrowser() { + const classes = useStyles(); + const directoryEditor = useDirectoryEditor(); + + return ( + <> +
+ + + + + + +
+ + + +
+ + file.path)} + /> + + ); +} + +function TemplateEditorTextArea(props: { errorText?: string }) { + const { errorText } = props; + const classes = useStyles(); + const directoryEditor = useDirectoryEditor(); + + const errorPanel = useMemo(() => { + const div = document.createElement('div'); + div.style.color = 'red'; + return div; + }, []); + + useEffect(() => { + errorPanel.textContent = errorText ?? ''; + }, [errorPanel, errorText]); + + return ( + ({ dom: errorPanel, top: true })), + ]} + value={directoryEditor.selectedFile?.content} + onChange={content => directoryEditor.selectedFile?.updateContent(content)} + /> + ); +} + +interface ErrorBoundaryProps { + generation: number; + setErrorText(errorText: string | undefined): void; + children: ReactNode; +} + +interface ErrorBoundaryState { + shouldRender: boolean; +} + +class ErrorBoundary extends Component { + state = { + shouldRender: true, + }; + + componentDidUpdate(prevProps: { generation: number }) { + if (prevProps.generation !== this.props.generation) { + 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 TemplateFormState { + filePath?: string; + content?: string; + steps?: TemplateParameterSchema['steps']; + formData: JsonObject; + schemaGeneration: number; +} + +type TemplateFormAction = + | { + type: 'reset'; + } + | { + type: 'updateData'; + formData: JsonObject; + } + | { + type: 'updateSchema'; + steps: TemplateParameterSchema['steps']; + filePath: string; + }; + +const initialTemplateFormState: TemplateFormState = { + steps: undefined, + filePath: undefined, + formData: {}, + // Used to reset the error boundary in edit + schemaGeneration: 0, +}; + +function templateFormReducer( + state: TemplateFormState, + action: TemplateFormAction, +): TemplateFormState { + switch (action.type) { + case 'reset': { + return initialTemplateFormState; + } + case 'updateData': { + return { + ...state, + formData: action.formData, + }; + } + case 'updateSchema': { + const { filePath, steps } = action; + + return { + steps, + filePath, + formData: state.filePath === filePath ? state.formData : {}, + schemaGeneration: state.schemaGeneration + 1, + }; + } + default: + return state; + } +} + +interface TemplateEditorFormProps { + setErrorText: (errorText?: string) => void; + fieldExtensions?: FieldExtensionOptions[]; +} + +function isJsonObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function TemplateEditorForm(props: TemplateEditorFormProps) { + const { setErrorText, fieldExtensions = [] } = props; + const dryRun = useDryRun(); + + const apiHolder = useApiHolder(); + const directoryEditor = useDirectoryEditor(); + const { selectedFile } = directoryEditor; + const [state, dispatch] = useReducer( + templateFormReducer, + initialTemplateFormState, + ); + + useDebounce( + () => { + try { + if (!selectedFile || !selectedFile.path.match(/\.ya?ml$/)) { + dispatch({ type: 'reset' }); + return; + } + const parsed: JsonValue = yaml.parse(selectedFile.content); + const isTemplate = + typeof parsed === 'object' && + parsed !== null && + 'kind' in parsed && + typeof parsed.kind === 'string' && + parsed.kind.toLocaleLowerCase('en-US') === 'template'; + + if (!isTemplate) { + dispatch({ type: 'reset' }); + return; + } + + const spec = parsed.spec; + const parameters = isJsonObject(spec) && spec.parameters; + if (!Array.isArray(parameters)) { + setErrorText('Template parameters must be an array'); + return; + } + + const fieldValidators = Object.fromEntries( + fieldExtensions.map(({ name, validation }) => [name, validation]), + ); + + setErrorText(); + + dispatch({ + type: 'updateSchema', + filePath: selectedFile.path, + steps: parameters.flatMap(param => + isJsonObject(param) + ? [ + { + title: String(param.title), + schema: param, + validate: createValidator(param, fieldValidators, { + apiHolder, + }), + }, + ] + : [], + ), + }); + } catch (e) { + setErrorText(e.message); + } + }, + 250, + [selectedFile?.path, selectedFile?.content, apiHolder], + ); + + const fields = useMemo(() => { + return Object.fromEntries( + fieldExtensions.map(({ name, component }) => [name, component]), + ); + }, [fieldExtensions]); + + if (!state.steps) { + return null; + } + + const handleDryRun = async () => { + if (!selectedFile) { + return; + } + + await dryRun.execute({ + templateContent: selectedFile.content, + values: state.formData, + files: directoryEditor.files, + }); + }; + + return ( + + dispatch({ type: 'updateData', formData: e.formData })} + onReset={() => dispatch({ type: 'updateData', formData: {} })} + finishButtonLabel="Try It" + onFinish={handleDryRun} + /> + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx new file mode 100644 index 0000000000..756bf8421c --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx @@ -0,0 +1,364 @@ +/* + * 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 IconButton from '@material-ui/core/IconButton'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import ListItemText from '@material-ui/core/ListItemText'; +import Accordion from '@material-ui/core/Accordion'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import Typography from '@material-ui/core/Typography'; +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; +import Box from '@material-ui/core/Box'; +import React, { + Children, + ReactNode, + useEffect, + useMemo, + useState, +} from 'react'; +import classNames from 'classnames'; +import { useDryRun } from './DryRunContext'; +import DeleteIcon from '@material-ui/icons/Delete'; +import CheckIcon from '@material-ui/icons/Check'; +import CancelIcon from '@material-ui/icons/Cancel'; +import ExpandMoreIcon from '@material-ui/icons/ExpandLess'; +import { FileBrowser } from './FileBrowser'; +import CodeMirror from '@uiw/react-codemirror'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; +import { StreamLanguage } from '@codemirror/stream-parser'; +import { LogViewer } from '@backstage/core-components'; +import { usePrevious } from '@react-hookz/web'; +import { TaskStatusStepper } from '../TaskPage/TaskPage'; +import { TaskPageLinks } from '../TaskPage/TaskPageLinks'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import { BackstageTheme } from '@backstage/theme'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + accordionHeader: { + height: 48, + minHeight: 0, + '&.Mui-expanded': { + height: 48, + minHeight: 0, + }, + }, + accordionContent: { + display: 'grid', + background: theme.palette.background.default, + gridTemplateColumns: '180px auto 1fr', + gridTemplateRows: '1fr', + padding: 0, + height: 400, + }, + resultList: { + overflowY: 'auto', + background: theme.palette.background.default, + }, + resultListIconSuccess: { + minWidth: 0, + marginRight: theme.spacing(1), + color: theme.palette.status.ok, + }, + resultListIconFailure: { + minWidth: 0, + marginRight: theme.spacing(1), + color: theme.palette.status.error, + }, + resultView: { + display: 'flex', + flexFlow: 'column nowrap', + }, + resultViewItemWrapper: { + flex: 1, + position: 'relative', + }, + resultViewItem: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + + display: 'flex', + '& > *': { + flex: 1, + }, + }, + codeMirror: { + height: '100%', + overflowY: 'auto', + }, +})); + +export function TemplateEditorDryRunResults() { + const classes = useStyles(); + const dryRun = useDryRun(); + const [expanded, setExpanded] = useState(true); + const [hidden, setHidden] = useState(true); + + const resultsLength = dryRun.results.length; + const prevResultsLength = usePrevious(resultsLength); + useEffect(() => { + if (prevResultsLength === 0 && resultsLength === 1) { + setExpanded(true); + setHidden(false); + } else if (prevResultsLength === 1 && resultsLength === 0) { + setExpanded(false); + } + }, [prevResultsLength, resultsLength]); + + return ( + <> + + + ); +} + +function ResultList() { + const classes = useStyles(); + const dryRun = useDryRun(); + + return ( + + {dryRun.results.map(result => { + const failed = result.log.some(l => l.status === 'failed'); + return ( + dryRun.selectResult(result.id)} + > + + {failed ? : } + + + + dryRun.deleteResult(result.id)} + > + + + + + ); + })} + + ); +} + +function ResultView() { + const classes = useStyles(); + const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>( + 'files', + ); + + return ( +
+ setSelectedTab(v)}> + + + + + + +
+
+ {selectedTab === 'files' && } + {selectedTab === 'log' && } + {selectedTab === 'output' && } +
+
+
+ ); +} + +const useSplitViewStyles = makeStyles(theme => ({ + root: { + display: 'grid', + gridTemplateColumns: '1fr auto 3fr', + gridTemplateRows: '1fr', + }, + child: { + overflowY: 'auto', + height: '100%', + minHeight: 0, + }, + childPaper: { + background: theme.palette.background.paper, + }, +})); + +function SplitView(props: { children: ReactNode }) { + const classes = useSplitViewStyles(); + const childArray = Children.toArray(props.children); + + if (childArray.length !== 2) { + throw new Error('SplitView must have exactly 2 children'); + } + + return ( +
+
+ {childArray[0]} +
+ +
{childArray[1]}
+
+ ); +} + +function FilesContent() { + const classes = useStyles(); + const { selectedResult } = useDryRun(); + const [selectedPath, setSelectedPath] = useState(''); + const selectedFile = selectedResult?.content.find( + f => f.path === selectedPath, + ); + + useEffect(() => { + if (selectedResult) { + const [firstFile] = selectedResult.content; + if (firstFile) { + setSelectedPath(firstFile.path); + } else { + setSelectedPath(''); + } + } + return undefined; + }, [selectedResult]); + + if (!selectedResult) { + return null; + } + return ( + + file.path)} + /> + + + ); +} +function LogContent() { + const { selectedResult } = useDryRun(); + const [currentStepId, setUserSelectedStepId] = useState(); + + const steps = useMemo(() => { + if (!selectedResult) { + return []; + } + return ( + selectedResult.steps.map(step => { + const stepLog = selectedResult.log.filter(l => l.stepId === step.id); + return { + id: step.id, + name: step.name, + logString: stepLog.map(l => l.message).join('\n'), + status: stepLog[stepLog.length - 1]?.status ?? 'completed', + }; + }) ?? [] + ); + }, [selectedResult]); + + if (!selectedResult) { + return null; + } + + const selectedStep = steps.find(s => s.id === currentStepId) ?? steps[0]; + + return ( + + + + + ); +} + +function OutputContent() { + const classes = useStyles(); + const { selectedResult } = useDryRun(); + + if (!selectedResult) { + return null; + } + + return ( + + + {selectedResult.output?.links?.length && ( + + )} + + + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index 71c64a9fa4..20de52a266 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -13,258 +13,147 @@ * 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 { EditorIntro } from './EditorIntro'; +import { TemplateEditor } from './TemplateEditor'; +import { TemplateFormEditor } from './TemplateFormEditor'; import { FieldExtensionOptions } from '../../extensions'; -import { TemplateParameterSchema } from '../../types'; -import { MultistepJsonForm } from '../MultistepJsonForm'; -import { createValidator } from '../TemplatePage'; +import { MockFileSystemAccess } from '../../lib/filesystem/MockFileSystemAccess'; -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[]; -}) => { - const classes = useStyles(); - const alertApi = useApi(alertApiRef); - const catalogApi = useApi(catalogApiRef); - const apiHolder = useApiHolder(); - const [selectedTemplate, setSelectedTemplate] = useState(''); - const [schema, setSchema] = useState({ - title: '', - steps: [], +} + +export function TemplateEditorPage(props: TemplateEditorPageProps) { + const [selection, setSelection] = useState({ + type: 'local', + directory: MockFileSystemAccess.createMockDirectory({ + 'template.yaml': ` +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: bitbucket-demo +spec: + type: service + + parameters: + - title: Choose a name and location + required: + - name + - repoUrl + properties: + name: + title: Name + type: string + 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 }} + - id: publish + name: Publish + action: publish:bitbucket + input: + description: This is \${{ parameters.name }} + repoUrl: \${{ parameters.repoUrl }} + + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: \${{ steps.publish.output.repoContentsUrl }} + catalogInfoPath: '/catalog-info.yaml' + + output: + links: + - title: Repository + url: \${{ steps.publish.output.remoteUrl }} + - title: Open in catalog + icon: catalog + entityRef: \${{ steps.register.output.entityRef }} +`, + 'template/catalog-info.yaml': ` +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: \${{values.name | dump}} +spec: + type: website + lifecycle: experimental + owner: \${{values.owner | dump}} +`, + }), }); - const [templateOptions, setTemplateOptions] = useState([]); - 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], - ); - - 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 = ( + setSelection(undefined)} + /> + ); + } else if (selection?.type === 'form') { + content = ( + setSelection(undefined)} + /> + ); + } else { + content = ( + + { + if (option === 'local') { + WebFileSystemAccess.get() + .requestDirectoryAccess() + .then(directory => setSelection({ type: 'local', directory })) + .catch(() => {}); + } else if (option === 'form') { + setSelection({ type: 'form' }); + } + }} + /> + + ); + } return (
- - {loading && } - - - - - Load Existing Template - - - - ({ dom: errorPanel, top: true })), - ]} - onChange={handleCodeChange} - /> - - - {schema && ( - - { - return { - ...step, - validate: createValidator( - step.schema, - customFieldValidators, - { apiHolder }, - ), - }; - })} - /> - - )} - - - + {content} ); -}; +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormEditor.tsx new file mode 100644 index 0000000000..43c7f10a32 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormEditor.tsx @@ -0,0 +1,282 @@ +/* + * 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, { 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 { InfoCard } from '@backstage/core-components'; +import { alertApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api'; +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, + IconButton, + InputLabel, + LinearProgress, + makeStyles, + MenuItem, + Select, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import { IChangeEvent } from '@rjsf/core'; +import CodeMirror from '@uiw/react-codemirror'; +import yaml from 'yaml'; +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 +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 => ({ + controls: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + marginBottom: theme.spacing(1), + }, + grid: { + height: '100%', + }, + codeMirror: { + height: '95%', + }, +})); + +export const TemplateFormEditor = ({ + defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML, + customFieldExtensions = [], + onClose, +}: { + defaultPreviewTemplate?: string; + customFieldExtensions?: FieldExtensionOptions[]; + onClose?: () => void; +}) => { + const classes = useStyles(); + const alertApi = useApi(alertApiRef); + const catalogApi = useApi(catalogApiRef); + const apiHolder = useApiHolder(); + const [selectedTemplate, setSelectedTemplate] = useState(''); + const [schema, setSchema] = useState({ + title: '', + steps: [], + }); + const [templateOptions, setTemplateOptions] = useState([]); + 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 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]), + ); + + return ( + <> + {loading && } + + +
+ + + Load Existing Template + + + + + + + +
+ ({ dom: errorPanel, top: true })), + ]} + onChange={handleCodeChange} + /> +
+ + {schema && ( + + { + return { + ...step, + validate: createValidator( + step.schema, + customFieldValidators, + { apiHolder }, + ), + }; + })} + /> + + )} + +
+ + ); +}; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/index.ts index 506ff08f24..7ec6bddb64 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/index.ts +++ b/plugins/scaffolder/src/components/TemplateEditorPage/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { TemplateEditorPage } from './TemplateEditorPage'; diff --git a/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts new file mode 100644 index 0000000000..d466543dbd --- /dev/null +++ b/plugins/scaffolder/src/lib/filesystem/MockFileSystemAccess.ts @@ -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 { + 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 { + this.content = await new Response(data).text(); + } +} + +class MockDirectoryAccess implements TemplateDirectoryAccess { + private readonly files = new Array(); + + constructor(inputFiles: Record) { + this.files = Object.entries(inputFiles).map( + ([path, content]) => new MockFileAccess(path, content), + ); + } + + async listFiles(): Promise { + return this.files; + } +} + +/** @internal */ +export class MockFileSystemAccess { + private constructor() {} + + static createMockDirectory(files: Record) { + return new MockDirectoryAccess(files); + } +} diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index bddd6a7b70..1291cd9951 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -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,25 @@ export interface ScaffolderStreamLogsOptions { taskId: string; after?: number; } + +export interface ScaffolderDryRunOptions { + template: JsonValue; + values: JsonObject; + secrets: JsonObject; + content: { path: string; base64Content: string }[]; +} + +export interface ScaffolderDryRunResponse { + content: Array<{ + path: string; + base64Content: string; + executable: boolean; + }>; + log: Array; + steps: TaskStep[]; + output: ScaffolderTaskOutput; +} + /** * An API to interact with the scaffolder backend. * @@ -181,4 +200,6 @@ export interface ScaffolderApi { listActions(): Promise; streamLogs(options: ScaffolderStreamLogsOptions): Observable; + + dryRun?(options: ScaffolderDryRunOptions): Promise; } From 7c6ceb2d32e617ae2b93a750a6cbb7983e00150b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 24 Apr 2022 13:29:43 +0200 Subject: [PATCH 09/52] scaffolder: hook up template editor buttons + floating text area buttons and hotkey Signed-off-by: Patrik Oldsberg --- .../TemplateEditorPage/TemplateEditor.tsx | 131 ++++++++++++++---- 1 file changed, 101 insertions(+), 30 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx index 3361e3ab5a..d3f376c9ac 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx @@ -21,13 +21,20 @@ import React, { useReducer, useState, } from 'react'; +import { useKeyboardEvent } from '@react-hookz/web'; import useDebounce from 'react-use/lib/useDebounce'; import { useApiHolder } from '@backstage/core-plugin-api'; import { JsonObject, JsonValue } from '@backstage/types'; import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; import { showPanel } from '@codemirror/view'; import { StreamLanguage } from '@codemirror/language'; -import { IconButton, makeStyles, Divider } from '@material-ui/core'; +import { + IconButton, + makeStyles, + Divider, + Tooltip, + Paper, +} from '@material-ui/core'; import SaveIcon from '@material-ui/icons/Save'; import RefreshIcon from '@material-ui/icons/Refresh'; import CloseIcon from '@material-ui/icons/Close'; @@ -99,6 +106,11 @@ const useStyles = makeStyles(theme => ({ left: 0, right: 0, }, + editorFloatingButtons: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(3), + }, preview: { gridArea: 'preview', overflow: 'auto', @@ -108,11 +120,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export const TemplateEditor = ({ - directory, - fieldExtensions = [], - onClose, -}: { +export const TemplateEditor = (props: { directory: TemplateDirectoryAccess; fieldExtensions?: FieldExtensionOptions[]; onClose?: () => void; @@ -122,12 +130,12 @@ export const TemplateEditor = ({ const [errorText, setErrorText] = useState(); return ( - +
- +
@@ -135,7 +143,7 @@ export const TemplateEditor = ({
@@ -148,23 +156,52 @@ export const TemplateEditor = ({ ); }; -function TemplateEditorBrowser() { +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 ( <>
- - - - - - + + directoryEditor.save()} + > + + + + + directoryEditor.reload()} + > + + +
- - - + + + + +
e.key === 's' && (e.ctrlKey || e.metaKey), + e => { + e.preventDefault(); + directoryEditor.save(); + }, + ); + return ( - ({ dom: errorPanel, top: true })), - ]} - value={directoryEditor.selectedFile?.content} - onChange={content => directoryEditor.selectedFile?.updateContent(content)} - /> + <> + ({ dom: errorPanel, top: true })), + ]} + value={directoryEditor.selectedFile?.content} + onChange={content => + directoryEditor.selectedFile?.updateContent(content) + } + /> + {directoryEditor.selectedFile?.dirty && ( +
+ + + directoryEditor.save()} + > + + + + + directoryEditor.reload()} + > + + + + +
+ )} + ); } From a818ce53a7f5ba5d92b52ec5fafb9594c856ecfe Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 24 Apr 2022 17:03:47 +0200 Subject: [PATCH 10/52] scaffolder: tweak TemplateEditor error display Signed-off-by: Patrik Oldsberg --- .../TemplateEditorPage/TemplateEditor.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx index d3f376c9ac..169a245af9 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx @@ -16,7 +16,6 @@ import React, { Component, ReactNode, - useEffect, useMemo, useReducer, useState, @@ -106,6 +105,11 @@ const useStyles = makeStyles(theme => ({ left: 0, right: 0, }, + editorErrorPanel: { + color: theme.palette.error.main, + lineHeight: 2, + margin: theme.spacing(0, 1), + }, editorFloatingButtons: { position: 'absolute', top: theme.spacing(1), @@ -219,14 +223,14 @@ function TemplateEditorTextArea(props: { errorText?: string }) { const directoryEditor = useDirectoryEditor(); const errorPanel = useMemo(() => { + if (!errorText) { + return undefined; + } const div = document.createElement('div'); - div.style.color = 'red'; + div.classList.add(classes.editorErrorPanel); + div.textContent = errorText; return div; - }, []); - - useEffect(() => { - errorPanel.textContent = errorText ?? ''; - }, [errorPanel, errorText]); + }, [classes, errorText]); useKeyboardEvent( e => e.key === 's' && (e.ctrlKey || e.metaKey), @@ -244,7 +248,9 @@ function TemplateEditorTextArea(props: { errorText?: string }) { height="100%" extensions={[ StreamLanguage.define(yamlSupport), - showPanel.of(() => ({ dom: errorPanel, top: true })), + ...(errorPanel + ? [showPanel.of(() => ({ dom: errorPanel, bottom: true }))] + : []), ]} value={directoryEditor.selectedFile?.content} onChange={content => From d989e0c2030de5afed76337758e5906daf01f4e1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 24 Apr 2022 17:16:55 +0200 Subject: [PATCH 11/52] scaffolder: remove mock editor data Signed-off-by: Patrik Oldsberg --- .../TemplateEditorPage/DryRunContext.tsx | 313 +----------------- .../TemplateEditorPage/TemplateEditorPage.tsx | 72 +--- 2 files changed, 3 insertions(+), 382 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx index 1b81b517d3..ea687095db 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx @@ -52,323 +52,14 @@ interface DryRunProviderProps { children: ReactNode; } -const fakeResults: DryRunResult[] = [ - { - id: 1, - content: [ - { - path: 'catalog-info.yaml', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml1', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml2', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml3', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml4', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml5', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml6', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml7', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml8', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml9', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml10', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml11', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml12', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml13', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - { - path: 'catalog-info.yaml14', - executable: false, - base64Content: - 'CmFwaVZlcnNpb246IGJhY2tzdGFnZS5pby92MWFscGhhMQpraW5kOiBDb21wb25lbnQKbWV0YWRhdGE6CiAgbmFtZTogImFzZCIKc3BlYzoKICB0eXBlOiB3ZWJzaXRlCiAgbGlmZWN5Y2xlOiBleHBlcmltZW50YWwKICBvd25lcjogCg==', - }, - ], - steps: [ - { - id: 'fetch-base', - name: 'Fetch stuff', - action: 'fetch:plain', - }, - { - id: 'fetch-base2', - name: 'Fetch stuff', - action: 'fetch:plain', - }, - { - id: 'fetch-base3', - name: 'Fetch stuff', - action: 'fetch:plain', - }, - { - id: 'fetch-base4', - name: 'Fetch stuff', - action: 'fetch:plain', - }, - { - id: 'fetch-base5', - name: 'Fetch stuff', - action: 'fetch:plain', - }, - { - id: 'publish', - name: 'Publish Stuff', - action: 'publish:github', - }, - { - id: 'register', - name: 'Register Stuff', - action: 'catalog:register', - }, - ], - log: [ - { - message: 'Starting up task with 4 steps', - }, - { - stepId: 'fetch-base', - status: 'processing', - message: 'Beginning step Fetch Base', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Fetching template content from remote URL {"timestamp":"2022-04-18T11:25:46.888Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Listing files and directories in template {"timestamp":"2022-04-18T11:25:46.889Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Processing 1 template files/directories with input values {"name":"asd","timestamp":"2022-04-18T11:25:46.890Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Writing file catalog-info.yaml to template output path with mode 33188. {"timestamp":"2022-04-18T11:25:46.932Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - message: - '\u001b[32minfo\u001b[39m: Template result written to /var/folders/ll/hcvgyy216t70dkjxwkk7csn00000gn/T/dry-run-5c024be8-8a08-4a10-a502-b43c64ed9061 {"timestamp":"2022-04-18T11:25:46.936Z"}', - }, - { - stepId: 'fetch-base', - status: 'completed', - message: 'Finished step Fetch Base', - }, - { - stepId: 'fetch-base2', - status: 'completed', - message: 'Finished step Fetch Base', - }, - { - stepId: 'fetch-base3', - status: 'completed', - message: 'Finished step Fetch Base', - }, - { - stepId: 'fetch-base4', - status: 'completed', - message: 'Finished step Fetch Base', - }, - { - stepId: 'fetch-base5', - status: 'completed', - message: 'Finished step Fetch Base', - }, - { - stepId: 'publish', - status: 'processing', - message: 'Beginning step Publish', - }, - { - stepId: 'publish', - status: 'completed', - message: 'Skipping because publish:bitbucket does not support dry-run', - }, - { - stepId: 'register', - status: 'processing', - message: 'Beginning step Register', - }, - { - stepId: 'register', - status: 'completed', - message: 'Skipping because catalog:register does not support dry-run', - }, - ], - output: { - links: [ - { - title: 'Repository', - url: '', - }, - { - title: 'Open in catalog', - icon: 'catalog', - entityRef: 'entity', - }, - ], - }, - }, -]; - export function DryRunProvider(props: DryRunProviderProps) { const scaffolderApi = useApi(scaffolderApiRef); const [state, setState] = useState< Pick >({ - results: [ - { ...fakeResults[0], id: 1 }, - { ...fakeResults[0], id: 2 }, - { ...fakeResults[0], id: 3 }, - { ...fakeResults[0], id: 4 }, - { ...fakeResults[0], id: 5 }, - ], - selectedResult: fakeResults[0], + results: [], + selectedResult: undefined, }); const idRef = useRef(1); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index 20de52a266..f2f603337d 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -23,7 +23,6 @@ import { EditorIntro } from './EditorIntro'; import { TemplateEditor } from './TemplateEditor'; import { TemplateFormEditor } from './TemplateFormEditor'; import { FieldExtensionOptions } from '../../extensions'; -import { MockFileSystemAccess } from '../../lib/filesystem/MockFileSystemAccess'; type Selection = | { @@ -40,76 +39,7 @@ interface TemplateEditorPageProps { } export function TemplateEditorPage(props: TemplateEditorPageProps) { - const [selection, setSelection] = useState({ - type: 'local', - directory: MockFileSystemAccess.createMockDirectory({ - 'template.yaml': ` -apiVersion: scaffolder.backstage.io/v1beta3 -kind: Template -metadata: - name: bitbucket-demo -spec: - type: service - - parameters: - - title: Choose a name and location - required: - - name - - repoUrl - properties: - name: - title: Name - type: string - 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 }} - - id: publish - name: Publish - action: publish:bitbucket - input: - description: This is \${{ parameters.name }} - repoUrl: \${{ parameters.repoUrl }} - - - id: register - name: Register - action: catalog:register - input: - repoContentsUrl: \${{ steps.publish.output.repoContentsUrl }} - catalogInfoPath: '/catalog-info.yaml' - - output: - links: - - title: Repository - url: \${{ steps.publish.output.remoteUrl }} - - title: Open in catalog - icon: catalog - entityRef: \${{ steps.register.output.entityRef }} -`, - 'template/catalog-info.yaml': ` -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: \${{values.name | dump}} -spec: - type: website - lifecycle: experimental - owner: \${{values.owner | dump}} -`, - }), - }); + const [selection, setSelection] = useState(); let content: JSX.Element | null = null; if (selection?.type === 'local') { From dc1d67fc5b3f9619f3de278e3ceac9429e57a968 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 24 Apr 2022 18:18:43 +0200 Subject: [PATCH 12/52] scaffolder: tweak TemplateEditor error panel Signed-off-by: Patrik Oldsberg --- .../TemplateEditorPage/TemplateEditor.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx index 169a245af9..fa5de7f7ea 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx @@ -222,14 +222,15 @@ function TemplateEditorTextArea(props: { errorText?: string }) { const classes = useStyles(); const directoryEditor = useDirectoryEditor(); - const errorPanel = useMemo(() => { + const panelExtension = useMemo(() => { if (!errorText) { - return undefined; + return showPanel.of(null); } - const div = document.createElement('div'); - div.classList.add(classes.editorErrorPanel); - div.textContent = errorText; - return div; + + const dom = document.createElement('div'); + dom.classList.add(classes.editorErrorPanel); + dom.textContent = errorText; + return showPanel.of(() => ({ dom, bottom: true })); }, [classes, errorText]); useKeyboardEvent( @@ -246,12 +247,7 @@ function TemplateEditorTextArea(props: { errorText?: string }) { className={classes.editorCodeMirror} theme="dark" height="100%" - extensions={[ - StreamLanguage.define(yamlSupport), - ...(errorPanel - ? [showPanel.of(() => ({ dom: errorPanel, bottom: true }))] - : []), - ]} + extensions={[StreamLanguage.define(yamlSupport), panelExtension]} value={directoryEditor.selectedFile?.content} onChange={content => directoryEditor.selectedFile?.updateContent(content) From a9e587c76163026be7219645399d3d25420ba809 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 24 Apr 2022 18:19:10 +0200 Subject: [PATCH 13/52] scaffolder: fix template dry run results panel not expanding on first show Signed-off-by: Patrik Oldsberg --- .../TemplateEditorPage/TemplateEditorDryRunResults.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx index 756bf8421c..3e82c62aa1 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx @@ -112,15 +112,15 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({ export function TemplateEditorDryRunResults() { const classes = useStyles(); const dryRun = useDryRun(); - const [expanded, setExpanded] = useState(true); + 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) { - setExpanded(true); setHidden(false); + setExpanded(true); } else if (prevResultsLength === 1 && resultsLength === 0) { setExpanded(false); } From 84e3c67ae361b3ba7739cd496ca5ac97ffdff223 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 24 Apr 2022 18:19:41 +0200 Subject: [PATCH 14/52] scaffolder: fix width for dry run results view side pane Signed-off-by: Patrik Oldsberg --- .../TemplateEditorPage/TemplateEditorDryRunResults.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx index 3e82c62aa1..93c42b3384 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx @@ -222,7 +222,7 @@ function ResultView() { const useSplitViewStyles = makeStyles(theme => ({ root: { display: 'grid', - gridTemplateColumns: '1fr auto 3fr', + gridTemplateColumns: '280px auto 3fr', gridTemplateRows: '1fr', }, child: { From 93af1ae48eb6e25c8919c1b150c34155eecae6be Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 25 Apr 2022 23:19:06 +0200 Subject: [PATCH 15/52] scaffolder: move TemplateEditor components into subdirectory Signed-off-by: Patrik Oldsberg --- .../DirectoryEditorContext.tsx | 2 +- .../{ => TemplateEditor}/DirectoryLoader.tsx | 2 +- .../{ => TemplateEditor}/DryRunContext.tsx | 4 ++-- .../{ => TemplateEditor}/FileBrowser.test.tsx | 0 .../{ => TemplateEditor}/FileBrowser.tsx | 2 +- .../{ => TemplateEditor}/TemplateEditor.tsx | 10 +++++----- .../TemplateEditorDryRunResults.tsx | 4 ++-- .../TemplateEditorPage/TemplateEditor/index.ts | 17 +++++++++++++++++ 8 files changed, 29 insertions(+), 12 deletions(-) rename plugins/scaffolder/src/components/TemplateEditorPage/{ => TemplateEditor}/DirectoryEditorContext.tsx (99%) rename plugins/scaffolder/src/components/TemplateEditorPage/{ => TemplateEditor}/DirectoryLoader.tsx (97%) rename plugins/scaffolder/src/components/TemplateEditorPage/{ => TemplateEditor}/DryRunContext.tsx (97%) rename plugins/scaffolder/src/components/TemplateEditorPage/{ => TemplateEditor}/FileBrowser.test.tsx (100%) rename plugins/scaffolder/src/components/TemplateEditorPage/{ => TemplateEditor}/FileBrowser.tsx (99%) rename plugins/scaffolder/src/components/TemplateEditorPage/{ => TemplateEditor}/TemplateEditor.tsx (97%) rename plugins/scaffolder/src/components/TemplateEditorPage/{ => TemplateEditor}/TemplateEditorDryRunResults.tsx (98%) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/index.ts diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryEditorContext.tsx similarity index 99% rename from plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryEditorContext.tsx index 0edf390bfe..74073e797b 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryEditorContext.tsx @@ -20,7 +20,7 @@ import React, { createContext, ReactNode, useContext, useEffect } from 'react'; import { TemplateDirectoryAccess, TemplateFileAccess, -} from '../../lib/filesystem'; +} from '../../../lib/filesystem'; const MAX_SIZE = 1024 * 1024; const MAX_SIZE_MESSAGE = 'This file is too large to be displayed'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryLoader.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryLoader.tsx similarity index 97% rename from plugins/scaffolder/src/components/TemplateEditorPage/DirectoryLoader.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryLoader.tsx index 65f0c21e32..5ba68389c8 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryLoader.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryLoader.tsx @@ -20,7 +20,7 @@ import { useAsync } from '@react-hookz/web'; import { TemplateDirectoryAccess, WebFileSystemAccess, -} from '../../lib/filesystem'; +} from '../../../lib/filesystem'; interface DirectoryLoaderProps { onLoad(directory: TemplateDirectoryAccess): void; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunContext.tsx similarity index 97% rename from plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunContext.tsx index ea687095db..68cee21b77 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunContext.tsx @@ -24,8 +24,8 @@ import React, { useRef, useState, } from 'react'; -import { scaffolderApiRef } from '../../api'; -import { ScaffolderDryRunResponse } from '../../types'; +import { scaffolderApiRef } from '../../../api'; +import { ScaffolderDryRunResponse } from '../../../types'; interface DryRunOptions { templateContent: string; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.test.tsx similarity index 100% rename from plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.test.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.test.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.tsx similarity index 99% rename from plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.tsx index 705f5776c9..a815fc06f2 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/FileBrowser.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.tsx @@ -23,7 +23,7 @@ import TreeItem from '@material-ui/lab/TreeItem'; import { TemplateDirectoryAccess, TemplateFileAccess, -} from '../../lib/filesystem'; +} from '../../../lib/filesystem'; import { useAsync, useMountEffect } from '@react-hookz/web'; import { ErrorPanel, Progress } from '@backstage/core-components'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx similarity index 97% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx index fa5de7f7ea..b05fe632c0 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx @@ -39,11 +39,11 @@ import RefreshIcon from '@material-ui/icons/Refresh'; import CloseIcon from '@material-ui/icons/Close'; import CodeMirror from '@uiw/react-codemirror'; import yaml from 'yaml'; -import { FieldExtensionOptions } from '../../extensions'; -import { TemplateParameterSchema } from '../../types'; -import { MultistepJsonForm } from '../MultistepJsonForm'; -import { createValidator } from '../TemplatePage'; -import { TemplateDirectoryAccess } from '../../lib/filesystem'; +import { FieldExtensionOptions } from '../../../extensions'; +import { TemplateParameterSchema } from '../../../types'; +import { MultistepJsonForm } from '../../MultistepJsonForm'; +import { createValidator } from '../../TemplatePage'; +import { TemplateDirectoryAccess } from '../../../lib/filesystem'; import { FileBrowser } from './FileBrowser'; import { DirectoryEditorProvider, diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx similarity index 98% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx index 93c42b3384..a4a14f6c73 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorDryRunResults.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx @@ -47,8 +47,8 @@ import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; import { StreamLanguage } from '@codemirror/stream-parser'; import { LogViewer } from '@backstage/core-components'; import { usePrevious } from '@react-hookz/web'; -import { TaskStatusStepper } from '../TaskPage/TaskPage'; -import { TaskPageLinks } from '../TaskPage/TaskPageLinks'; +import { TaskStatusStepper } from '../../TaskPage/TaskPage'; +import { TaskPageLinks } from '../../TaskPage/TaskPageLinks'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import { BackstageTheme } from '@backstage/theme'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/index.ts new file mode 100644 index 0000000000..f0854db3a7 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/index.ts @@ -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 { TemplateEditor } from './TemplateEditor'; From 633afdd01bf125fb09ebe3ec26d95ae089e7254f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Apr 2022 13:45:54 +0200 Subject: [PATCH 16/52] scaffolder: fix codemirror import Signed-off-by: Patrik Oldsberg --- .../TemplateEditor/TemplateEditorDryRunResults.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx index a4a14f6c73..ca96f63a36 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx @@ -44,7 +44,7 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandLess'; import { FileBrowser } from './FileBrowser'; import CodeMirror from '@uiw/react-codemirror'; import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; -import { StreamLanguage } from '@codemirror/stream-parser'; +import { StreamLanguage } from '@codemirror/language'; import { LogViewer } from '@backstage/core-components'; import { usePrevious } from '@react-hookz/web'; import { TaskStatusStepper } from '../../TaskPage/TaskPage'; From 7e41a70dee574accd7da76955ef3776a7f4d388c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Apr 2022 14:01:50 +0200 Subject: [PATCH 17/52] scaffolder: move TemplateEditorDryRunResults to subdir Signed-off-by: Patrik Oldsberg --- .../TemplateEditorDryRunResults.tsx | 8 ++++---- .../TemplateEditor/DryRunResults/index.ts | 17 +++++++++++++++++ .../TemplateEditor/TemplateEditor.tsx | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) rename plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/{ => DryRunResults}/TemplateEditorDryRunResults.tsx (97%) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx similarity index 97% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx index ca96f63a36..84ef1fc73c 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorDryRunResults.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx @@ -36,19 +36,19 @@ import React, { useState, } from 'react'; import classNames from 'classnames'; -import { useDryRun } from './DryRunContext'; +import { useDryRun } from '../DryRunContext'; import DeleteIcon from '@material-ui/icons/Delete'; import CheckIcon from '@material-ui/icons/Check'; import CancelIcon from '@material-ui/icons/Cancel'; import ExpandMoreIcon from '@material-ui/icons/ExpandLess'; -import { FileBrowser } from './FileBrowser'; +import { FileBrowser } from '../FileBrowser'; import CodeMirror from '@uiw/react-codemirror'; import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; import { StreamLanguage } from '@codemirror/language'; import { LogViewer } from '@backstage/core-components'; import { usePrevious } from '@react-hookz/web'; -import { TaskStatusStepper } from '../../TaskPage/TaskPage'; -import { TaskPageLinks } from '../../TaskPage/TaskPageLinks'; +import { TaskStatusStepper } from '../../../TaskPage/TaskPage'; +import { TaskPageLinks } from '../../../TaskPage/TaskPageLinks'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import { BackstageTheme } from '@backstage/theme'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts new file mode 100644 index 0000000000..60ddc60026 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts @@ -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 { TemplateEditorDryRunResults } from './TemplateEditorDryRunResults'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx index b05fe632c0..7d4b44914c 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx @@ -50,7 +50,7 @@ import { useDirectoryEditor, } from './DirectoryEditorContext'; import { DryRunProvider, useDryRun } from './DryRunContext'; -import { TemplateEditorDryRunResults } from './TemplateEditorDryRunResults'; +import { TemplateEditorDryRunResults } from './DryRunResults'; const useStyles = makeStyles(theme => ({ // Reset and fix sizing to make sure scrolling behaves correctly From bb95aca5aae5f2b43ab448b231546fcfd9725144 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Apr 2022 14:03:55 +0200 Subject: [PATCH 18/52] scaffolder: extract DryRunResultsSplitView Signed-off-by: Patrik Oldsberg --- .../DryRunResults/DryRunResultsSplitView.tsx | 55 ++++++++++++++++++ .../TemplateEditorDryRunResults.tsx | 57 +++---------------- 2 files changed, 63 insertions(+), 49 deletions(-) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsSplitView.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsSplitView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsSplitView.tsx new file mode 100644 index 0000000000..b554a1c0c0 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsSplitView.tsx @@ -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 ( +
+
+ {childArray[0]} +
+ +
{childArray[1]}
+
+ ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx index 84ef1fc73c..5a429a3cc1 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx @@ -28,14 +28,7 @@ import Typography from '@material-ui/core/Typography'; import Tabs from '@material-ui/core/Tabs'; import Tab from '@material-ui/core/Tab'; import Box from '@material-ui/core/Box'; -import React, { - Children, - ReactNode, - useEffect, - useMemo, - useState, -} from 'react'; -import classNames from 'classnames'; +import React, { useEffect, useMemo, useState } from 'react'; import { useDryRun } from '../DryRunContext'; import DeleteIcon from '@material-ui/icons/Delete'; import CheckIcon from '@material-ui/icons/Check'; @@ -51,6 +44,7 @@ import { TaskStatusStepper } from '../../../TaskPage/TaskPage'; import { TaskPageLinks } from '../../../TaskPage/TaskPageLinks'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import { BackstageTheme } from '@backstage/theme'; +import { DryRunResultsSplitView } from './DryRunResultsSplitView'; const useStyles = makeStyles((theme: BackstageTheme) => ({ accordionHeader: { @@ -219,41 +213,6 @@ function ResultView() { ); } -const useSplitViewStyles = makeStyles(theme => ({ - root: { - display: 'grid', - gridTemplateColumns: '280px auto 3fr', - gridTemplateRows: '1fr', - }, - child: { - overflowY: 'auto', - height: '100%', - minHeight: 0, - }, - childPaper: { - background: theme.palette.background.paper, - }, -})); - -function SplitView(props: { children: ReactNode }) { - const classes = useSplitViewStyles(); - const childArray = Children.toArray(props.children); - - if (childArray.length !== 2) { - throw new Error('SplitView must have exactly 2 children'); - } - - return ( -
-
- {childArray[0]} -
- -
{childArray[1]}
-
- ); -} - function FilesContent() { const classes = useStyles(); const { selectedResult } = useDryRun(); @@ -278,7 +237,7 @@ function FilesContent() { return null; } return ( - + - + ); } function LogContent() { @@ -325,14 +284,14 @@ function LogContent() { const selectedStep = steps.find(s => s.id === currentStepId) ?? steps[0]; return ( - + - + ); } @@ -345,7 +304,7 @@ function OutputContent() { } return ( - + {selectedResult.output?.links?.length && ( @@ -359,6 +318,6 @@ function OutputContent() { readOnly value={JSON.stringify(selectedResult.output, null, 2)} /> - + ); } From 483aa3a47bdbf0e49f101401fa27b200e91798ab Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Apr 2022 14:05:52 +0200 Subject: [PATCH 19/52] scaffolder: extract DryRunResultsList Signed-off-by: Patrik Oldsberg --- .../DryRunResults/DryRunResultsList.tsx | 83 +++++++++++++++++++ .../TemplateEditorDryRunResults.tsx | 67 +-------------- 2 files changed, 85 insertions(+), 65 deletions(-) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsList.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsList.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsList.tsx new file mode 100644 index 0000000000..920f767cf8 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsList.tsx @@ -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 ( + + {dryRun.results.map(result => { + const failed = result.log.some(l => l.status === 'failed'); + return ( + dryRun.selectResult(result.id)} + > + + {failed ? : } + + + + dryRun.deleteResult(result.id)} + > + + + + + ); + })} + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx index 5a429a3cc1..fd49286818 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx @@ -16,11 +16,6 @@ import { makeStyles } from '@material-ui/core/styles'; import Divider from '@material-ui/core/Divider'; -import IconButton from '@material-ui/core/IconButton'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; -import ListItemText from '@material-ui/core/ListItemText'; import Accordion from '@material-ui/core/Accordion'; import AccordionSummary from '@material-ui/core/AccordionSummary'; import AccordionDetails from '@material-ui/core/AccordionDetails'; @@ -30,9 +25,6 @@ import Tab from '@material-ui/core/Tab'; import Box from '@material-ui/core/Box'; import React, { useEffect, useMemo, useState } from 'react'; import { useDryRun } from '../DryRunContext'; -import DeleteIcon from '@material-ui/icons/Delete'; -import CheckIcon from '@material-ui/icons/Check'; -import CancelIcon from '@material-ui/icons/Cancel'; import ExpandMoreIcon from '@material-ui/icons/ExpandLess'; import { FileBrowser } from '../FileBrowser'; import CodeMirror from '@uiw/react-codemirror'; @@ -42,9 +34,9 @@ import { LogViewer } from '@backstage/core-components'; import { usePrevious } from '@react-hookz/web'; import { TaskStatusStepper } from '../../../TaskPage/TaskPage'; import { TaskPageLinks } from '../../../TaskPage/TaskPageLinks'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; import { BackstageTheme } from '@backstage/theme'; import { DryRunResultsSplitView } from './DryRunResultsSplitView'; +import { DryRunResultsList } from './DryRunResultsList'; const useStyles = makeStyles((theme: BackstageTheme) => ({ accordionHeader: { @@ -63,20 +55,6 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({ padding: 0, height: 400, }, - resultList: { - overflowY: 'auto', - background: theme.palette.background.default, - }, - resultListIconSuccess: { - minWidth: 0, - marginRight: theme.spacing(1), - color: theme.palette.status.ok, - }, - resultListIconFailure: { - minWidth: 0, - marginRight: theme.spacing(1), - color: theme.palette.status.error, - }, resultView: { display: 'flex', flexFlow: 'column nowrap', @@ -137,7 +115,7 @@ export function TemplateEditorDryRunResults() { - + @@ -146,47 +124,6 @@ export function TemplateEditorDryRunResults() { ); } -function ResultList() { - const classes = useStyles(); - const dryRun = useDryRun(); - - return ( - - {dryRun.results.map(result => { - const failed = result.log.some(l => l.status === 'failed'); - return ( - dryRun.selectResult(result.id)} - > - - {failed ? : } - - - - dryRun.deleteResult(result.id)} - > - - - - - ); - })} - - ); -} - function ResultView() { const classes = useStyles(); const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>( From 5edc82d02af5cf8c9a7b186be33517f6099ca4cc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Apr 2022 14:09:01 +0200 Subject: [PATCH 20/52] scaffolder: extract DryRunResultsView + refactor DryRunResults Signed-off-by: Patrik Oldsberg --- .../DryRunResults/DryRunResults.tsx | 91 +++++++++++ ...ryRunResults.tsx => DryRunResultsView.tsx} | 151 +++++------------- .../TemplateEditor/DryRunResults/index.ts | 2 +- .../TemplateEditor/TemplateEditor.tsx | 4 +- 4 files changed, 136 insertions(+), 112 deletions(-) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResults.tsx rename plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/{TemplateEditorDryRunResults.tsx => DryRunResultsView.tsx} (67%) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResults.tsx new file mode 100644 index 0000000000..ae9374438f --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResults.tsx @@ -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 ( + <> + + + ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx similarity index 67% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx index fd49286818..1b03d80f34 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/TemplateEditorDryRunResults.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx @@ -14,56 +14,32 @@ * limitations under the License. */ -import { makeStyles } from '@material-ui/core/styles'; -import Divider from '@material-ui/core/Divider'; -import Accordion from '@material-ui/core/Accordion'; -import AccordionSummary from '@material-ui/core/AccordionSummary'; -import AccordionDetails from '@material-ui/core/AccordionDetails'; -import Typography from '@material-ui/core/Typography'; -import Tabs from '@material-ui/core/Tabs'; -import Tab from '@material-ui/core/Tab'; -import Box from '@material-ui/core/Box'; -import React, { useEffect, useMemo, useState } from 'react'; -import { useDryRun } from '../DryRunContext'; -import ExpandMoreIcon from '@material-ui/icons/ExpandLess'; -import { FileBrowser } from '../FileBrowser'; -import CodeMirror from '@uiw/react-codemirror'; -import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; -import { StreamLanguage } from '@codemirror/language'; import { LogViewer } from '@backstage/core-components'; -import { usePrevious } from '@react-hookz/web'; +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 { BackstageTheme } from '@backstage/theme'; +import { useDryRun } from '../DryRunContext'; +import { FileBrowser } from '../FileBrowser'; import { DryRunResultsSplitView } from './DryRunResultsSplitView'; -import { DryRunResultsList } from './DryRunResultsList'; -const useStyles = makeStyles((theme: BackstageTheme) => ({ - accordionHeader: { - height: 48, - minHeight: 0, - '&.Mui-expanded': { - height: 48, - minHeight: 0, - }, - }, - accordionContent: { - display: 'grid', - background: theme.palette.background.default, - gridTemplateColumns: '180px auto 1fr', - gridTemplateRows: '1fr', - padding: 0, - height: 400, - }, - resultView: { +const useStyles = makeStyles({ + root: { display: 'flex', flexFlow: 'column nowrap', }, - resultViewItemWrapper: { + contentWrapper: { flex: 1, position: 'relative', }, - resultViewItem: { + content: { position: 'absolute', top: 0, left: 0, @@ -79,76 +55,7 @@ const useStyles = makeStyles((theme: BackstageTheme) => ({ height: '100%', overflowY: 'auto', }, -})); - -export function TemplateEditorDryRunResults() { - 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 ( - <> - - - ); -} - -function ResultView() { - const classes = useStyles(); - const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>( - 'files', - ); - - return ( -
- setSelectedTab(v)}> - - - - - - -
-
- {selectedTab === 'files' && } - {selectedTab === 'log' && } - {selectedTab === 'output' && } -
-
-
- ); -} +}); function FilesContent() { const classes = useStyles(); @@ -258,3 +165,29 @@ function OutputContent() { ); } + +export function DryRunResultsView() { + const classes = useStyles(); + const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>( + 'files', + ); + + return ( +
+ setSelectedTab(v)}> + + + + + + +
+
+ {selectedTab === 'files' && } + {selectedTab === 'log' && } + {selectedTab === 'output' && } +
+
+
+ ); +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts index 60ddc60026..af8ef5214f 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { TemplateEditorDryRunResults } from './TemplateEditorDryRunResults'; +export { DryRunResults } from './DryRunResults'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx index 7d4b44914c..be3ae84d39 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx @@ -50,7 +50,7 @@ import { useDirectoryEditor, } from './DirectoryEditorContext'; import { DryRunProvider, useDryRun } from './DryRunContext'; -import { TemplateEditorDryRunResults } from './DryRunResults'; +import { DryRunResults } from './DryRunResults'; const useStyles = makeStyles(theme => ({ // Reset and fix sizing to make sure scrolling behaves correctly @@ -151,7 +151,7 @@ export const TemplateEditor = (props: { />
- +
From 5d63d15dbfe1b281d139ed8dc0e31943318989e0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Apr 2022 14:13:36 +0200 Subject: [PATCH 21/52] scaffolder: extract FileBrowser Signed-off-by: Patrik Oldsberg --- .../FileBrowser.test.tsx | 0 .../FileBrowser.tsx | 50 ++----------------- .../src/components/FileBrowser/index.ts | 17 +++++++ .../DryRunResults/DryRunResultsView.tsx | 2 +- .../TemplateEditor/TemplateEditor.tsx | 2 +- 5 files changed, 24 insertions(+), 47 deletions(-) rename plugins/scaffolder/src/components/{TemplateEditorPage/TemplateEditor => FileBrowser}/FileBrowser.test.tsx (100%) rename plugins/scaffolder/src/components/{TemplateEditorPage/TemplateEditor => FileBrowser}/FileBrowser.tsx (76%) create mode 100644 plugins/scaffolder/src/components/FileBrowser/index.ts diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.test.tsx b/plugins/scaffolder/src/components/FileBrowser/FileBrowser.test.tsx similarity index 100% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.test.tsx rename to plugins/scaffolder/src/components/FileBrowser/FileBrowser.test.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.tsx b/plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx similarity index 76% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.tsx rename to plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx index a815fc06f2..1273b8ad09 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/FileBrowser.tsx +++ b/plugins/scaffolder/src/components/FileBrowser/FileBrowser.tsx @@ -20,12 +20,6 @@ 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'; -import { - TemplateDirectoryAccess, - TemplateFileAccess, -} from '../../../lib/filesystem'; -import { useAsync, useMountEffect } from '@react-hookz/web'; -import { ErrorPanel, Progress } from '@backstage/core-components'; const useStyles = makeStyles({ root: { @@ -114,11 +108,15 @@ function FileTreeItem({ entry }: { entry: FileEntry }) { } interface FileBrowserProps { - selected?: string; + /** 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(); @@ -145,41 +143,3 @@ export function FileBrowser(props: FileBrowserProps) { ); } - -interface TemplateDirectoryAccessBrowserProps { - directory: TemplateDirectoryAccess; - onSelect?(file: TemplateFileAccess): void; -} - -function TemplateDirectoryAccessBrowser( - props: TemplateDirectoryAccessBrowserProps, -) { - const [state, { execute }] = useAsync(async () => { - const files = await props.directory.listFiles(); - return { - filePaths: files.map(file => file.path), - getFile: (path: string) => files.find(file => file.path === path), - }; - }); - - useMountEffect(execute); - - if (state.error) { - return ; - } else if (!state.result) { - return ; - } - - const handleSelect = (path: string) => { - const file = state.result?.getFile(path); - if (file) { - props.onSelect?.(file); - } - }; - - return ( - - ); -} - -FileBrowser.TemplateDirectoryAccess = TemplateDirectoryAccessBrowser; diff --git a/plugins/scaffolder/src/components/FileBrowser/index.ts b/plugins/scaffolder/src/components/FileBrowser/index.ts new file mode 100644 index 0000000000..5d18109a9c --- /dev/null +++ b/plugins/scaffolder/src/components/FileBrowser/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx index 1b03d80f34..9e0f613d91 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx @@ -27,7 +27,7 @@ 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 { FileBrowser } from '../../../FileBrowser'; import { DryRunResultsSplitView } from './DryRunResultsSplitView'; const useStyles = makeStyles({ diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx index be3ae84d39..e1900357ad 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx @@ -44,7 +44,7 @@ import { TemplateParameterSchema } from '../../../types'; import { MultistepJsonForm } from '../../MultistepJsonForm'; import { createValidator } from '../../TemplatePage'; import { TemplateDirectoryAccess } from '../../../lib/filesystem'; -import { FileBrowser } from './FileBrowser'; +import { FileBrowser } from '../../FileBrowser'; import { DirectoryEditorProvider, useDirectoryEditor, From 48307fbf0d834da86df8dd64b4606222cca126c3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Apr 2022 14:14:55 +0200 Subject: [PATCH 22/52] scaffolder: remove unused DirectoryLoader Signed-off-by: Patrik Oldsberg --- .../TemplateEditor/DirectoryLoader.tsx | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryLoader.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryLoader.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryLoader.tsx deleted file mode 100644 index 5ba68389c8..0000000000 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryLoader.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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 Button from '@material-ui/core/Button'; -import React from 'react'; -import { useAsync } from '@react-hookz/web'; -import { - TemplateDirectoryAccess, - WebFileSystemAccess, -} from '../../../lib/filesystem'; - -interface DirectoryLoaderProps { - onLoad(directory: TemplateDirectoryAccess): void; -} - -/** @internal */ -export function DirectoryLoader(props: DirectoryLoaderProps): JSX.Element { - const supportsWebAccess = WebFileSystemAccess.isSupported(); - - const [{ status, error }, { execute }] = useAsync(async () => { - if (!supportsWebAccess) { - return; - } - const directory = await WebFileSystemAccess.get().requestDirectoryAccess(); - props.onLoad(directory); - }); - - if (error) { - return
Fail: {error.message}
; - } - - return ( - - ); -} From a788dacc7372d302150f686d57c946f6240ba9c0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Apr 2022 14:55:57 +0200 Subject: [PATCH 23/52] scaffolder: extract TemplateEditorTextArea Signed-off-by: Patrik Oldsberg --- .../TemplateEditor/TemplateEditor.tsx | 110 ++----------- .../TemplateEditor/TemplateEditorTextArea.tsx | 151 ++++++++++++++++++ 2 files changed, 161 insertions(+), 100 deletions(-) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx index e1900357ad..4e1b26441b 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx @@ -13,6 +13,12 @@ * 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 { 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, { Component, ReactNode, @@ -20,37 +26,21 @@ import React, { useReducer, useState, } from 'react'; -import { useKeyboardEvent } from '@react-hookz/web'; import useDebounce from 'react-use/lib/useDebounce'; -import { useApiHolder } from '@backstage/core-plugin-api'; -import { JsonObject, JsonValue } from '@backstage/types'; -import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; -import { showPanel } from '@codemirror/view'; -import { StreamLanguage } from '@codemirror/language'; -import { - IconButton, - makeStyles, - Divider, - Tooltip, - Paper, -} from '@material-ui/core'; -import SaveIcon from '@material-ui/icons/Save'; -import RefreshIcon from '@material-ui/icons/Refresh'; -import CloseIcon from '@material-ui/icons/Close'; -import CodeMirror from '@uiw/react-codemirror'; import yaml from 'yaml'; import { FieldExtensionOptions } from '../../../extensions'; +import { TemplateDirectoryAccess } from '../../../lib/filesystem'; import { TemplateParameterSchema } from '../../../types'; +import { FileBrowser } from '../../FileBrowser'; import { MultistepJsonForm } from '../../MultistepJsonForm'; import { createValidator } from '../../TemplatePage'; -import { TemplateDirectoryAccess } from '../../../lib/filesystem'; -import { FileBrowser } from '../../FileBrowser'; import { DirectoryEditorProvider, useDirectoryEditor, } from './DirectoryEditorContext'; import { DryRunProvider, useDryRun } from './DryRunContext'; import { DryRunResults } from './DryRunResults'; +import { TemplateEditorTextArea } from './TemplateEditorTextArea'; const useStyles = makeStyles(theme => ({ // Reset and fix sizing to make sure scrolling behaves correctly @@ -94,27 +84,9 @@ const useStyles = makeStyles(theme => ({ marginBottom: theme.spacing(1), }, editor: { - position: 'relative', gridArea: 'editor', overflow: 'auto', }, - editorCodeMirror: { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, - }, - editorErrorPanel: { - color: theme.palette.error.main, - lineHeight: 2, - margin: theme.spacing(0, 1), - }, - editorFloatingButtons: { - position: 'absolute', - top: theme.spacing(1), - right: theme.spacing(3), - }, preview: { gridArea: 'preview', overflow: 'auto', @@ -142,7 +114,7 @@ export const TemplateEditor = (props: {
- +
void }) { ); } -function TemplateEditorTextArea(props: { errorText?: string }) { - const { errorText } = props; - const classes = useStyles(); - const directoryEditor = useDirectoryEditor(); - - const panelExtension = useMemo(() => { - if (!errorText) { - return showPanel.of(null); - } - - const dom = document.createElement('div'); - dom.classList.add(classes.editorErrorPanel); - dom.textContent = errorText; - return showPanel.of(() => ({ dom, bottom: true })); - }, [classes, errorText]); - - useKeyboardEvent( - e => e.key === 's' && (e.ctrlKey || e.metaKey), - e => { - e.preventDefault(); - directoryEditor.save(); - }, - ); - - return ( - <> - - directoryEditor.selectedFile?.updateContent(content) - } - /> - {directoryEditor.selectedFile?.dirty && ( -
- - - directoryEditor.save()} - > - - - - - directoryEditor.reload()} - > - - - - -
- )} - - ); -} - interface ErrorBoundaryProps { generation: number; setErrorText(errorText: string | undefined): void; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx new file mode 100644 index 0000000000..3700348ef4 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx @@ -0,0 +1,151 @@ +/* + * 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 ( +
+ + {(props.onSave || props.onReload) && ( +
+ + {props.onSave && ( + + props.onSave?.()} + > + + + + )} + {props.onReload && ( + + props.onReload?.()} + > + + + + )} + +
+ )} +
+ ); +} + +/** 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(), + } + : undefined; + + return ( + directoryEditor.selectedFile?.updateContent(content)} + {...actions} + /> + ); +} + +TemplateEditorTextArea.DirectoryEditor = TemplateEditorDirectoryEditorTextArea; From 5146bc96fc5547f4ae9b5c9524d9ca1448c772be Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 11:16:17 +0200 Subject: [PATCH 24/52] scaffolder: extract TemplateEditorForm Signed-off-by: Patrik Oldsberg --- .../TemplateEditor/TemplateEditor.tsx | 229 +----------------- .../TemplateEditor/TemplateEditorForm.tsx | 218 +++++++++++++++++ 2 files changed, 222 insertions(+), 225 deletions(-) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx index 4e1b26441b..f6b936e8af 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx @@ -13,33 +13,21 @@ * 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 { 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, { - Component, - ReactNode, - useMemo, - useReducer, - useState, -} from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; -import yaml from 'yaml'; +import React, { useState } from 'react'; import { FieldExtensionOptions } from '../../../extensions'; import { TemplateDirectoryAccess } from '../../../lib/filesystem'; -import { TemplateParameterSchema } from '../../../types'; import { FileBrowser } from '../../FileBrowser'; -import { MultistepJsonForm } from '../../MultistepJsonForm'; -import { createValidator } from '../../TemplatePage'; import { DirectoryEditorProvider, useDirectoryEditor, } from './DirectoryEditorContext'; -import { DryRunProvider, useDryRun } from './DryRunContext'; +import { DryRunProvider } from './DryRunContext'; import { DryRunResults } from './DryRunResults'; +import { TemplateEditorForm } from './TemplateEditorForm'; import { TemplateEditorTextArea } from './TemplateEditorTextArea'; const useStyles = makeStyles(theme => ({ @@ -117,7 +105,7 @@ export const TemplateEditor = (props: {
- @@ -188,212 +176,3 @@ function TemplateEditorBrowser(props: { onClose?: () => void }) { ); } - -interface ErrorBoundaryProps { - generation: number; - setErrorText(errorText: string | undefined): void; - children: ReactNode; -} - -interface ErrorBoundaryState { - shouldRender: boolean; -} - -class ErrorBoundary extends Component { - state = { - shouldRender: true, - }; - - componentDidUpdate(prevProps: { generation: number }) { - if (prevProps.generation !== this.props.generation) { - 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 TemplateFormState { - filePath?: string; - content?: string; - steps?: TemplateParameterSchema['steps']; - formData: JsonObject; - schemaGeneration: number; -} - -type TemplateFormAction = - | { - type: 'reset'; - } - | { - type: 'updateData'; - formData: JsonObject; - } - | { - type: 'updateSchema'; - steps: TemplateParameterSchema['steps']; - filePath: string; - }; - -const initialTemplateFormState: TemplateFormState = { - steps: undefined, - filePath: undefined, - formData: {}, - // Used to reset the error boundary in edit - schemaGeneration: 0, -}; - -function templateFormReducer( - state: TemplateFormState, - action: TemplateFormAction, -): TemplateFormState { - switch (action.type) { - case 'reset': { - return initialTemplateFormState; - } - case 'updateData': { - return { - ...state, - formData: action.formData, - }; - } - case 'updateSchema': { - const { filePath, steps } = action; - - return { - steps, - filePath, - formData: state.filePath === filePath ? state.formData : {}, - schemaGeneration: state.schemaGeneration + 1, - }; - } - default: - return state; - } -} - -interface TemplateEditorFormProps { - setErrorText: (errorText?: string) => void; - fieldExtensions?: FieldExtensionOptions[]; -} - -function isJsonObject(value: JsonValue | undefined): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function TemplateEditorForm(props: TemplateEditorFormProps) { - const { setErrorText, fieldExtensions = [] } = props; - const dryRun = useDryRun(); - - const apiHolder = useApiHolder(); - const directoryEditor = useDirectoryEditor(); - const { selectedFile } = directoryEditor; - const [state, dispatch] = useReducer( - templateFormReducer, - initialTemplateFormState, - ); - - useDebounce( - () => { - try { - if (!selectedFile || !selectedFile.path.match(/\.ya?ml$/)) { - dispatch({ type: 'reset' }); - return; - } - const parsed: JsonValue = yaml.parse(selectedFile.content); - const isTemplate = - typeof parsed === 'object' && - parsed !== null && - 'kind' in parsed && - typeof parsed.kind === 'string' && - parsed.kind.toLocaleLowerCase('en-US') === 'template'; - - if (!isTemplate) { - dispatch({ type: 'reset' }); - return; - } - - const spec = parsed.spec; - const parameters = isJsonObject(spec) && spec.parameters; - if (!Array.isArray(parameters)) { - setErrorText('Template parameters must be an array'); - return; - } - - const fieldValidators = Object.fromEntries( - fieldExtensions.map(({ name, validation }) => [name, validation]), - ); - - setErrorText(); - - dispatch({ - type: 'updateSchema', - filePath: selectedFile.path, - steps: parameters.flatMap(param => - isJsonObject(param) - ? [ - { - title: String(param.title), - schema: param, - validate: createValidator(param, fieldValidators, { - apiHolder, - }), - }, - ] - : [], - ), - }); - } catch (e) { - setErrorText(e.message); - } - }, - 250, - [selectedFile?.path, selectedFile?.content, apiHolder], - ); - - const fields = useMemo(() => { - return Object.fromEntries( - fieldExtensions.map(({ name, component }) => [name, component]), - ); - }, [fieldExtensions]); - - if (!state.steps) { - return null; - } - - const handleDryRun = async () => { - if (!selectedFile) { - return; - } - - await dryRun.execute({ - templateContent: selectedFile.content, - values: state.formData, - files: directoryEditor.files, - }); - }; - - return ( - - dispatch({ type: 'updateData', formData: e.formData })} - onReset={() => dispatch({ type: 'updateData', formData: {} })} - finishButtonLabel="Try It" - onFinish={handleDryRun} - /> - - ); -} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx new file mode 100644 index 0000000000..a9e0182317 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx @@ -0,0 +1,218 @@ +/* + * 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 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'; + +interface ErrorBoundaryProps { + invalidator: unknown; + setErrorText(errorText: string | undefined): void; + children: ReactNode; +} + +interface ErrorBoundaryState { + shouldRender: boolean; +} + +class ErrorBoundary extends Component { + 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; + fieldExtensions?: FieldExtensionOptions[]; +} + +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 apiHolder = useApiHolder(); + + const [steps, setSteps] = useState(); + + 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 ( + + onUpdate(e.formData)} + onReset={() => onUpdate({})} + finishButtonLabel={onDryRun && 'Try It'} + onFinish={onDryRun && (() => onDryRun(data))} + /> + + ); +} + +/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */ +export function TemplateEditorFormDirectoryEditorDryRun( + props: Pick, +) { + const { setErrorText, fieldExtensions = [] } = props; + const dryRun = useDryRun(); + + const directoryEditor = useDirectoryEditor(); + const { selectedFile } = directoryEditor; + + const [data, setData] = useState({}); + + 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.DirectoryEditorDryRun = + TemplateEditorFormDirectoryEditorDryRun; From 6758e83662eccec31be9ebc26f0dbdd9862ae069 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 11:19:16 +0200 Subject: [PATCH 25/52] scaffolder: extract TemplateEditorBrowser Signed-off-by: Patrik Oldsberg --- .../TemplateEditor/TemplateEditor.tsx | 88 +---------------- .../TemplateEditor/TemplateEditorBrowser.tsx | 98 +++++++++++++++++++ 2 files changed, 103 insertions(+), 83 deletions(-) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorBrowser.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx index f6b936e8af..bd12efa9b8 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx @@ -13,24 +13,18 @@ * 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 { makeStyles } from '@material-ui/core'; import React, { useState } from 'react'; import { FieldExtensionOptions } from '../../../extensions'; import { TemplateDirectoryAccess } from '../../../lib/filesystem'; -import { FileBrowser } from '../../FileBrowser'; -import { - DirectoryEditorProvider, - useDirectoryEditor, -} from './DirectoryEditorContext'; +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(theme => ({ +const useStyles = makeStyles({ // Reset and fix sizing to make sure scrolling behaves correctly rootWrapper: { gridArea: 'pageContent', @@ -56,21 +50,6 @@ const useStyles = makeStyles(theme => ({ gridArea: 'browser', overflow: 'auto', }, - browserButton: { - padding: theme.spacing(1), - }, - browserButtons: { - display: 'flex', - flexFlow: 'row nowrap', - alignItems: 'center', - justifyContent: 'flex-start', - }, - browserButtonsGap: { - flex: '1 1 auto', - }, - browserButtonsDivider: { - marginBottom: theme.spacing(1), - }, editor: { gridArea: 'editor', overflow: 'auto', @@ -82,7 +61,7 @@ const useStyles = makeStyles(theme => ({ results: { gridArea: 'results', }, -})); +}); export const TemplateEditor = (props: { directory: TemplateDirectoryAccess; @@ -119,60 +98,3 @@ export const TemplateEditor = (props: { ); }; - -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 ( - <> -
- - directoryEditor.save()} - > - - - - - directoryEditor.reload()} - > - - - -
- - - - - -
- - file.path)} - /> - - ); -} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorBrowser.tsx new file mode 100644 index 0000000000..e315319a52 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorBrowser.tsx @@ -0,0 +1,98 @@ +/* + * 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 ( + <> +
+ + directoryEditor.save()} + > + + + + + directoryEditor.reload()} + > + + + +
+ + + + + +
+ + file.path)} + /> + + ); +} From 3c87f60481abbb0e2559d3233a5387db4c3f35dc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 12:14:29 +0200 Subject: [PATCH 26/52] scaffolder: tweak names of TemplateEditor components Signed-off-by: Patrik Oldsberg --- .../{EditorIntro.tsx => TemplateEditorIntro.tsx} | 4 ++-- .../components/TemplateEditorPage/TemplateEditorPage.tsx | 8 ++++---- .../{TemplateFormEditor.tsx => TemplateFormPreviewer.tsx} | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) rename plugins/scaffolder/src/components/TemplateEditorPage/{EditorIntro.tsx => TemplateEditorIntro.tsx} (98%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateFormEditor.tsx => TemplateFormPreviewer.tsx} (99%) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/EditorIntro.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx similarity index 98% rename from plugins/scaffolder/src/components/TemplateEditorPage/EditorIntro.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx index 1c07a78c9e..dc411ee965 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/EditorIntro.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx @@ -47,7 +47,7 @@ interface EditorIntroProps { onSelect?: (option: 'local' | 'form') => void; } -export const EditorIntro = (props: EditorIntroProps) => { +export function TemplateEditorIntro(props: EditorIntroProps) { const classes = useStyles(); const supportsLoad = WebFileSystemAccess.isSupported(); @@ -124,4 +124,4 @@ export const EditorIntro = (props: EditorIntroProps) => {
); -}; +} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index f2f603337d..3741a6fc3d 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -19,9 +19,9 @@ import { TemplateDirectoryAccess, WebFileSystemAccess, } from '../../lib/filesystem'; -import { EditorIntro } from './EditorIntro'; +import { TemplateEditorIntro } from './TemplateEditorIntro'; import { TemplateEditor } from './TemplateEditor'; -import { TemplateFormEditor } from './TemplateFormEditor'; +import { TemplateFormPreviewer } from './TemplateFormPreviewer'; import { FieldExtensionOptions } from '../../extensions'; type Selection = @@ -52,7 +52,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { ); } else if (selection?.type === 'form') { content = ( - setSelection(undefined)} @@ -61,7 +61,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { } else { content = ( - { if (option === 'local') { WebFileSystemAccess.get() diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx similarity index 99% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormEditor.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx index 43c7f10a32..bff515d13b 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -105,7 +105,7 @@ const useStyles = makeStyles(theme => ({ }, })); -export const TemplateFormEditor = ({ +export const TemplateFormPreviewer = ({ defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML, customFieldExtensions = [], onClose, From c98cfa0e6c1061f265e644d59d37f632f0bde4b0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 14:35:13 +0200 Subject: [PATCH 27/52] scaffolder: refactor TemplateFormPreviewer to use common components Signed-off-by: Patrik Oldsberg --- .../TemplateFormPreviewer.tsx | 190 ++++++------------ 1 file changed, 62 insertions(+), 128 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx index bff515d13b..d59362a1cc 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -13,23 +13,14 @@ * 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 { InfoCard } from '@backstage/core-components'; -import { alertApiRef, useApi, useApiHolder } from '@backstage/core-plugin-api'; +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; 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, IconButton, InputLabel, LinearProgress, @@ -38,13 +29,12 @@ import { Select, } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; -import { IChangeEvent } from '@rjsf/core'; -import CodeMirror from '@uiw/react-codemirror'; +import React, { useCallback, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; import yaml from 'yaml'; import { FieldExtensionOptions } from '../../extensions'; -import { TemplateParameterSchema } from '../../types'; -import { MultistepJsonForm } from '../MultistepJsonForm'; -import { createValidator } from '../TemplatePage'; +import { TemplateEditorForm } from './TemplateEditor/TemplateEditorForm'; +import { TemplateEditorTextArea } from './TemplateEditor/TemplateEditorTextArea'; const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI parameters: @@ -91,17 +81,28 @@ type TemplateOption = { }; 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', - marginBottom: theme.spacing(1), + margin: theme.spacing(1), }, - grid: { - height: '100%', + textArea: { + gridArea: 'textArea', }, - codeMirror: { - height: '95%', + preview: { + gridArea: 'preview', }, })); @@ -117,12 +118,8 @@ export const TemplateFormPreviewer = ({ const classes = useStyles(); const alertApi = useApi(alertApiRef); const catalogApi = useApi(catalogApiRef); - const apiHolder = useApiHolder(); const [selectedTemplate, setSelectedTemplate] = useState(''); - const [schema, setSchema] = useState({ - title: '', - steps: [], - }); + const [errorText, setErrorText] = useState(); const [templateOptions, setTemplateOptions] = useState([]); const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate); const [formState, setFormState] = useState({}); @@ -161,30 +158,6 @@ export const TemplateFormPreviewer = ({ [catalogApi], ); - 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); @@ -193,90 +166,51 @@ export const TemplateFormPreviewer = ({ [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]), - ); - return ( <> {loading && } - - -
- - - Load Existing Template - - - +
+
+ + + Load Existing Template + + + - - - -
- ({ dom: errorPanel, top: true })), - ]} - onChange={handleCodeChange} + + + +
+
+ - - - {schema && ( - - { - return { - ...step, - validate: createValidator( - step.schema, - customFieldValidators, - { apiHolder }, - ), - }; - })} - /> - - )} - - +
+
+ +
+ ); }; From f7805dfea86f7af65fab15b0e22c2affca6a3168 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 14:38:46 +0200 Subject: [PATCH 28/52] scaffolder: add wrapper to TemplateEditorForm to handle overflow Signed-off-by: Patrik Oldsberg --- .../TemplateEditor/TemplateEditor.tsx | 47 +++++++------------ .../TemplateEditor/TemplateEditorForm.tsx | 44 ++++++++++++----- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx index bd12efa9b8..baf8ebf3f9 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx @@ -26,17 +26,8 @@ import { TemplateEditorTextArea } from './TemplateEditorTextArea'; const useStyles = makeStyles({ // Reset and fix sizing to make sure scrolling behaves correctly - rootWrapper: { - gridArea: 'pageContent', - position: 'relative', - width: '100%', - }, root: { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, + gridArea: 'pageContent', display: 'grid', gridTemplateAreas: ` @@ -75,25 +66,23 @@ export const TemplateEditor = (props: { return ( -
-
-
- -
-
- -
-
- -
-
- -
-
-
+
+
+ +
+
+ +
+
+ +
+
+ +
+
); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx index a9e0182317..2e675bce0a 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx @@ -15,6 +15,7 @@ */ 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'; @@ -25,6 +26,22 @@ 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; @@ -83,6 +100,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { setErrorText, fieldExtensions = [], } = props; + const classes = useStyles(); const apiHolder = useApiHolder(); const [steps, setSteps] = useState(); @@ -159,17 +177,21 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { } return ( - - onUpdate(e.formData)} - onReset={() => onUpdate({})} - finishButtonLabel={onDryRun && 'Try It'} - onFinish={onDryRun && (() => onDryRun(data))} - /> - +
+
+ + onUpdate(e.formData)} + onReset={() => onUpdate({})} + finishButtonLabel={onDryRun && 'Try It'} + onFinish={onDryRun && (() => onDryRun(data))} + /> + +
+
); } From 289d3491991660c8b15a287e5bf8efffcee155fa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 14:40:22 +0200 Subject: [PATCH 29/52] scaffolder: always show file reload in TemplateEditorTextArea Signed-off-by: Patrik Oldsberg --- .../TemplateEditor/TemplateEditorTextArea.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx index 3700348ef4..588b0cd4ba 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx @@ -136,7 +136,9 @@ export function TemplateEditorDirectoryEditorTextArea(props: { onSave: () => directoryEditor.save(), onReload: () => directoryEditor.reload(), } - : undefined; + : { + onReload: () => directoryEditor.reload(), + }; return ( Date: Fri, 29 Apr 2022 14:43:58 +0200 Subject: [PATCH 30/52] scaffolder: flatten TemplateEditorPage structure Signed-off-by: Patrik Oldsberg --- .../DirectoryEditorContext.tsx | 2 +- .../{TemplateEditor => }/DryRunContext.tsx | 4 ++-- .../DryRunResults/DryRunResults.tsx | 0 .../DryRunResults/DryRunResultsList.tsx | 0 .../DryRunResults/DryRunResultsSplitView.tsx | 0 .../DryRunResults/DryRunResultsView.tsx | 6 +++--- .../{TemplateEditor => }/DryRunResults/index.ts | 0 .../{TemplateEditor => }/TemplateEditor.tsx | 4 ++-- .../TemplateEditorPage/TemplateEditor/index.ts | 17 ----------------- .../TemplateEditorBrowser.tsx | 2 +- .../{TemplateEditor => }/TemplateEditorForm.tsx | 8 ++++---- .../TemplateEditorTextArea.tsx | 0 .../TemplateFormPreviewer.tsx | 4 ++-- 13 files changed, 15 insertions(+), 32 deletions(-) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/DirectoryEditorContext.tsx (99%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/DryRunContext.tsx (97%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/DryRunResults/DryRunResults.tsx (100%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/DryRunResults/DryRunResultsList.tsx (100%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/DryRunResults/DryRunResultsSplitView.tsx (100%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/DryRunResults/DryRunResultsView.tsx (96%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/DryRunResults/index.ts (100%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/TemplateEditor.tsx (95%) delete mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/index.ts rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/TemplateEditorBrowser.tsx (98%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/TemplateEditorForm.tsx (96%) rename plugins/scaffolder/src/components/TemplateEditorPage/{TemplateEditor => }/TemplateEditorTextArea.tsx (100%) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryEditorContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx similarity index 99% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryEditorContext.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx index 74073e797b..0edf390bfe 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DirectoryEditorContext.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DirectoryEditorContext.tsx @@ -20,7 +20,7 @@ import React, { createContext, ReactNode, useContext, useEffect } from 'react'; import { TemplateDirectoryAccess, TemplateFileAccess, -} from '../../../lib/filesystem'; +} from '../../lib/filesystem'; const MAX_SIZE = 1024 * 1024; const MAX_SIZE_MESSAGE = 'This file is too large to be displayed'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx similarity index 97% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunContext.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx index 68cee21b77..ea687095db 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunContext.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx @@ -24,8 +24,8 @@ import React, { useRef, useState, } from 'react'; -import { scaffolderApiRef } from '../../../api'; -import { ScaffolderDryRunResponse } from '../../../types'; +import { scaffolderApiRef } from '../../api'; +import { ScaffolderDryRunResponse } from '../../types'; interface DryRunOptions { templateContent: string; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResults.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.tsx similarity index 100% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResults.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsList.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx similarity index 100% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsList.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsSplitView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx similarity index 100% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsSplitView.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx similarity index 96% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx index 9e0f613d91..f854f504ce 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/DryRunResultsView.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx @@ -24,10 +24,10 @@ 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 { TaskStatusStepper } from '../../TaskPage/TaskPage'; +import { TaskPageLinks } from '../../TaskPage/TaskPageLinks'; import { useDryRun } from '../DryRunContext'; -import { FileBrowser } from '../../../FileBrowser'; +import { FileBrowser } from '../../FileBrowser'; import { DryRunResultsSplitView } from './DryRunResultsSplitView'; const useStyles = makeStyles({ diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/index.ts similarity index 100% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/DryRunResults/index.ts rename to plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/index.ts diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx similarity index 95% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx index baf8ebf3f9..cc4dd8c9a1 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx @@ -15,8 +15,8 @@ */ import { makeStyles } from '@material-ui/core'; import React, { useState } from 'react'; -import { FieldExtensionOptions } from '../../../extensions'; -import { TemplateDirectoryAccess } from '../../../lib/filesystem'; +import { FieldExtensionOptions } from '../../extensions'; +import { TemplateDirectoryAccess } from '../../lib/filesystem'; import { DirectoryEditorProvider } from './DirectoryEditorContext'; import { DryRunProvider } from './DryRunContext'; import { DryRunResults } from './DryRunResults'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/index.ts b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/index.ts deleted file mode 100644 index f0854db3a7..0000000000 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { TemplateEditor } from './TemplateEditor'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx similarity index 98% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorBrowser.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx index e315319a52..9402047327 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorBrowser.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx @@ -18,7 +18,7 @@ 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 { FileBrowser } from '../FileBrowser'; import { useDirectoryEditor } from './DirectoryEditorContext'; const useStyles = makeStyles(theme => ({ diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx similarity index 96% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx index 2e675bce0a..11b4da246d 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx @@ -19,10 +19,10 @@ 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 { FieldExtensionOptions } from '../../extensions'; +import { TemplateParameterSchema } from '../../types'; +import { MultistepJsonForm } from '../MultistepJsonForm'; +import { createValidator } from '../TemplatePage'; import { useDirectoryEditor } from './DirectoryEditorContext'; import { useDryRun } from './DryRunContext'; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorTextArea.tsx similarity index 100% rename from plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorTextArea.tsx rename to plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorTextArea.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx index d59362a1cc..bfa7071d48 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -33,8 +33,8 @@ import React, { useCallback, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import yaml from 'yaml'; import { FieldExtensionOptions } from '../../extensions'; -import { TemplateEditorForm } from './TemplateEditor/TemplateEditorForm'; -import { TemplateEditorTextArea } from './TemplateEditor/TemplateEditorTextArea'; +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: From 05b6044399f22304b4dacabfd7b1d8a2bb0d143f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 15:35:20 +0200 Subject: [PATCH 31/52] scaffolder: memo DryRunContext value Signed-off-by: Patrik Oldsberg --- .../TemplateEditorPage/DryRunContext.tsx | 116 +++++++++--------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx index ea687095db..c3032d7e55 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx @@ -21,6 +21,7 @@ import React, { createContext, ReactNode, useContext, + useMemo, useRef, useState, } from 'react'; @@ -63,65 +64,68 @@ export function DryRunProvider(props: DryRunProviderProps) { }); const idRef = useRef(1); - const dryRun = { - ...state, - selectResult: (id: number) => { - setState(prevState => { - const result = prevState.results.find(r => r.id === id); - if (result === prevState.selectedResult) { - return prevState; + const dryRun = useMemo( + () => ({ + ...state, + selectResult: (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, + }; + }); + }, + deleteResult: (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, + }; + }); + }, + execute: async (options: DryRunOptions) => { + if (!scaffolderApi.dryRun) { + throw new Error('Scaffolder API does not support dry-run'); } - return { - results: prevState.results, - selectedResult: result, + + const parsed = yaml.parse(options.templateContent); + + const response = await scaffolderApi.dryRun({ + template: parsed, + values: options.values, + secrets: {}, + content: options.files.map(file => ({ + path: file.path, + base64Content: btoa(file.content), + })), + }); + + const result = { + ...response, + id: idRef.current++, }; - }); - }, - deleteResult: (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, - }; - }); - }, - execute: 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: {}, - content: 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, - })); - }, - }; + setState(prevState => ({ + results: [...prevState.results, result], + selectedResult: prevState.selectedResult ?? result, + })); + }, + }), + [scaffolderApi, state], + ); return ( From 88b81c3e6f89c2644d033142d1b3093e111f7907 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 15:35:35 +0200 Subject: [PATCH 32/52] scaffolder: add tests for DryRunResults Signed-off-by: Patrik Oldsberg --- .../DryRunResults/DryRunResults.test.tsx | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx new file mode 100644 index 0000000000..7b08544736 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 () => ({ content: [], log: [], output: {}, steps: [] }), +}; + +describe('DryRunResults', () => { + it('renders without exploding', async () => { + await renderInTestApp( + + + + + , + ); + 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( + + + + + + , + ); + + expect(screen.getByText('Files')).not.toBeVisible(); + + await act(async () => { + rerender( + + + + + + , + ); + }); + + 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( + + + + + + , + ); + }); + + await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible()); + }); +}); From 4b839c4f02890465e05e60437f6866f29000daf9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 16:05:31 +0200 Subject: [PATCH 33/52] scaffolder: add DryRunResultsList tests Signed-off-by: Patrik Oldsberg --- .../DryRunResults/DryRunResultsList.test.tsx | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx new file mode 100644 index 0000000000..ca35f71e95 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 () => ({ content: [], log: [], output: {}, steps: [] }), +}; + +describe('DryRunResultsList', () => { + it('renders without exploding', async () => { + const rendered = await renderInTestApp( + + + + + , + ); + expect(rendered.baseElement.querySelector('ul')).toBeEmptyDOMElement(); + }); + + it('adds new result items and deletes them', async () => { + const { rerender } = await renderInTestApp( + + + + + + , + ); + + expect(screen.queryByText('Result 1')).toBeInTheDocument(); + expect(screen.queryByText('Result 2')).not.toBeInTheDocument(); + + await act(async () => { + rerender( + + + + + + , + ); + }); + + 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(); + }); +}); From e48ca034e961096a4c8482d4abb584e6f596af4e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 16:12:15 +0200 Subject: [PATCH 34/52] scaffolder: memo methods in DryRunProvider Signed-off-by: Patrik Oldsberg --- .../TemplateEditorPage/DryRunContext.tsx | 124 ++++++++++-------- 1 file changed, 67 insertions(+), 57 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx index c3032d7e55..97112f75df 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx @@ -20,6 +20,7 @@ import { JsonObject } from '@backstage/types'; import React, { createContext, ReactNode, + useCallback, useContext, useMemo, useRef, @@ -64,67 +65,76 @@ export function DryRunProvider(props: DryRunProviderProps) { }); 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: {}, + content: 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: (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, - }; - }); - }, - deleteResult: (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, - }; - }); - }, - execute: 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: {}, - content: 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, - })); - }, + selectResult, + deleteResult, + execute, }), - [scaffolderApi, state], + [state, selectResult, deleteResult, execute], ); return ( From 71228274aef7ce7c0607ed577105ecf6aa010218 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 16:22:39 +0200 Subject: [PATCH 35/52] scaffolder: add test for DryRunResultsView Signed-off-by: Patrik Oldsberg --- .../DryRunResults/DryRunResultsView.test.tsx | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx new file mode 100644 index 0000000000..3f416dcceb --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 inside 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 })}, +})); + +// 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; +}; + +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( + ({ + content: [ + { + path: 'foo.txt', + base64Content: btoa('Foo Content'), + executable: false, + }, + ], + log: [{ message: 'Foo Message', stepId: 'foo' }], + output: { + links: [{ title: 'Foo Link', url: 'http://example.com' }], + }, + steps: [{ id: 'foo', action: 'foo', name: 'Foo' }], + }), + }, + ], + ]} + > + + + + + , + { + 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(); + }); +}); From 01db4019e0c2bd935b7c49cf9c633281d9bb3582 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 16:32:21 +0200 Subject: [PATCH 36/52] scaffolder: add test for TemplateEditorPage Signed-off-by: Patrik Oldsberg --- .../DryRunResults/DryRunResultsView.test.tsx | 13 ----- .../TemplateEditorPage.test.tsx | 58 +++++++++++++++++++ plugins/scaffolder/src/setupTests.ts | 13 +++++ 3 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx index 3f416dcceb..a26d6e3803 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx @@ -31,19 +31,6 @@ jest.mock('react-virtualized-auto-sizer', () => ({ }) => <>{props.children({ width: 400, height: 200 })}, })); -// 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; -}; - function DryRunRemote() { const dryRun = useDryRun(); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx new file mode 100644 index 0000000000..bf9cd8c464 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.test.tsx @@ -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(); + + 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(); + + expect( + screen.getByRole('button', { name: /Load Template Directory/ }), + ).toBeDisabled(); + }); + + it('should be able to continue to form preview', async () => { + await renderInTestApp( + + + , + ); + + await userEvent.click(screen.getByText('Edit Template Form')); + + expect(screen.getByLabelText('Load Existing Template')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/setupTests.ts b/plugins/scaffolder/src/setupTests.ts index 10252413a7..5499f969dc 100644 --- a/plugins/scaffolder/src/setupTests.ts +++ b/plugins/scaffolder/src/setupTests.ts @@ -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; +}; From d59ec5c6d5843201f53c02979d61d8966e862112 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 16:46:17 +0200 Subject: [PATCH 37/52] scaffolder: add test for TemplateEditorBrowser Signed-off-by: Patrik Oldsberg --- .../TemplateEditorBrowser.test.tsx | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx new file mode 100644 index 0000000000..cec311a5cd --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.test.tsx @@ -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( + + + , + ); + + 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(); + }); +}); From 636dc857812f63bcd6c3fed959935d055fb368d8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 16:48:56 +0200 Subject: [PATCH 38/52] scaffolder: fix dryRun implementation in ScaffolderClient Signed-off-by: Patrik Oldsberg --- plugins/scaffolder/src/api.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 0c2dee8f7f..5cefeaaa5c 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -22,8 +22,7 @@ import { } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; -import { JsonObject, Observable } from '@backstage/types'; +import { Observable } from '@backstage/types'; import qs from 'qs'; import ObservableImpl from 'zen-observable'; import { @@ -37,6 +36,8 @@ import { ScaffolderGetIntegrationsListOptions, ScaffolderGetIntegrationsListResponse, ScaffolderTask, + ScaffolderDryRunOptions, + ScaffolderDryRunResponse, } from './types'; /** @@ -173,14 +174,11 @@ export class ScaffolderClient implements ScaffolderApi { return this.streamLogsEventStream(options); } - async dryRun(options: { - template: TemplateEntityV1beta3; - values: JsonObject; - secrets: JsonObject; - content: { path: string; base64Content: string }[]; - }): Promise { + async dryRun( + options: ScaffolderDryRunOptions, + ): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); - const res = await this.fetchApi.fetch(`${baseUrl}/v2/dryrun`, { + const response = await this.fetchApi.fetch(`${baseUrl}/v2/dryrun`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -193,11 +191,11 @@ export class ScaffolderClient implements ScaffolderApi { }), }); - if (!res.ok) { - throw await ResponseError.fromResponse(res); + if (!response.ok) { + throw await ResponseError.fromResponse(response); } - return res.json(); + return response.json(); } private streamLogsEventStream({ From b622410ef0397c4f01d47845c4d82d66cb007885 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 2 May 2022 11:41:19 +0200 Subject: [PATCH 39/52] scaffolder,scaffolder-backend: fix API reports Signed-off-by: Patrik Oldsberg --- plugins/scaffolder-backend/api-report.md | 4 +++ plugins/scaffolder/api-report.md | 36 ++++++++++++++++++++++++ plugins/scaffolder/src/index.ts | 2 ++ plugins/scaffolder/src/types.ts | 2 ++ 4 files changed, 44 insertions(+) diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index b059a30d9b..1b2676cd00 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -40,6 +40,7 @@ export type ActionContext = { output(name: string, value: JsonValue): void; createTemporaryDirectory(): Promise; templateInfo?: TemplateInfo; + isDryRun?: boolean; }; // @public @@ -550,6 +551,8 @@ export interface TaskContext { // (undocumented) getWorkspaceName(): Promise; // (undocumented) + isDryRun?: boolean; + // (undocumented) secrets?: TaskSecrets; // (undocumented) spec: TaskSpec; @@ -665,6 +668,7 @@ export class TaskWorker { export type TemplateAction = { id: string; description?: string; + supportsDryRun?: boolean; schema?: { input?: Schema; output?: Schema; diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 229737eaf6..f7314390df 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -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; // (undocumented) getIntegrationsList( options: ScaffolderGetIntegrationsListOptions, @@ -256,6 +259,8 @@ export class ScaffolderClient implements ScaffolderApi { useLongPollingLogs?: boolean; }); // (undocumented) + dryRun(options: ScaffolderDryRunOptions): Promise; + // (undocumented) getIntegrationsList( options: ScaffolderGetIntegrationsListOptions, ): Promise; @@ -274,6 +279,37 @@ export class ScaffolderClient implements ScaffolderApi { streamLogs(options: ScaffolderStreamLogsOptions): Observable; } +// @public (undocumented) +export interface ScaffolderDryRunOptions { + // (undocumented) + content: { + path: string; + base64Content: string; + }[]; + // (undocumented) + secrets: JsonObject; + // (undocumented) + template: JsonValue; + // (undocumented) + values: JsonObject; +} + +// @public (undocumented) +export interface ScaffolderDryRunResponse { + // (undocumented) + content: Array<{ + path: string; + base64Content: string; + executable: boolean; + }>; + // (undocumented) + log: Array; + // (undocumented) + output: ScaffolderTaskOutput; + // (undocumented) + steps: TaskStep[]; +} + // @public export const ScaffolderFieldExtensions: React_2.ComponentType; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index fc132f644c..c42dc95f9e 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -25,6 +25,8 @@ export type { ListActionsResponse, LogEvent, ScaffolderApi, + ScaffolderDryRunOptions, + ScaffolderDryRunResponse, ScaffolderGetIntegrationsListOptions, ScaffolderGetIntegrationsListResponse, ScaffolderOutputLink, diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 1291cd9951..1044d36823 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -150,6 +150,7 @@ export interface ScaffolderStreamLogsOptions { after?: number; } +/** @public */ export interface ScaffolderDryRunOptions { template: JsonValue; values: JsonObject; @@ -157,6 +158,7 @@ export interface ScaffolderDryRunOptions { content: { path: string; base64Content: string }[]; } +/** @public */ export interface ScaffolderDryRunResponse { content: Array<{ path: string; From 36c39f9b42fd880012709fff92c6767c37cf868b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 10:27:18 +0200 Subject: [PATCH 40/52] sfaffolder: switch /v2/dryrun to /v2/dry-run Signed-off-by: Patrik Oldsberg --- plugins/scaffolder-backend/src/service/router.ts | 2 +- plugins/scaffolder/src/api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index dcef37f8e4..7bf61c9eaf 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -334,7 +334,7 @@ export async function createRouter( clearTimeout(timeout); }); }) - .post('/v2/dryrun', async (req, res) => { + .post('/v2/dry-run', async (req, res) => { const template = req.body.template as TemplateEntityV1beta3; const values = req.body.values; const token = getBearerToken(req.headers.authorization); diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 5cefeaaa5c..763d08b60a 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -178,7 +178,7 @@ export class ScaffolderClient implements ScaffolderApi { options: ScaffolderDryRunOptions, ): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); - const response = await this.fetchApi.fetch(`${baseUrl}/v2/dryrun`, { + const response = await this.fetchApi.fetch(`${baseUrl}/v2/dry-run`, { method: 'POST', headers: { 'Content-Type': 'application/json', From caafe75e890d14ad9767eb6ee0432f1bf89a5375 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 10:38:29 +0200 Subject: [PATCH 41/52] scaffolder-backend: validate dry-run template input Signed-off-by: Patrik Oldsberg --- plugins/scaffolder-backend/src/service/router.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 7bf61c9eaf..da5e2c3030 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -28,6 +28,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { TemplateEntityV1beta3, TaskSpec, + templateEntityV1beta3Validator, } from '@backstage/plugin-scaffolder-common'; import express from 'express'; import Router from 'express-promise-router'; @@ -336,6 +337,10 @@ export async function createRouter( }) .post('/v2/dry-run', async (req, res) => { const template = req.body.template as TemplateEntityV1beta3; + if (!(await templateEntityV1beta3Validator.check(template))) { + throw new InputError('Input template is not a template'); + } + const values = req.body.values; const token = getBearerToken(req.headers.authorization); const content = req.body.content as { From 0203775f4be5bd2c069c3e3d4726343f9da74c38 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 10:55:42 +0200 Subject: [PATCH 42/52] scaffolder-backend: add dry-run request body validation Signed-off-by: Patrik Oldsberg --- plugins/scaffolder-backend/package.json | 3 +- .../scaffolder-backend/src/service/router.ts | 32 ++++++++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 528d84a299..acaf18a8d4 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -72,7 +72,8 @@ "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", diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index da5e2c3030..01a6bbb235 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -22,7 +22,7 @@ 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 { @@ -35,6 +35,7 @@ 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, @@ -336,20 +337,27 @@ export async function createRouter( }); }) .post('/v2/dry-run', async (req, res) => { - const template = req.body.template as TemplateEntityV1beta3; + const bodySchema = z.object({ + template: z.unknown(), + values: z.record(z.unknown()), + secrets: z.record(z.string()), + content: 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 values = req.body.values; const token = getBearerToken(req.headers.authorization); - const content = req.body.content as { - path: string; - base64Content: string; - }[]; for (const parameters of [template.spec.parameters ?? []].flat()) { - const result = validate(values, parameters); + const result = validate(body.values, parameters); if (!result.valid) { res.status(400).json({ errors: result.errors }); return; @@ -367,15 +375,15 @@ export async function createRouter( apiVersion: template.apiVersion, steps, output: template.spec.output ?? {}, - parameters: values, + parameters: body.values as JsonObject, }, - content: (content ?? []).map(file => ({ + content: (body.content ?? []).map(file => ({ path: file.path, content: Buffer.from(file.base64Content, 'base64'), })), secrets: { - ...req.body.secrets, - backstageToken: token, + ...body.secrets, + ...(token && { backstageToken: token }), }, }); From 63f2164c1dd4dbbeda378638f1b9d0cf891d6b4a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 10:56:43 +0200 Subject: [PATCH 43/52] scaffolder-backend: clarifying comment about done Signed-off-by: Patrik Oldsberg --- .../scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts index de3590c3ac..d4dfb2dd17 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -109,6 +109,7 @@ export function createDryRunner(options: TemplateTesterCreateOptions) { }, }, 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}`, From 03a467ebc9fda69117e2f681cd8a3d6aba453ea3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 11:23:40 +0200 Subject: [PATCH 44/52] scaffolder-backend: refactor actions to use serializeDirectoryContents Signed-off-by: Patrik Oldsberg --- .../actions/builtin/fetch/template.ts | 2 + .../builtin/publish/githubPullRequest.ts | 73 +++++++------------ .../builtin/publish/gitlabMergeRequest.ts | 38 ++++------ 3 files changed, 43 insertions(+), 70 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index 041263c90c..c6d6b86e7b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -154,6 +154,7 @@ export function createFetchTemplateAction(options: { dot: true, onlyFiles: false, markDirectories: true, + followSymbolicLinks: false, }); const nonTemplatedEntries = new Set( @@ -165,6 +166,7 @@ export function createFetchTemplateAction(options: { dot: true, onlyFiles: false, markDirectories: true, + followSymbolicLinks: false, }), ), ) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts index 3bf90d1e30..f8f9b22d61 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -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, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts index 63cfeaa8b3..7259336b23 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gitlabMergeRequest.ts @@ -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); From c7a5d8d7ae6aad56d667991d7c5bb3a95047f450 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 12:18:29 +0200 Subject: [PATCH 45/52] scaffolder: rename dry-run contents -> directoryContents Signed-off-by: Patrik Oldsberg --- .../src/scaffolder/dryrun/createDryRunner.ts | 10 +++++----- plugins/scaffolder-backend/src/service/router.ts | 6 +++--- plugins/scaffolder/src/api.ts | 2 +- .../components/TemplateEditorPage/DryRunContext.tsx | 2 +- .../DryRunResults/DryRunResults.test.tsx | 7 ++++++- .../DryRunResults/DryRunResultsList.test.tsx | 7 ++++++- .../DryRunResults/DryRunResultsView.test.tsx | 2 +- .../DryRunResults/DryRunResultsView.tsx | 6 +++--- plugins/scaffolder/src/types.ts | 4 ++-- 9 files changed, 28 insertions(+), 18 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts index d4dfb2dd17..8453f793b5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -36,12 +36,12 @@ import { resolveSafeChildPath } from '@backstage/backend-common'; interface DryRunInput { spec: TaskSpec; secrets?: TaskSecrets; - content: SerializedFile[]; + directoryContents: SerializedFile[]; } interface DryRunResult { log: JsonObject[]; - content: SerializedFile[]; + directoryContents: SerializedFile[]; output: JsonObject; } @@ -88,7 +88,7 @@ export function createDryRunner(options: TemplateTesterCreateOptions) { ); try { - await deserializeDirectoryContents(contentsPath, input.content); + await deserializeDirectoryContents(contentsPath, input.directoryContents); const result = await workflowRunner.execute({ spec: { @@ -130,11 +130,11 @@ export function createDryRunner(options: TemplateTesterCreateOptions) { if (!contentPromise) { throw new Error('Content extraction step was skipped'); } - const content = await contentPromise; + const directoryContents = await contentPromise; return { log, - content, + directoryContents, output: result.output, }; } finally { diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 01a6bbb235..61035c71fe 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -341,7 +341,7 @@ export async function createRouter( template: z.unknown(), values: z.record(z.unknown()), secrets: z.record(z.string()), - content: z.array( + directoryContents: z.array( z.object({ path: z.string(), base64Content: z.string() }), ), }); @@ -377,7 +377,7 @@ export async function createRouter( output: template.spec.output ?? {}, parameters: body.values as JsonObject, }, - content: (body.content ?? []).map(file => ({ + directoryContents: (body.directoryContents ?? []).map(file => ({ path: file.path, content: Buffer.from(file.base64Content, 'base64'), })), @@ -390,7 +390,7 @@ export async function createRouter( res.status(200).json({ ...result, steps, - content: result.content.map(file => ({ + directoryContents: result.directoryContents.map(file => ({ path: file.path, executable: file.executable, base64Content: file.content.toString('base64'), diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 763d08b60a..f31bdf70b9 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -187,7 +187,7 @@ export class ScaffolderClient implements ScaffolderApi { template: options.template, values: options.values, secrets: options.secrets, - content: options.content, + directoryContents: options.directoryContents, }), }); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx index 97112f75df..b1fe8a19ff 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunContext.tsx @@ -108,7 +108,7 @@ export function DryRunProvider(props: DryRunProviderProps) { template: parsed, values: options.values, secrets: {}, - content: options.files.map(file => ({ + directoryContents: options.files.map(file => ({ path: file.path, base64Content: btoa(file.content), })), diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx index 7b08544736..bc3354cc46 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx @@ -51,7 +51,12 @@ function DryRunRemote({ } const mockScaffolderApi = { - dryRun: async () => ({ content: [], log: [], output: {}, steps: [] }), + dryRun: async () => ({ + directoryContents: [], + log: [], + output: {}, + steps: [], + }), }; describe('DryRunResults', () => { diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx index ca35f71e95..65eb59c84a 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx @@ -40,7 +40,12 @@ function DryRunRemote({ execute }: { execute?: number }) { } const mockScaffolderApi = { - dryRun: async () => ({ content: [], log: [], output: {}, steps: [] }), + dryRun: async () => ({ + directoryContents: [], + log: [], + output: {}, + steps: [], + }), }; describe('DryRunResultsList', () => { diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx index a26d6e3803..3a6bf0aff8 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx @@ -55,7 +55,7 @@ describe('DryRunResultsView', () => { scaffolderApiRef, { dryRun: async () => ({ - content: [ + directoryContents: [ { path: 'foo.txt', base64Content: btoa('Foo Content'), diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx index f854f504ce..275a1b9902 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx @@ -61,13 +61,13 @@ function FilesContent() { const classes = useStyles(); const { selectedResult } = useDryRun(); const [selectedPath, setSelectedPath] = useState(''); - const selectedFile = selectedResult?.content.find( + const selectedFile = selectedResult?.directoryContents.find( f => f.path === selectedPath, ); useEffect(() => { if (selectedResult) { - const [firstFile] = selectedResult.content; + const [firstFile] = selectedResult.directoryContents; if (firstFile) { setSelectedPath(firstFile.path); } else { @@ -85,7 +85,7 @@ function FilesContent() { file.path)} + filePaths={selectedResult.directoryContents.map(file => file.path)} /> Date: Tue, 31 May 2022 12:20:15 +0200 Subject: [PATCH 46/52] scaffolder: make dry-run secrets optional to match task execution Signed-off-by: Patrik Oldsberg --- plugins/scaffolder-backend/src/service/router.ts | 2 +- plugins/scaffolder/src/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 61035c71fe..3132ccffda 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -340,7 +340,7 @@ export async function createRouter( const bodySchema = z.object({ template: z.unknown(), values: z.record(z.unknown()), - secrets: z.record(z.string()), + secrets: z.record(z.string()).optional(), directoryContents: z.array( z.object({ path: z.string(), base64Content: z.string() }), ), diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 4f56345cf3..c0b828314b 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -154,7 +154,7 @@ export interface ScaffolderStreamLogsOptions { export interface ScaffolderDryRunOptions { template: JsonValue; values: JsonObject; - secrets: JsonObject; + secrets?: Record; directoryContents: { path: string; base64Content: string }[]; } From c94fb6d666a1c3f71aaaf6831237e39c9a740a4d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 12:20:37 +0200 Subject: [PATCH 47/52] scaffolder: fix life Signed-off-by: Patrik Oldsberg --- .../src/lib/files/serializeDirectoryContents.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts index a58274d694..e1ea676ec5 100644 --- a/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts +++ b/plugins/scaffolder-backend/src/lib/files/serializeDirectoryContents.test.ts @@ -22,7 +22,7 @@ describe('serializeDirectoryContents', () => { mockFs.restore(); }); - it('should life files in this directory', async () => { + it('should list files in this directory', async () => { await expect(serializeDirectoryContents(__dirname)).resolves.toEqual( expect.arrayContaining([ { From 73e427c4054770c46a2550144bab447526b1938d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 13:03:55 +0200 Subject: [PATCH 48/52] scaffolder: wrap dry-run log body in an outer object Signed-off-by: Patrik Oldsberg --- .../src/scaffolder/dryrun/createDryRunner.ts | 10 ++++++---- .../DryRunResults/DryRunResultsList.tsx | 2 +- .../DryRunResults/DryRunResultsView.test.tsx | 2 +- .../DryRunResults/DryRunResultsView.tsx | 8 +++++--- plugins/scaffolder/src/types.ts | 2 +- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts index 8453f793b5..f2c3c13509 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/createDryRunner.ts @@ -40,7 +40,7 @@ interface DryRunInput { } interface DryRunResult { - log: JsonObject[]; + log: Array<{ body: JsonObject }>; directoryContents: SerializedFile[]; output: JsonObject; } @@ -81,7 +81,7 @@ export function createDryRunner(options: TemplateTesterCreateOptions) { }); const dryRunId = uuid(); - const log = new Array(); + const log = new Array<{ body: JsonObject }>(); const contentsPath = resolveSafeChildPath( options.workingDirectory, `dry-run-content-${dryRunId}`, @@ -118,8 +118,10 @@ export function createDryRunner(options: TemplateTesterCreateOptions) { return; } log.push({ - ...logMetadata, - message, + body: { + ...logMetadata, + message, + }, }); }, async complete() { diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx index 920f767cf8..462c3b58fd 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx @@ -52,7 +52,7 @@ export function DryRunResultsList() { return ( {dryRun.results.map(result => { - const failed = result.log.some(l => l.status === 'failed'); + const failed = result.log.some(l => l.body.status === 'failed'); return ( { executable: false, }, ], - log: [{ message: 'Foo Message', stepId: 'foo' }], + log: [{ body: { message: 'Foo Message', stepId: 'foo' } }], output: { links: [{ title: 'Foo Link', url: 'http://example.com' }], }, diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx index 275a1b9902..9c56039808 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx @@ -110,12 +110,14 @@ function LogContent() { } return ( selectedResult.steps.map(step => { - const stepLog = selectedResult.log.filter(l => l.stepId === step.id); + const stepLog = selectedResult.log.filter( + l => l.body.stepId === step.id, + ); return { id: step.id, name: step.name, - logString: stepLog.map(l => l.message).join('\n'), - status: stepLog[stepLog.length - 1]?.status ?? 'completed', + logString: stepLog.map(l => l.body.message).join('\n'), + status: stepLog[stepLog.length - 1]?.body.status ?? 'completed', }; }) ?? [] ); diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index c0b828314b..efdfcaff93 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -165,7 +165,7 @@ export interface ScaffolderDryRunResponse { base64Content: string; executable: boolean; }>; - log: Array; + log: Array>; steps: TaskStep[]; output: ScaffolderTaskOutput; } From a2490f64fe18bb718fc461cfc85f2c45b63a9e92 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 13:05:51 +0200 Subject: [PATCH 49/52] scaffolder: remove unnecessary TemplateFileSystemAccess interface Signed-off-by: Patrik Oldsberg --- .../TemplateEditorPage/TemplateEditorPage.tsx | 3 +-- .../src/lib/filesystem/WebFileSystemAccess.ts | 20 +++++-------------- .../scaffolder/src/lib/filesystem/index.ts | 6 +----- .../scaffolder/src/lib/filesystem/types.ts | 4 ---- 4 files changed, 7 insertions(+), 26 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index 3741a6fc3d..fce2881190 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -64,8 +64,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { { if (option === 'local') { - WebFileSystemAccess.get() - .requestDirectoryAccess() + WebFileSystemAccess.requestDirectoryAccess() .then(directory => setSelection({ type: 'local', directory })) .catch(() => {}); } else if (option === 'form') { diff --git a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts index 2de3bd296f..36e7b11de2 100644 --- a/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts +++ b/plugins/scaffolder/src/lib/filesystem/WebFileSystemAccess.ts @@ -14,11 +14,7 @@ * limitations under the License. */ -import { - TemplateFileSystemAccess, - TemplateDirectoryAccess, - TemplateFileAccess, -} from './types'; +import { TemplateDirectoryAccess, TemplateFileAccess } from './types'; type WritableFileHandle = FileSystemFileHandle & { createWritable(): Promise<{ @@ -82,24 +78,18 @@ class WebDirectoryAccess implements TemplateDirectoryAccess { } /** @internal */ -export class WebFileSystemAccess implements TemplateFileSystemAccess { - private static instance = new WebFileSystemAccess(); - - static get(): TemplateFileSystemAccess { - return this.instance; - } - +export class WebFileSystemAccess { static isSupported(): boolean { return Boolean(showDirectoryPicker); } - private constructor() {} - - async requestDirectoryAccess(): Promise { + static async requestDirectoryAccess(): Promise { if (!showDirectoryPicker) { throw new Error('File system access is not supported'); } const handle = await showDirectoryPicker(); return new WebDirectoryAccess(handle); } + + private constructor() {} } diff --git a/plugins/scaffolder/src/lib/filesystem/index.ts b/plugins/scaffolder/src/lib/filesystem/index.ts index c57fc35756..1f485d0743 100644 --- a/plugins/scaffolder/src/lib/filesystem/index.ts +++ b/plugins/scaffolder/src/lib/filesystem/index.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -export type { - TemplateFileAccess, - TemplateDirectoryAccess, - TemplateFileSystemAccess, -} from './types'; +export type { TemplateFileAccess, TemplateDirectoryAccess } from './types'; export { blobToBase64 } from './helpers'; export { WebFileSystemAccess } from './WebFileSystemAccess'; diff --git a/plugins/scaffolder/src/lib/filesystem/types.ts b/plugins/scaffolder/src/lib/filesystem/types.ts index 820ae5f99d..11de159b2a 100644 --- a/plugins/scaffolder/src/lib/filesystem/types.ts +++ b/plugins/scaffolder/src/lib/filesystem/types.ts @@ -23,7 +23,3 @@ export interface TemplateFileAccess { export interface TemplateDirectoryAccess { listFiles(): Promise>; } - -export interface TemplateFileSystemAccess { - requestDirectoryAccess(): Promise; -} From 407996a8bda52148a24d42cb1cb89afd77aed96a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 13:07:28 +0200 Subject: [PATCH 50/52] scaffolder: update API report Signed-off-by: Patrik Oldsberg --- plugins/scaffolder/api-report.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index f7314390df..0884e0116c 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -282,12 +282,12 @@ export class ScaffolderClient implements ScaffolderApi { // @public (undocumented) export interface ScaffolderDryRunOptions { // (undocumented) - content: { + directoryContents: { path: string; base64Content: string; }[]; // (undocumented) - secrets: JsonObject; + secrets?: Record; // (undocumented) template: JsonValue; // (undocumented) @@ -297,13 +297,13 @@ export interface ScaffolderDryRunOptions { // @public (undocumented) export interface ScaffolderDryRunResponse { // (undocumented) - content: Array<{ + directoryContents: Array<{ path: string; base64Content: string; executable: boolean; }>; // (undocumented) - log: Array; + log: Array>; // (undocumented) output: ScaffolderTaskOutput; // (undocumented) From 139f8f08a2bf506a66e82e824f1e94104e50e691 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 13:11:59 +0200 Subject: [PATCH 51/52] scaffolder: disable global save button in template editor if no files are dirty Signed-off-by: Patrik Oldsberg --- .../src/components/TemplateEditorPage/TemplateEditorBrowser.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx index 9402047327..177ad11c2b 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorBrowser.tsx @@ -67,6 +67,7 @@ export function TemplateEditorBrowser(props: { onClose?: () => void }) { !file.dirty)} onClick={() => directoryEditor.save()} > From b2e0687363c0659444ae89990d274582aafd7387 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 31 May 2022 13:27:19 +0200 Subject: [PATCH 52/52] scaffolder-backend: fix merge conflict in router Signed-off-by: Patrik Oldsberg --- plugins/scaffolder-backend/src/service/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 3132ccffda..df416093f4 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -354,7 +354,7 @@ export async function createRouter( throw new InputError('Input template is not a template'); } - const token = getBearerToken(req.headers.authorization); + const { token } = parseBearerToken(req.headers.authorization); for (const parameters of [template.spec.parameters ?? []].flat()) { const result = validate(body.values, parameters);