scaffolder: initial e2e editor + dry-run

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2022-04-08 10:28:03 +02:00
parent 16f11352ab
commit df4497ecd7
20 changed files with 2483 additions and 255 deletions
@@ -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) {
@@ -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();
+1
View File
@@ -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",
+29 -1
View File
@@ -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<unknown> {
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,
@@ -180,8 +180,9 @@ export const MultistepJsonForm = (props: Props) => {
try {
await onFinish();
} catch (err) {
setDisableButtons(false);
errorApi.post(err);
} finally {
setDisableButtons(false);
}
};
@@ -161,16 +161,16 @@ function TaskStepIconComponent(props: StepIconProps) {
}
export const TaskStatusStepper = memo(
({
steps,
currentStepId,
onUserStepChange,
}: {
(props: {
steps: TaskStep[];
currentStepId: string | undefined;
onUserStepChange: (id: string) => void;
classes?: {
root?: string;
};
}) => {
const classes = useStyles();
const { steps, currentStepId, onUserStepChange } = props;
const classes = useStyles(props);
return (
<div className={classes.root}>
@@ -41,7 +41,10 @@ export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
.filter(({ url, entityRef }) => url || entityRef)
.map(({ url, entityRef, title, icon }) => {
if (entityRef) {
const entityName = parseEntityRef(entityRef);
const entityName = parseEntityRef(entityRef, {
defaultKind: '<unknown>',
defaultNamespace: '<unknown>',
});
const target = entityRoute(entityName);
return { title, icon, url: target };
}
@@ -0,0 +1,243 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ErrorPanel, Progress } from '@backstage/core-components';
import { useAsync, useRerender } from '@react-hookz/web';
import React, { createContext, ReactNode, useContext, useEffect } from 'react';
import {
TemplateDirectoryAccess,
TemplateFileAccess,
} from '../../lib/filesystem';
const MAX_SIZE = 1024 * 1024;
const MAX_SIZE_MESSAGE = 'This file is too large to be displayed';
interface DirectoryEditorFile {
/** The path of the file relative to the root directory */
path: string;
/** The staged content of the file */
content: string;
/** Whether the staged content matches what is on disk */
dirty: boolean;
/** Update the staged content of the file without saving */
updateContent(content: string): void;
/** Save the staged content of the file to disk */
save(): Promise<void>;
/** Reload the staged content of the file from disk */
reload(): Promise<void>;
}
interface DirectoryEditor {
/** A list of all files in the edited directory */
files: Array<DirectoryEditorFile>;
/** The currently selected file */
selectedFile: DirectoryEditorFile | undefined;
/** Switch the selected file */
setSelectedFile(path: string | undefined): void;
/** Save all files to disk */
save(): Promise<void>;
/** Reload all files from disk */
reload(): Promise<void>;
subscribe(listener: () => void): () => void;
}
class DirectoryEditorFileManager implements DirectoryEditorFile {
readonly #access: TemplateFileAccess;
readonly #signalUpdate: () => void;
#content?: string;
#savedContent?: string;
constructor(access: TemplateFileAccess, signalUpdate: () => void) {
this.#access = access;
this.#signalUpdate = signalUpdate;
}
get path() {
return this.#access.path;
}
get content() {
return this.#content ?? MAX_SIZE_MESSAGE;
}
updateContent(content: string): void {
if (this.#content === undefined) {
return;
}
this.#content = content;
this.#signalUpdate();
}
get dirty() {
return this.#content !== this.#savedContent;
}
async save(): Promise<void> {
if (this.#content !== undefined) {
await this.#access.save(this.#content);
this.#savedContent = this.#content;
this.#signalUpdate();
}
}
async reload(): Promise<void> {
const file = await this.#access.file();
if (file.size > MAX_SIZE) {
if (this.#content !== undefined) {
this.#content = undefined;
this.#savedContent = undefined;
this.#signalUpdate();
}
return;
}
const content = await file.text();
if (this.#content !== content) {
this.#content = content;
this.#savedContent = content;
this.#signalUpdate();
}
}
}
class DirectoryEditorManager implements DirectoryEditor {
readonly #access: TemplateDirectoryAccess;
readonly #listeners = new Set<() => void>();
#files: DirectoryEditorFile[] = [];
#selectedFile: DirectoryEditorFile | undefined;
constructor(access: TemplateDirectoryAccess) {
this.#access = access;
}
get files() {
return this.#files;
}
get selectedFile() {
return this.#selectedFile;
}
setSelectedFile = (path: string | undefined): void => {
const prev = this.#selectedFile;
const next = this.#files.find(file => file.path === path);
if (prev !== next) {
this.#selectedFile = next;
this.#signalUpdate();
}
};
get dirty() {
return this.#files.some(file => file.dirty);
}
async save(): Promise<void> {
await Promise.all(this.#files.map(file => file.save()));
}
async reload(): Promise<void> {
const selectedPath = this.#selectedFile?.path;
const files = await this.#access.listFiles();
const fileManagers = await Promise.all(
files.map(async file => {
const manager = new DirectoryEditorFileManager(
file,
this.#signalUpdate,
);
await manager.reload();
return manager;
}),
);
this.#files.length = 0;
this.#files.push(...fileManagers);
this.setSelectedFile(selectedPath);
this.#signalUpdate();
}
subscribe(listener: () => void): () => void {
this.#listeners.add(listener);
return () => {
this.#listeners.delete(listener);
};
}
#signalUpdate = () => {
this.#listeners.forEach(listener => listener());
};
}
const DirectoryEditorContext = createContext<DirectoryEditor | undefined>(
undefined,
);
export function useDirectoryEditor(): DirectoryEditor {
const value = useContext(DirectoryEditorContext);
const rerender = useRerender();
useEffect(() => value?.subscribe(rerender), [value, rerender]);
if (!value) {
throw new Error('must be used within a DirectoryEditorProvider');
}
return value;
}
interface DirectoryEditorProviderProps {
directory: TemplateDirectoryAccess;
children?: ReactNode;
}
export function DirectoryEditorProvider(props: DirectoryEditorProviderProps) {
const { directory } = props;
const [{ result, error }, { execute }] = useAsync(
async (dir: TemplateDirectoryAccess) => {
const manager = new DirectoryEditorManager(dir);
await manager.reload();
const firstYaml = manager.files.find(file => file.path.match(/\.ya?ml$/));
if (firstYaml) {
manager.setSelectedFile(firstYaml.path);
}
return manager;
},
);
useEffect(() => {
execute(directory);
}, [execute, directory]);
if (error) {
return <ErrorPanel error={error} />;
} else if (!result) {
return <Progress />;
}
return (
<DirectoryEditorContext.Provider value={result}>
{props.children}
</DirectoryEditorContext.Provider>
);
}
@@ -0,0 +1,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 <div>Fail: {error.message}</div>;
}
return (
<Button
disabled={!supportsWebAccess || status === 'loading'}
onClick={execute}
>
Load Directory
</Button>
);
}
@@ -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<void>;
}
const DryRunContext = createContext<DryRun | undefined>(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: '<example>',
},
{
title: 'Open in catalog',
icon: 'catalog',
entityRef: 'entity',
},
],
},
},
];
export function DryRunProvider(props: DryRunProviderProps) {
const scaffolderApi = useApi(scaffolderApiRef);
const [state, setState] = useState<
Pick<DryRun, 'results' | 'selectedResult'>
>({
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 (
<DryRunContext.Provider value={dryRun}>
{props.children}
</DryRunContext.Provider>
);
}
export function useDryRun(): DryRun {
const value = useContext(DryRunContext);
if (!value) {
throw new Error('must be used within a DryRunProvider');
}
return value;
}
@@ -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 = (
<Card className={classes.card} elevation={4}>
<CardActionArea
disabled={!supportsLoad}
onClick={() => props.onSelect?.('local')}
>
<CardContent>
<Typography
variant="h5"
gutterBottom
color={supportsLoad ? undefined : 'textSecondary'}
style={{ display: 'flex', flexFlow: 'row nowrap' }}
>
Load Template Directory
</Typography>
<Typography
variant="body1"
color={supportsLoad ? undefined : 'textSecondary'}
>
Load a local template directory, allowing you to both edit and try
executing your own template.
</Typography>
</CardContent>
</CardActionArea>
{!supportsLoad && (
<div className={classes.infoIcon}>
<Tooltip
placement="top"
title="Only supported in some Chromium-based browsers"
>
<InfoOutlinedIcon />
</Tooltip>
</div>
)}
</Card>
);
const cardFormEditor = (
<Card className={classes.card} elevation={4}>
<CardActionArea onClick={() => props.onSelect?.('form')}>
<CardContent>
<Typography variant="h5" gutterBottom>
Edit Template Form
</Typography>
<Typography variant="body1">
Preview and edit a template form, either using a sample template or
by loading a template from the catalog.
</Typography>
</CardContent>
</CardActionArea>
</Card>
);
return (
<div style={props.style}>
<Typography variant="h6" className={classes.introText}>
Get started by choosing one of the options below
</Typography>
<div
style={{
display: 'flex',
flexFlow: 'row wrap',
alignItems: 'flex-start',
justifyContent: 'center',
alignContent: 'flex-start',
}}
>
{supportsLoad && cardLoadLocal}
{cardFormEditor}
{!supportsLoad && cardLoadLocal}
</div>
</div>
);
};
@@ -0,0 +1,95 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FileEntry, parseFileEntires } from './FileBrowser';
function dir(path: string, ...children: FileEntry[]): FileEntry {
return {
type: 'directory',
path: path,
name: path.split('/').pop()!,
children: children,
};
}
function file(path: string): FileEntry {
return {
type: 'file',
path: path,
name: path.split('/').pop()!,
};
}
describe('parseFileEntires', () => {
it('parses an empty list', () => {
expect(parseFileEntires([])).toEqual([]);
});
it('parses a single file', () => {
expect(parseFileEntires(['a.txt'])).toEqual([file('a.txt')]);
expect(parseFileEntires(['a/b.txt'])).toEqual([dir('a', file('a/b.txt'))]);
expect(parseFileEntires(['a/b/c.txt'])).toEqual([
dir('a', dir('a/b', file('a/b/c.txt'))),
]);
});
it('parses multiple files', () => {
expect(parseFileEntires(['a.txt', 'b.txt'])).toEqual([
file('a.txt'),
file('b.txt'),
]);
expect(parseFileEntires(['a.txt', 'a/b.txt'])).toEqual([
dir('a', file('a/b.txt')),
file('a.txt'),
]);
expect(parseFileEntires(['a.txt', 'a/b.txt', 'a/c.txt'])).toEqual([
dir('a', file('a/b.txt'), file('a/c.txt')),
file('a.txt'),
]);
expect(parseFileEntires(['a.txt', 'a/b/c.txt', 'a/b/d.txt'])).toEqual([
dir('a', dir('a/b', file('a/b/c.txt'), file('a/b/d.txt'))),
file('a.txt'),
]);
});
it('throws an error on invalid filenames', () => {
expect(() => parseFileEntires([''])).toThrow(`Invalid path part: ''`);
expect(() => parseFileEntires(['/'])).toThrow(`Invalid path part: ''`);
expect(() => parseFileEntires(['a/'])).toThrow(`Invalid path part: ''`);
expect(() => parseFileEntires(['/a.txt'])).toThrow(`Invalid path part: ''`);
expect(() => parseFileEntires(['a//a.txt'])).toThrow(
`Invalid path part: ''`,
);
});
it('throws an error on conflicting directory and filenames', () => {
expect(() => parseFileEntires(['a', 'a'])).toThrow(
`Duplicate filename at 'a'`,
);
expect(() => parseFileEntires(['a', 'a/b'])).toThrow(
`Duplicate filename at 'a'`,
);
expect(() => parseFileEntires(['a/b', 'a'])).toThrow(
`Duplicate filename at 'a'`,
);
expect(() => parseFileEntires(['a/b', 'a/b/c'])).toThrow(
`Duplicate filename at 'a/b'`,
);
expect(() => parseFileEntires(['a/b/c', 'a/b/c'])).toThrow(
`Duplicate filename at 'a/b/c'`,
);
});
});
@@ -0,0 +1,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 <TreeItem nodeId={entry.path} label={entry.name} />;
}
return (
<TreeItem nodeId={entry.path} label={entry.name}>
{entry.children.map(child => (
<FileTreeItem key={child.path} entry={child} />
))}
</TreeItem>
);
}
interface FileBrowserProps {
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 (
<TreeView
selected={props.selected}
className={classes.root}
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
onNodeSelect={(_e: unknown, nodeId: string) => {
if (props.onSelect && props.filePaths.includes(nodeId)) {
props.onSelect(nodeId);
}
}}
>
{fileTree.map(entry => (
<FileTreeItem key={entry.path} entry={entry} />
))}
</TreeView>
);
}
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 <ErrorPanel error={state.error!} />;
} else if (!state.result) {
return <Progress />;
}
const handleSelect = (path: string) => {
const file = state.result?.getFile(path);
if (file) {
props.onSelect?.(file);
}
};
return (
<FileBrowser filePaths={state.result.filePaths} onSelect={handleSelect} />
);
}
FileBrowser.TemplateDirectoryAccess = TemplateDirectoryAccessBrowser;
@@ -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<any, any>[];
onClose?: () => void;
}) => {
const classes = useStyles();
const [errorText, setErrorText] = useState<string>();
return (
<DirectoryEditorProvider directory={directory}>
<DryRunProvider>
<div className={classes.rootWrapper}>
<main className={classes.root}>
<section className={classes.browser}>
<TemplateEditorBrowser />
</section>
<section className={classes.editor}>
<TemplateEditorTextArea errorText={errorText} />
</section>
<section className={classes.preview}>
<TemplateEditorForm
setErrorText={setErrorText}
fieldExtensions={fieldExtensions}
/>
</section>
<section className={classes.results}>
<TemplateEditorDryRunResults />
</section>
</main>
</div>
</DryRunProvider>
</DirectoryEditorProvider>
);
};
function TemplateEditorBrowser() {
const classes = useStyles();
const directoryEditor = useDirectoryEditor();
return (
<>
<div className={classes.browserButtons}>
<IconButton className={classes.browserButton}>
<SaveIcon />
</IconButton>
<IconButton className={classes.browserButton}>
<RefreshIcon />
</IconButton>
<div className={classes.browserButtonsGap} />
<IconButton className={classes.browserButton}>
<CloseIcon />
</IconButton>
</div>
<Divider className={classes.browserButtonsDivider} />
<FileBrowser
selected={directoryEditor.selectedFile?.path ?? ''}
onSelect={directoryEditor.setSelectedFile}
filePaths={directoryEditor.files.map(file => 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 (
<CodeMirror
className={classes.editorCodeMirror}
theme="dark"
height="100%"
extensions={[
StreamLanguage.define(yamlSupport),
showPanel.of(() => ({ 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<ErrorBoundaryProps, ErrorBoundaryState> {
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<any, any>[];
}
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 (
<ErrorBoundary
generation={state.schemaGeneration}
setErrorText={setErrorText}
>
<MultistepJsonForm
steps={state.steps}
fields={fields}
formData={state.formData}
onChange={e => dispatch({ type: 'updateData', formData: e.formData })}
onReset={() => dispatch({ type: 'updateData', formData: {} })}
finishButtonLabel="Try It"
onFinish={handleDryRun}
/>
</ErrorBoundary>
);
}
@@ -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 (
<>
<Accordion
variant="outlined"
expanded={expanded}
hidden={resultsLength === 0 && hidden}
onChange={(_, exp) => setExpanded(exp)}
onTransitionEnd={() => resultsLength === 0 && setHidden(true)}
>
<AccordionSummary
className={classes.accordionHeader}
expandIcon={<ExpandMoreIcon />}
>
<Typography>Dry-run results</Typography>
</AccordionSummary>
<Divider orientation="horizontal" />
<AccordionDetails className={classes.accordionContent}>
<ResultList />
<Divider orientation="horizontal" />
<ResultView />
</AccordionDetails>
</Accordion>
</>
);
}
function ResultList() {
const classes = useStyles();
const dryRun = useDryRun();
return (
<List className={classes.resultList} dense>
{dryRun.results.map(result => {
const failed = result.log.some(l => l.status === 'failed');
return (
<ListItem
button
key={result.id}
selected={dryRun.selectedResult?.id === result.id}
onClick={() => dryRun.selectResult(result.id)}
>
<ListItemIcon
className={
failed
? classes.resultListIconFailure
: classes.resultListIconSuccess
}
>
{failed ? <CancelIcon /> : <CheckIcon />}
</ListItemIcon>
<ListItemText primary={`Result ${result.id}`} />
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="delete"
onClick={() => dryRun.deleteResult(result.id)}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
);
})}
</List>
);
}
function ResultView() {
const classes = useStyles();
const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>(
'files',
);
return (
<div className={classes.resultView}>
<Tabs value={selectedTab} onChange={(_, v) => setSelectedTab(v)}>
<Tab value="files" label="Files" />
<Tab value="log" label="Log" />
<Tab value="output" label="Output" />
</Tabs>
<Divider />
<div className={classes.resultViewItemWrapper}>
<div className={classes.resultViewItem}>
{selectedTab === 'files' && <FilesContent />}
{selectedTab === 'log' && <LogContent />}
{selectedTab === 'output' && <OutputContent />}
</div>
</div>
</div>
);
}
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 (
<div className={classes.root}>
<div className={classNames(classes.child, classes.childPaper)}>
{childArray[0]}
</div>
<Divider orientation="horizontal" />
<div className={classes.child}>{childArray[1]}</div>
</div>
);
}
function FilesContent() {
const classes = useStyles();
const { selectedResult } = useDryRun();
const [selectedPath, setSelectedPath] = useState<string>('');
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 (
<SplitView>
<FileBrowser
selected={selectedPath}
onSelect={setSelectedPath}
filePaths={selectedResult.content.map(file => file.path)}
/>
<CodeMirror
className={classes.codeMirror}
theme="dark"
height="100%"
extensions={[StreamLanguage.define(yamlSupport)]}
readOnly
value={
selectedFile?.base64Content ? atob(selectedFile.base64Content) : ''
}
/>
</SplitView>
);
}
function LogContent() {
const { selectedResult } = useDryRun();
const [currentStepId, setUserSelectedStepId] = useState<string>();
const steps = useMemo(() => {
if (!selectedResult) {
return [];
}
return (
selectedResult.steps.map(step => {
const stepLog = selectedResult.log.filter(l => l.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 (
<SplitView>
<TaskStatusStepper
steps={steps}
currentStepId={selectedStep.id}
onUserStepChange={setUserSelectedStepId}
/>
<LogViewer text={selectedStep?.logString ?? ''} />
</SplitView>
);
}
function OutputContent() {
const classes = useStyles();
const { selectedResult } = useDryRun();
if (!selectedResult) {
return null;
}
return (
<SplitView>
<Box pt={2}>
{selectedResult.output?.links?.length && (
<TaskPageLinks output={selectedResult.output} />
)}
</Box>
<CodeMirror
className={classes.codeMirror}
theme="dark"
height="100%"
extensions={[StreamLanguage.define(yamlSupport)]}
readOnly
value={JSON.stringify(selectedResult.output, null, 2)}
/>
</SplitView>
);
}
@@ -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<any, any>[];
}) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
const catalogApi = useApi(catalogApiRef);
const apiHolder = useApiHolder();
const [selectedTemplate, setSelectedTemplate] = useState('');
const [schema, setSchema] = useState<TemplateParameterSchema>({
title: '',
steps: [],
}
export function TemplateEditorPage(props: TemplateEditorPageProps) {
const [selection, setSelection] = useState<Selection>({
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<TemplateOption[]>([]);
const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
const [formState, setFormState] = useState({});
const { loading } = useAsync(
() =>
catalogApi
.getEntities({
filter: { kind: 'template' },
fields: [
'kind',
'metadata.namespace',
'metadata.name',
'metadata.title',
'spec.parameters',
],
})
.then(({ items }) =>
setTemplateOptions(
items.map(template => ({
label:
template.metadata.title ??
humanizeEntityRef(template, { defaultKind: 'template' }),
value: template,
})),
),
)
.catch(e =>
alertApi.post({
message: `Error loading exisiting templates: ${e.message}`,
severity: 'error',
}),
),
[catalogApi],
);
const errorPanel = document.createElement('div');
errorPanel.style.color = 'red';
useDebounce(
() => {
try {
const parsedTemplate = yaml.parse(templateYaml);
setSchema({
title: 'Preview',
steps: parsedTemplate.parameters.map((param: JsonObject) => ({
title: param.title,
schema: param,
})),
});
setFormState({});
} catch (e) {
errorPanel.textContent = e.message;
}
},
250,
[setFormState, setSchema, templateYaml],
);
const handleSelectChange = useCallback(
selected => {
setSelectedTemplate(selected);
setTemplateYaml(yaml.stringify(selected.spec));
},
[setTemplateYaml],
);
const handleFormReset = () => setFormState({});
const handleFormChange = useCallback(
(e: IChangeEvent) => setFormState(e.formData),
[setFormState],
);
const handleCodeChange = useCallback(
(code: string) => {
setTemplateYaml(code);
},
[setTemplateYaml],
);
const customFieldComponents = Object.fromEntries(
customFieldExtensions.map(({ name, component }) => [name, component]),
);
const customFieldValidators = Object.fromEntries(
customFieldExtensions.map(({ name, validation }) => [name, validation]),
);
let content: JSX.Element | null = null;
if (selection?.type === 'local') {
content = (
<TemplateEditor
directory={selection.directory}
fieldExtensions={props.customFieldExtensions}
onClose={() => setSelection(undefined)}
/>
);
} else if (selection?.type === 'form') {
content = (
<TemplateFormEditor
defaultPreviewTemplate={props.defaultPreviewTemplate}
customFieldExtensions={props.customFieldExtensions}
onClose={() => setSelection(undefined)}
/>
);
} else {
content = (
<Content>
<EditorIntro
onSelect={option => {
if (option === 'local') {
WebFileSystemAccess.get()
.requestDirectoryAccess()
.then(directory => setSelection({ type: 'local', directory }))
.catch(() => {});
} else if (option === 'form') {
setSelection({ type: 'form' });
}
}}
/>
</Content>
);
}
return (
<Page themeId="home">
<Header
title="Template Editor"
subtitle="Preview your template parameter UI"
subtitle="Edit, preview, and try out templates and template forms"
/>
<Content>
{loading && <LinearProgress />}
<Grid container className={classes.grid}>
<Grid item xs={6}>
<FormControl
className={classes.templateSelect}
variant="outlined"
fullWidth
>
<InputLabel id="select-template-label">
Load Existing Template
</InputLabel>
<Select
value={selectedTemplate}
label="Load Existing Template"
labelId="select-template-label"
onChange={e => handleSelectChange(e.target.value)}
>
{templateOptions.map((option, idx) => (
<MenuItem key={idx} value={option.value as any}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<CodeMirror
className={classes.codeMirror}
value={templateYaml}
theme="dark"
height="100%"
extensions={[
StreamLanguage.define(yamlSupport),
showPanel.of(() => ({ dom: errorPanel, top: true })),
]}
onChange={handleCodeChange}
/>
</Grid>
<Grid item xs={6}>
{schema && (
<InfoCard key={JSON.stringify(schema)}>
<MultistepJsonForm
formData={formState}
fields={customFieldComponents}
onChange={handleFormChange}
onReset={handleFormReset}
steps={schema.steps.map(step => {
return {
...step,
validate: createValidator(
step.schema,
customFieldValidators,
{ apiHolder },
),
};
})}
/>
</InfoCard>
)}
</Grid>
</Grid>
</Content>
{content}
</Page>
);
};
}
@@ -0,0 +1,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<any, any>[];
onClose?: () => void;
}) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
const catalogApi = useApi(catalogApiRef);
const apiHolder = useApiHolder();
const [selectedTemplate, setSelectedTemplate] = useState('');
const [schema, setSchema] = useState<TemplateParameterSchema>({
title: '',
steps: [],
});
const [templateOptions, setTemplateOptions] = useState<TemplateOption[]>([]);
const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
const [formState, setFormState] = useState({});
const { loading } = useAsync(
() =>
catalogApi
.getEntities({
filter: { kind: 'template' },
fields: [
'kind',
'metadata.namespace',
'metadata.name',
'metadata.title',
'spec.parameters',
'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 && <LinearProgress />}
<Grid container className={classes.grid}>
<Grid item xs={6}>
<div className={classes.controls}>
<FormControl variant="outlined" size="small" fullWidth>
<InputLabel id="select-template-label">
Load Existing Template
</InputLabel>
<Select
value={selectedTemplate}
label="Load Existing Template"
labelId="select-template-label"
onChange={e => handleSelectChange(e.target.value)}
>
{templateOptions.map((option, idx) => (
<MenuItem key={idx} value={option.value as any}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton size="medium" onClick={onClose}>
<CloseIcon />
</IconButton>
</div>
<CodeMirror
className={classes.codeMirror}
value={templateYaml}
theme="dark"
height="100%"
extensions={[
StreamLanguage.define(yamlSupport),
showPanel.of(() => ({ dom: errorPanel, top: true })),
]}
onChange={handleCodeChange}
/>
</Grid>
<Grid item xs={6}>
{schema && (
<InfoCard key={JSON.stringify(schema)}>
<MultistepJsonForm
formData={formState}
fields={customFieldComponents}
onChange={handleFormChange}
onReset={handleFormReset}
steps={schema.steps.map(step => {
return {
...step,
validate: createValidator(
step.schema,
customFieldValidators,
{ apiHolder },
),
};
})}
/>
</InfoCard>
)}
</Grid>
</Grid>
</>
);
};
@@ -13,4 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TemplateEditorPage } from './TemplateEditorPage';
@@ -0,0 +1,57 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TemplateDirectoryAccess, TemplateFileAccess } from './types';
class MockFileAccess implements TemplateFileAccess {
constructor(readonly path: string, private content: string) {}
async file(): Promise<File> {
const blob = new Blob([this.content]);
return Object.assign(blob, {
name: this.path.split('/').pop()!,
lastModified: Date.now(),
webkitRelativePath: this.path,
});
}
async save(data: string | Blob | BufferSource): Promise<void> {
this.content = await new Response(data).text();
}
}
class MockDirectoryAccess implements TemplateDirectoryAccess {
private readonly files = new Array<TemplateFileAccess>();
constructor(inputFiles: Record<string, string>) {
this.files = Object.entries(inputFiles).map(
([path, content]) => new MockFileAccess(path, content),
);
}
async listFiles(): Promise<TemplateFileAccess[]> {
return this.files;
}
}
/** @internal */
export class MockFileSystemAccess {
private constructor() {}
static createMockDirectory(files: Record<string, string>) {
return new MockDirectoryAccess(files);
}
}
+22 -1
View File
@@ -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<LogEvent['body']>;
steps: TaskStep[];
output: ScaffolderTaskOutput;
}
/**
* An API to interact with the scaffolder backend.
*
@@ -181,4 +200,6 @@ export interface ScaffolderApi {
listActions(): Promise<ListActionsResponse>;
streamLogs(options: ScaffolderStreamLogsOptions): Observable<LogEvent>;
dryRun?(options: ScaffolderDryRunOptions): Promise<ScaffolderDryRunResponse>;
}