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;