scaffolder: extract TemplateEditorForm

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2022-04-29 11:16:17 +02:00
parent a788dacc73
commit 5146bc96fc
2 changed files with 222 additions and 225 deletions
@@ -13,33 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApiHolder } from '@backstage/core-plugin-api';
import { JsonObject, JsonValue } from '@backstage/types';
import { Divider, IconButton, makeStyles, Tooltip } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import RefreshIcon from '@material-ui/icons/Refresh';
import SaveIcon from '@material-ui/icons/Save';
import React, {
Component,
ReactNode,
useMemo,
useReducer,
useState,
} from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import yaml from 'yaml';
import React, { useState } from 'react';
import { FieldExtensionOptions } from '../../../extensions';
import { TemplateDirectoryAccess } from '../../../lib/filesystem';
import { TemplateParameterSchema } from '../../../types';
import { FileBrowser } from '../../FileBrowser';
import { MultistepJsonForm } from '../../MultistepJsonForm';
import { createValidator } from '../../TemplatePage';
import {
DirectoryEditorProvider,
useDirectoryEditor,
} from './DirectoryEditorContext';
import { DryRunProvider, useDryRun } from './DryRunContext';
import { DryRunProvider } from './DryRunContext';
import { DryRunResults } from './DryRunResults';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
const useStyles = makeStyles(theme => ({
@@ -117,7 +105,7 @@ export const TemplateEditor = (props: {
<TemplateEditorTextArea.DirectoryEditor errorText={errorText} />
</section>
<section className={classes.preview}>
<TemplateEditorForm
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={props.fieldExtensions}
/>
@@ -188,212 +176,3 @@ function TemplateEditorBrowser(props: { onClose?: () => void }) {
</>
);
}
interface ErrorBoundaryProps {
generation: number;
setErrorText(errorText: string | undefined): void;
children: ReactNode;
}
interface ErrorBoundaryState {
shouldRender: boolean;
}
class ErrorBoundary extends Component<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,218 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApiHolder } from '@backstage/core-plugin-api';
import { JsonObject, JsonValue } from '@backstage/types';
import React, { Component, ReactNode, useMemo, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import yaml from 'yaml';
import { FieldExtensionOptions } from '../../../extensions';
import { TemplateParameterSchema } from '../../../types';
import { MultistepJsonForm } from '../../MultistepJsonForm';
import { createValidator } from '../../TemplatePage';
import { useDirectoryEditor } from './DirectoryEditorContext';
import { useDryRun } from './DryRunContext';
interface ErrorBoundaryProps {
invalidator: unknown;
setErrorText(errorText: string | undefined): void;
children: ReactNode;
}
interface ErrorBoundaryState {
shouldRender: boolean;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state = {
shouldRender: true,
};
componentDidUpdate(prevProps: { invalidator: unknown }) {
if (prevProps.invalidator !== this.props.invalidator) {
this.setState({ shouldRender: true });
}
}
componentDidCatch(error: Error) {
this.props.setErrorText(error.message);
this.setState({ shouldRender: false });
}
render() {
return this.state.shouldRender ? this.props.children : null;
}
}
interface TemplateEditorFormProps {
content?: string;
/** Setting this to true will cause the content to be parsed as if it is the template entity spec */
contentIsSpec?: boolean;
data: JsonObject;
onUpdate: (data: JsonObject) => void;
setErrorText: (errorText?: string) => void;
onDryRun?: (data: JsonObject) => Promise<void>;
fieldExtensions?: FieldExtensionOptions<any, any>[];
}
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Shows the a template form that is parsed from the provided content */
export function TemplateEditorForm(props: TemplateEditorFormProps) {
const {
content,
contentIsSpec,
data,
onUpdate,
onDryRun,
setErrorText,
fieldExtensions = [],
} = props;
const apiHolder = useApiHolder();
const [steps, setSteps] = useState<TemplateParameterSchema['steps']>();
const fields = useMemo(() => {
return Object.fromEntries(
fieldExtensions.map(({ name, component }) => [name, component]),
);
}, [fieldExtensions]);
useDebounce(
() => {
try {
if (!content) {
setSteps(undefined);
return;
}
const parsed: JsonValue = yaml.parse(content);
if (!isJsonObject(parsed)) {
setSteps(undefined);
return;
}
let rootObj = parsed;
if (!contentIsSpec) {
const isTemplate =
String(parsed.kind).toLocaleLowerCase('en-US') === 'template';
if (!isTemplate) {
setSteps(undefined);
return;
}
rootObj = isJsonObject(parsed.spec) ? parsed.spec : {};
}
const { parameters } = rootObj;
if (!Array.isArray(parameters)) {
setErrorText('Template parameters must be an array');
setSteps(undefined);
return;
}
const fieldValidators = Object.fromEntries(
fieldExtensions.map(({ name, validation }) => [name, validation]),
);
setErrorText();
setSteps(
parameters.flatMap(param =>
isJsonObject(param)
? [
{
title: String(param.title),
schema: param,
validate: createValidator(param, fieldValidators, {
apiHolder,
}),
},
]
: [],
),
);
} catch (e) {
setErrorText(e.message);
}
},
250,
[contentIsSpec, content, apiHolder],
);
if (!steps) {
return null;
}
return (
<ErrorBoundary invalidator={steps} setErrorText={setErrorText}>
<MultistepJsonForm
steps={steps}
fields={fields}
formData={data}
onChange={e => onUpdate(e.formData)}
onReset={() => onUpdate({})}
finishButtonLabel={onDryRun && 'Try It'}
onFinish={onDryRun && (() => onDryRun(data))}
/>
</ErrorBoundary>
);
}
/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */
export function TemplateEditorFormDirectoryEditorDryRun(
props: Pick<TemplateEditorFormProps, 'setErrorText' | 'fieldExtensions'>,
) {
const { setErrorText, fieldExtensions = [] } = props;
const dryRun = useDryRun();
const directoryEditor = useDirectoryEditor();
const { selectedFile } = directoryEditor;
const [data, setData] = useState<JsonObject>({});
const handleDryRun = async () => {
if (!selectedFile) {
return;
}
await dryRun.execute({
templateContent: selectedFile.content,
values: data,
files: directoryEditor.files,
});
};
const content =
selectedFile && selectedFile.path.match(/\.ya?ml$/)
? selectedFile.content
: undefined;
return (
<TemplateEditorForm
onDryRun={handleDryRun}
fieldExtensions={fieldExtensions}
setErrorText={setErrorText}
content={content}
data={data}
onUpdate={setData}
/>
);
}
TemplateEditorForm.DirectoryEditorDryRun =
TemplateEditorFormDirectoryEditorDryRun;