From 5146bc96fc5547f4ae9b5c9524d9ca1448c772be Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Apr 2022 11:16:17 +0200 Subject: [PATCH] scaffolder: extract TemplateEditorForm Signed-off-by: Patrik Oldsberg --- .../TemplateEditor/TemplateEditor.tsx | 229 +----------------- .../TemplateEditor/TemplateEditorForm.tsx | 218 +++++++++++++++++ 2 files changed, 222 insertions(+), 225 deletions(-) create mode 100644 plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx index 4e1b26441b..f6b936e8af 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditor.tsx @@ -13,33 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useApiHolder } from '@backstage/core-plugin-api'; -import { JsonObject, JsonValue } from '@backstage/types'; import { Divider, IconButton, makeStyles, Tooltip } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import RefreshIcon from '@material-ui/icons/Refresh'; import SaveIcon from '@material-ui/icons/Save'; -import React, { - Component, - ReactNode, - useMemo, - useReducer, - useState, -} from 'react'; -import useDebounce from 'react-use/lib/useDebounce'; -import yaml from 'yaml'; +import React, { useState } from 'react'; import { FieldExtensionOptions } from '../../../extensions'; import { TemplateDirectoryAccess } from '../../../lib/filesystem'; -import { TemplateParameterSchema } from '../../../types'; import { FileBrowser } from '../../FileBrowser'; -import { MultistepJsonForm } from '../../MultistepJsonForm'; -import { createValidator } from '../../TemplatePage'; import { DirectoryEditorProvider, useDirectoryEditor, } from './DirectoryEditorContext'; -import { DryRunProvider, useDryRun } from './DryRunContext'; +import { DryRunProvider } from './DryRunContext'; import { DryRunResults } from './DryRunResults'; +import { TemplateEditorForm } from './TemplateEditorForm'; import { TemplateEditorTextArea } from './TemplateEditorTextArea'; const useStyles = makeStyles(theme => ({ @@ -117,7 +105,7 @@ export const TemplateEditor = (props: {
- @@ -188,212 +176,3 @@ function TemplateEditorBrowser(props: { onClose?: () => void }) { ); } - -interface ErrorBoundaryProps { - generation: number; - setErrorText(errorText: string | undefined): void; - children: ReactNode; -} - -interface ErrorBoundaryState { - shouldRender: boolean; -} - -class ErrorBoundary extends Component { - state = { - shouldRender: true, - }; - - componentDidUpdate(prevProps: { generation: number }) { - if (prevProps.generation !== this.props.generation) { - this.setState({ shouldRender: true }); - } - } - - componentDidCatch(error: Error) { - this.props.setErrorText(error.message); - this.setState({ shouldRender: false }); - } - - render() { - return this.state.shouldRender ? this.props.children : null; - } -} - -interface TemplateFormState { - filePath?: string; - content?: string; - steps?: TemplateParameterSchema['steps']; - formData: JsonObject; - schemaGeneration: number; -} - -type TemplateFormAction = - | { - type: 'reset'; - } - | { - type: 'updateData'; - formData: JsonObject; - } - | { - type: 'updateSchema'; - steps: TemplateParameterSchema['steps']; - filePath: string; - }; - -const initialTemplateFormState: TemplateFormState = { - steps: undefined, - filePath: undefined, - formData: {}, - // Used to reset the error boundary in edit - schemaGeneration: 0, -}; - -function templateFormReducer( - state: TemplateFormState, - action: TemplateFormAction, -): TemplateFormState { - switch (action.type) { - case 'reset': { - return initialTemplateFormState; - } - case 'updateData': { - return { - ...state, - formData: action.formData, - }; - } - case 'updateSchema': { - const { filePath, steps } = action; - - return { - steps, - filePath, - formData: state.filePath === filePath ? state.formData : {}, - schemaGeneration: state.schemaGeneration + 1, - }; - } - default: - return state; - } -} - -interface TemplateEditorFormProps { - setErrorText: (errorText?: string) => void; - fieldExtensions?: FieldExtensionOptions[]; -} - -function isJsonObject(value: JsonValue | undefined): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function TemplateEditorForm(props: TemplateEditorFormProps) { - const { setErrorText, fieldExtensions = [] } = props; - const dryRun = useDryRun(); - - const apiHolder = useApiHolder(); - const directoryEditor = useDirectoryEditor(); - const { selectedFile } = directoryEditor; - const [state, dispatch] = useReducer( - templateFormReducer, - initialTemplateFormState, - ); - - useDebounce( - () => { - try { - if (!selectedFile || !selectedFile.path.match(/\.ya?ml$/)) { - dispatch({ type: 'reset' }); - return; - } - const parsed: JsonValue = yaml.parse(selectedFile.content); - const isTemplate = - typeof parsed === 'object' && - parsed !== null && - 'kind' in parsed && - typeof parsed.kind === 'string' && - parsed.kind.toLocaleLowerCase('en-US') === 'template'; - - if (!isTemplate) { - dispatch({ type: 'reset' }); - return; - } - - const spec = parsed.spec; - const parameters = isJsonObject(spec) && spec.parameters; - if (!Array.isArray(parameters)) { - setErrorText('Template parameters must be an array'); - return; - } - - const fieldValidators = Object.fromEntries( - fieldExtensions.map(({ name, validation }) => [name, validation]), - ); - - setErrorText(); - - dispatch({ - type: 'updateSchema', - filePath: selectedFile.path, - steps: parameters.flatMap(param => - isJsonObject(param) - ? [ - { - title: String(param.title), - schema: param, - validate: createValidator(param, fieldValidators, { - apiHolder, - }), - }, - ] - : [], - ), - }); - } catch (e) { - setErrorText(e.message); - } - }, - 250, - [selectedFile?.path, selectedFile?.content, apiHolder], - ); - - const fields = useMemo(() => { - return Object.fromEntries( - fieldExtensions.map(({ name, component }) => [name, component]), - ); - }, [fieldExtensions]); - - if (!state.steps) { - return null; - } - - const handleDryRun = async () => { - if (!selectedFile) { - return; - } - - await dryRun.execute({ - templateContent: selectedFile.content, - values: state.formData, - files: directoryEditor.files, - }); - }; - - return ( - - dispatch({ type: 'updateData', formData: e.formData })} - onReset={() => dispatch({ type: 'updateData', formData: {} })} - finishButtonLabel="Try It" - onFinish={handleDryRun} - /> - - ); -} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx new file mode 100644 index 0000000000..a9e0182317 --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor/TemplateEditorForm.tsx @@ -0,0 +1,218 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useApiHolder } from '@backstage/core-plugin-api'; +import { JsonObject, JsonValue } from '@backstage/types'; +import React, { Component, ReactNode, useMemo, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import yaml from 'yaml'; +import { FieldExtensionOptions } from '../../../extensions'; +import { TemplateParameterSchema } from '../../../types'; +import { MultistepJsonForm } from '../../MultistepJsonForm'; +import { createValidator } from '../../TemplatePage'; +import { useDirectoryEditor } from './DirectoryEditorContext'; +import { useDryRun } from './DryRunContext'; + +interface ErrorBoundaryProps { + invalidator: unknown; + setErrorText(errorText: string | undefined): void; + children: ReactNode; +} + +interface ErrorBoundaryState { + shouldRender: boolean; +} + +class ErrorBoundary extends Component { + state = { + shouldRender: true, + }; + + componentDidUpdate(prevProps: { invalidator: unknown }) { + if (prevProps.invalidator !== this.props.invalidator) { + this.setState({ shouldRender: true }); + } + } + + componentDidCatch(error: Error) { + this.props.setErrorText(error.message); + this.setState({ shouldRender: false }); + } + + render() { + return this.state.shouldRender ? this.props.children : null; + } +} + +interface TemplateEditorFormProps { + content?: string; + /** Setting this to true will cause the content to be parsed as if it is the template entity spec */ + contentIsSpec?: boolean; + data: JsonObject; + onUpdate: (data: JsonObject) => void; + setErrorText: (errorText?: string) => void; + + onDryRun?: (data: JsonObject) => Promise; + fieldExtensions?: FieldExtensionOptions[]; +} + +function isJsonObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Shows the a template form that is parsed from the provided content */ +export function TemplateEditorForm(props: TemplateEditorFormProps) { + const { + content, + contentIsSpec, + data, + onUpdate, + onDryRun, + setErrorText, + fieldExtensions = [], + } = props; + const apiHolder = useApiHolder(); + + const [steps, setSteps] = useState(); + + const fields = useMemo(() => { + return Object.fromEntries( + fieldExtensions.map(({ name, component }) => [name, component]), + ); + }, [fieldExtensions]); + + useDebounce( + () => { + try { + if (!content) { + setSteps(undefined); + return; + } + const parsed: JsonValue = yaml.parse(content); + + if (!isJsonObject(parsed)) { + setSteps(undefined); + return; + } + + let rootObj = parsed; + if (!contentIsSpec) { + const isTemplate = + String(parsed.kind).toLocaleLowerCase('en-US') === 'template'; + if (!isTemplate) { + setSteps(undefined); + return; + } + + rootObj = isJsonObject(parsed.spec) ? parsed.spec : {}; + } + + const { parameters } = rootObj; + if (!Array.isArray(parameters)) { + setErrorText('Template parameters must be an array'); + setSteps(undefined); + return; + } + + const fieldValidators = Object.fromEntries( + fieldExtensions.map(({ name, validation }) => [name, validation]), + ); + + setErrorText(); + setSteps( + parameters.flatMap(param => + isJsonObject(param) + ? [ + { + title: String(param.title), + schema: param, + validate: createValidator(param, fieldValidators, { + apiHolder, + }), + }, + ] + : [], + ), + ); + } catch (e) { + setErrorText(e.message); + } + }, + 250, + [contentIsSpec, content, apiHolder], + ); + + if (!steps) { + return null; + } + + return ( + + onUpdate(e.formData)} + onReset={() => onUpdate({})} + finishButtonLabel={onDryRun && 'Try It'} + onFinish={onDryRun && (() => onDryRun(data))} + /> + + ); +} + +/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */ +export function TemplateEditorFormDirectoryEditorDryRun( + props: Pick, +) { + const { setErrorText, fieldExtensions = [] } = props; + const dryRun = useDryRun(); + + const directoryEditor = useDirectoryEditor(); + const { selectedFile } = directoryEditor; + + const [data, setData] = useState({}); + + const handleDryRun = async () => { + if (!selectedFile) { + return; + } + + await dryRun.execute({ + templateContent: selectedFile.content, + values: data, + files: directoryEditor.files, + }); + }; + + const content = + selectedFile && selectedFile.path.match(/\.ya?ml$/) + ? selectedFile.content + : undefined; + + return ( + + ); +} + +TemplateEditorForm.DirectoryEditorDryRun = + TemplateEditorFormDirectoryEditorDryRun;