scaffolder: extract TemplateEditorForm
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
+4
-225
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+218
@@ -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;
|
||||
Reference in New Issue
Block a user