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; }