From 3ab148535476554ee0e8f03efdb222df7c2dd982 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 10 Feb 2023 12:56:15 +0100 Subject: [PATCH 1/6] chore: added FieldOverrides to the scaffolder form Signed-off-by: blam --- .../FieldOverrides/DescriptionField.tsx | 22 +++++++++++++++++++ .../Stepper/FieldOverrides/index.ts | 16 ++++++++++++++ .../src/next/components/Stepper/Stepper.tsx | 3 ++- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 plugins/scaffolder-react/src/next/components/Stepper/FieldOverrides/DescriptionField.tsx create mode 100644 plugins/scaffolder-react/src/next/components/Stepper/FieldOverrides/index.ts diff --git a/plugins/scaffolder-react/src/next/components/Stepper/FieldOverrides/DescriptionField.tsx b/plugins/scaffolder-react/src/next/components/Stepper/FieldOverrides/DescriptionField.tsx new file mode 100644 index 0000000000..eded38beee --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Stepper/FieldOverrides/DescriptionField.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2021 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 { MarkdownContent } from '@backstage/core-components'; +import { FieldProps } from '@rjsf/utils'; + +export const DescriptionField = ({ description }: FieldProps) => + description && ; diff --git a/plugins/scaffolder-react/src/next/components/Stepper/FieldOverrides/index.ts b/plugins/scaffolder-react/src/next/components/Stepper/FieldOverrides/index.ts new file mode 100644 index 0000000000..d6d0bf3499 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Stepper/FieldOverrides/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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. + */ +export { DescriptionField } from './DescriptionField'; diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index f802121597..dc04bb1f47 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -39,6 +39,7 @@ import { FormProps } from '../../types'; import { LayoutOptions } from '../../../layouts'; import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps'; import { hasErrors } from './utils'; +import * as FieldOverrides from './FieldOverrides'; const useStyles = makeStyles(theme => ({ backButton: { @@ -175,7 +176,7 @@ export const Stepper = (stepperProps: StepperProps) => { schema={currentStep.schema} uiSchema={currentStep.uiSchema} onSubmit={handleNext} - fields={extensions} + fields={{ ...FieldOverrides, ...extensions }} showErrorList={false} onChange={handleChange} {...(props.FormProps ?? {})} From a07750745b9ab19054577bceff223b5d198558fa Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 10 Feb 2023 12:57:34 +0100 Subject: [PATCH 2/6] chore: added changeset Signed-off-by: blam --- .changeset/heavy-comics-applaud.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/heavy-comics-applaud.md diff --git a/.changeset/heavy-comics-applaud.md b/.changeset/heavy-comics-applaud.md new file mode 100644 index 0000000000..faa0aabb4c --- /dev/null +++ b/.changeset/heavy-comics-applaud.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': minor +--- + +Added `DescriptionField` field override to the `next/scaffolder` From 7c145f21ecb62e619823055fb0215c211312d12a Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 10 Feb 2023 14:38:39 +0100 Subject: [PATCH 3/6] chore: make the new TemplateEditor journey work with the new form work Signed-off-by: blam --- plugins/scaffolder-react/api-report.md | 7 +- .../src/next/components/Form/Form.tsx | 23 ++ .../src/next/components/Form/index.ts | 17 ++ .../src/next/components/Stepper/Stepper.tsx | 8 +- .../src/next/components/index.ts | 1 + plugins/scaffolder/package.json | 1 + plugins/scaffolder/src/next/Router/Router.tsx | 14 + .../CustomFieldExplorer.tsx | 196 ++++++++++++++ .../DirectoryEditorContext.tsx | 243 ++++++++++++++++++ .../TemplateEditorPage/DryRunContext.test.tsx | 44 ++++ .../next/TemplateEditorPage/DryRunContext.tsx | 179 +++++++++++++ .../DryRunResults/DryRunResults.test.tsx | 118 +++++++++ .../DryRunResults/DryRunResults.tsx | 91 +++++++ .../DryRunResults/DryRunResultsList.test.tsx | 95 +++++++ .../DryRunResults/DryRunResultsList.tsx | 83 ++++++ .../DryRunResults/DryRunResultsSplitView.tsx | 55 ++++ .../DryRunResults/DryRunResultsView.test.tsx | 99 +++++++ .../DryRunResults/DryRunResultsView.tsx | 195 ++++++++++++++ .../TemplateEditorPage/DryRunResults/index.ts | 17 ++ .../TemplateEditorPage/TemplateEditor.tsx | 94 +++++++ .../TemplateEditorBrowser.test.tsx | 55 ++++ .../TemplateEditorBrowser.tsx | 100 +++++++ .../TemplateEditorPage/TemplateEditorForm.tsx | 234 +++++++++++++++++ .../TemplateEditorIntro.tsx | 144 +++++++++++ .../TemplateEditorPage.test.tsx | 58 +++++ .../TemplateEditorPage/TemplateEditorPage.tsx | 107 ++++++++ .../TemplateEditorTextArea.tsx | 153 +++++++++++ .../TemplateFormPreviewer.tsx | 219 ++++++++++++++++ .../src/next/TemplateEditorPage/index.ts | 17 ++ yarn.lock | 1 + 30 files changed, 2661 insertions(+), 7 deletions(-) create mode 100644 plugins/scaffolder-react/src/next/components/Form/Form.tsx create mode 100644 plugins/scaffolder-react/src/next/components/Form/index.ts create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/CustomFieldExplorer.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.test.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/index.ts create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.test.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.test.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx create mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/index.ts diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md index 72a0b92eef..33246d8bea 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -7,13 +7,14 @@ import { ApiHolder } from '@backstage/core-plugin-api'; import { ApiRef } from '@backstage/core-plugin-api'; +import { ComponentType } from 'react'; import { Dispatch } from 'react'; import { Extension } from '@backstage/core-plugin-api'; import { FieldProps } from '@rjsf/core'; import { FieldProps as FieldProps_2 } from '@rjsf/utils'; import { FieldValidation } from '@rjsf/core'; import { FieldValidation as FieldValidation_2 } from '@rjsf/utils'; -import type { FormProps as FormProps_2 } from '@rjsf/core-v5'; +import { FormProps as FormProps_2 } from '@rjsf/core-v5'; import { IconComponent } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; @@ -22,6 +23,7 @@ import { Observable } from '@backstage/types'; import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; +import { RJSFSchema } from '@rjsf/utils'; import { SetStateAction } from 'react'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { TaskStep } from '@backstage/plugin-scaffolder-common'; @@ -126,6 +128,9 @@ export type FieldExtensionOptions< schema?: CustomFieldExtensionSchema; }; +// @alpha (undocumented) +export const Form: ComponentType>; + // @public export type FormProps = Pick< FormProps_2, diff --git a/plugins/scaffolder-react/src/next/components/Form/Form.tsx b/plugins/scaffolder-react/src/next/components/Form/Form.tsx new file mode 100644 index 0000000000..79bcaa8475 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/Form.tsx @@ -0,0 +1,23 @@ +/* + * 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 { withTheme } from '@rjsf/core-v5'; + +// TODO(blam): We require here, as the types in this package depend on @rjsf/core explicitly +// which is what we're using here as the default types, it needs to depend on @rjsf/core-v5 because +// of the re-writing we're doing. Once we've migrated, we can import this the exact same as before. +/** @alpha */ +export const Form = withTheme(require('@rjsf/material-ui-v5').Theme); diff --git a/plugins/scaffolder-react/src/next/components/Form/index.ts b/plugins/scaffolder-react/src/next/components/Form/index.ts new file mode 100644 index 0000000000..6f780e92a4 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Form/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { Form } from './Form'; diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index dc04bb1f47..bb2844fd52 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -22,7 +22,7 @@ import { Button, makeStyles, } from '@material-ui/core'; -import { type IChangeEvent, withTheme } from '@rjsf/core-v5'; +import { type IChangeEvent } from '@rjsf/core-v5'; import { ErrorSchema } from '@rjsf/utils'; import React, { useCallback, useMemo, useState, type ReactNode } from 'react'; import { NextFieldExtensionOptions } from '../../extensions'; @@ -40,6 +40,7 @@ import { LayoutOptions } from '../../../layouts'; import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps'; import { hasErrors } from './utils'; import * as FieldOverrides from './FieldOverrides'; +import { Form } from '../Form'; const useStyles = makeStyles(theme => ({ backButton: { @@ -75,11 +76,6 @@ export type StepperProps = { layouts?: LayoutOptions[]; }; -// TODO(blam): We require here, as the types in this package depend on @rjsf/core explicitly -// which is what we're using here as the default types, it needs to depend on @rjsf/core-v5 because -// of the re-writing we're doing. Once we've migrated, we can import this the exact same as before. -const Form = withTheme(require('@rjsf/material-ui-v5').Theme); - /** * The `Stepper` component is the Wizard that is rendered when a user selects a template * @alpha diff --git a/plugins/scaffolder-react/src/next/components/index.ts b/plugins/scaffolder-react/src/next/components/index.ts index 05e5a1105a..60a8fd9a51 100644 --- a/plugins/scaffolder-react/src/next/components/index.ts +++ b/plugins/scaffolder-react/src/next/components/index.ts @@ -19,3 +19,4 @@ export * from './ReviewState'; export * from './TemplateGroup'; export * from './Workflow'; export * from './TemplateOutputs'; +export * from './Form'; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 86f9100deb..08381732c2 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -58,6 +58,7 @@ "@rjsf/core": "^3.2.1", "@rjsf/material-ui": "^3.2.1", "@rjsf/utils": "5.1.0", + "@rjsf/validator-ajv8": "5.1.0", "@uiw/react-codemirror": "^4.9.3", "classnames": "^2.2.6", "git-url-parse": "^13.0.0", diff --git a/plugins/scaffolder/src/next/Router/Router.tsx b/plugins/scaffolder/src/next/Router/Router.tsx index 049579e16c..ded344b048 100644 --- a/plugins/scaffolder/src/next/Router/Router.tsx +++ b/plugins/scaffolder/src/next/Router/Router.tsx @@ -32,6 +32,7 @@ import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default'; import { nextActionsRouteRef, + nextEditRouteRef, nextScaffolderListTaskRouteRef, nextScaffolderTaskRouteRef, nextSelectedTemplateRouteRef, @@ -40,6 +41,7 @@ import { ErrorPage } from '@backstage/core-components'; import { OngoingTask } from '../OngoingTask'; import { ActionsPage } from '../../components/ActionsPage'; import { ListTasksPage } from '../../components/ListTasksPage'; +import { TemplateEditorPage } from '../TemplateEditorPage'; /** * The Props for the Scaffolder Router @@ -130,6 +132,18 @@ export const Router = (props: PropsWithChildren) => { /> } /> + + + + } + /> + } /> ({ + root: { + gridArea: 'pageContent', + display: 'grid', + gridTemplateAreas: ` + "controls controls" + "fieldForm preview" + `, + gridTemplateRows: 'auto 1fr', + gridTemplateColumns: '1fr 1fr', + }, + controls: { + gridArea: 'controls', + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + margin: theme.spacing(1), + }, + fieldForm: { + gridArea: 'fieldForm', + }, + preview: { + gridArea: 'preview', + }, +})); + +export const CustomFieldExplorer = ({ + customFieldExtensions = [], + onClose, +}: { + customFieldExtensions?: NextFieldExtensionOptions[]; + onClose?: () => void; +}) => { + const classes = useStyles(); + const fieldOptions = customFieldExtensions.filter(field => !!field.schema); + const [selectedField, setSelectedField] = useState(fieldOptions[0]); + const [fieldFormState, setFieldFormState] = useState({}); + const [refreshKey, setRefreshKey] = useState(Date.now()); + const sampleFieldTemplate = useMemo( + () => + yaml.stringify({ + parameters: [ + { + title: `${selectedField.name} Example`, + properties: { + [selectedField.name]: { + type: selectedField.schema?.returnValue?.type, + 'ui:field': selectedField.name, + 'ui:options': fieldFormState, + }, + }, + }, + ], + }), + [fieldFormState, selectedField], + ); + + const fieldComponents = useMemo(() => { + return Object.fromEntries( + customFieldExtensions.map(({ name, component }) => [name, component]), + ); + }, [customFieldExtensions]); + + const handleSelectionChange = useCallback( + selection => { + setSelectedField(selection); + setFieldFormState({}); + }, + [setFieldFormState, setSelectedField], + ); + + const handleFieldConfigChange = useCallback( + state => { + setFieldFormState(state); + // Force TemplateEditorForm to re-render since some fields + // may not be responsive to ui:option changes + setRefreshKey(Date.now()); + }, + [setFieldFormState, setRefreshKey], + ); + + return ( +
+
+ + + Choose Custom Field Extension + + + + + + + +
+
+ + + +
handleFieldConfigChange(e.formData)} + validator={validator} + schema={selectedField.schema?.uiOptions || {}} + > + +
+
+
+
+
+ + + + + + + null} + /> +
+
+ ); +}; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx new file mode 100644 index 0000000000..0edf390bfe --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx @@ -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; + /** Reload the staged content of the file from disk */ + reload(): Promise; +} + +interface DirectoryEditor { + /** A list of all files in the edited directory */ + files: Array; + + /** The currently selected file */ + selectedFile: DirectoryEditorFile | undefined; + /** Switch the selected file */ + setSelectedFile(path: string | undefined): void; + + /** Save all files to disk */ + save(): Promise; + /** Reload all files from disk */ + reload(): Promise; + + 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 { + if (this.#content !== undefined) { + await this.#access.save(this.#content); + this.#savedContent = this.#content; + this.#signalUpdate(); + } + } + + async reload(): Promise { + 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 { + await Promise.all(this.#files.map(file => file.save())); + } + + async reload(): Promise { + 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( + 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 ; + } else if (!result) { + return ; + } + + return ( + + {props.children} + + ); +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.test.tsx new file mode 100644 index 0000000000..36a62f1b8a --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { base64EncodeContent } from './DryRunContext'; + +// eslint-disable-next-line no-restricted-imports +import { TextEncoder } from 'util'; + +window.TextEncoder = TextEncoder; + +describe('base64EncodeContent', () => { + it('encodes text files', () => { + expect(base64EncodeContent('abc')).toBe('YWJj'); + expect(base64EncodeContent('abc'.repeat(1000000))).toBe( + window.btoa(''), + ); + }); + + it('encodes binary files', () => { + expect(base64EncodeContent('\x00\x01\x02')).toBe('AAEC'); + expect(base64EncodeContent('😅')).toBe('8J+YhQ=='); + // Triggers chunking + expect(base64EncodeContent('😅'.repeat(18000))).toBe( + '8J+YhfCfmIXwn5iF8J+YhfCfmIXwn5iF'.repeat(3000), + ); + // Triggers size check + expect(base64EncodeContent('😅'.repeat(1000000))).toBe( + window.btoa(''), + ); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.tsx new file mode 100644 index 0000000000..a617eebe1e --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.tsx @@ -0,0 +1,179 @@ +/* + * 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, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react'; +import { + scaffolderApiRef, + ScaffolderDryRunResponse, +} from '@backstage/plugin-scaffolder-react'; + +const MAX_CONTENT_SIZE = 64 * 1024; +const CHUNK_SIZE = 32 * 1024; + +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; +} + +const DryRunContext = createContext(undefined); + +interface DryRunProviderProps { + children: ReactNode; +} + +export function base64EncodeContent(content: string): string { + if (content.length > MAX_CONTENT_SIZE) { + return window.btoa(''); + } + + try { + return window.btoa(content); + } catch { + const decoder = new TextEncoder(); + const buffer = decoder.encode(content); + + const chunks = new Array(); + for (let offset = 0; offset < buffer.length; offset += CHUNK_SIZE) { + chunks.push( + String.fromCharCode(...buffer.slice(offset, offset + CHUNK_SIZE)), + ); + } + return window.btoa(chunks.join('')); + } +} + +export function DryRunProvider(props: DryRunProviderProps) { + const scaffolderApi = useApi(scaffolderApiRef); + + const [state, setState] = useState< + Pick + >({ + results: [], + selectedResult: undefined, + }); + const idRef = useRef(1); + + const selectResult = useCallback((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, + }; + }); + }, []); + + const deleteResult = useCallback((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, + }; + }); + }, []); + + const execute = useCallback( + 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: {}, + directoryContents: options.files.map(file => ({ + path: file.path, + base64Content: base64EncodeContent(file.content), + })), + }); + + const result = { + ...response, + id: idRef.current++, + }; + + setState(prevState => ({ + results: [...prevState.results, result], + selectedResult: prevState.selectedResult ?? result, + })); + }, + [scaffolderApi], + ); + + const dryRun = useMemo( + () => ({ + ...state, + selectResult, + deleteResult, + execute, + }), + [state, selectResult, deleteResult, execute], + ); + + return ( + + {props.children} + + ); +} + +export function useDryRun(): DryRun { + const value = useContext(DryRunContext); + if (!value) { + throw new Error('must be used within a DryRunProvider'); + } + return value; +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx new file mode 100644 index 0000000000..0267093ee6 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { useEffect } from 'react'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import { DryRunProvider, useDryRun } from '../DryRunContext'; +import { DryRunResults } from './DryRunResults'; + +function DryRunRemote({ + execute, + remove, +}: { + execute?: boolean; + remove?: boolean; +}) { + const dryRun = useDryRun(); + + useEffect(() => { + if (execute) { + dryRun.execute({ + templateContent: '', + values: {}, + files: [], + }); + } + if (remove) { + if (dryRun.selectedResult) { + dryRun.deleteResult(dryRun.selectedResult.id); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [execute, remove]); + + return null; +} + +const mockScaffolderApi = { + dryRun: async () => ({ + directoryContents: [], + log: [], + output: {}, + steps: [], + }), +}; + +describe('DryRunResults', () => { + it('renders without exploding', async () => { + await renderInTestApp( + + + + + , + ); + expect(screen.getByText('Dry-run results')).toBeInTheDocument(); + }); + + it('expands when dry-run result is added and toggles on click, and disappears when results are gone', async () => { + const { rerender } = await renderInTestApp( + + + + + + , + ); + + expect(screen.getByText('Files')).not.toBeVisible(); + + await act(async () => { + rerender( + + + + + + , + ); + }); + + expect(screen.getByText('Files')).toBeVisible(); + + await userEvent.click(screen.getByText('Dry-run results')); + await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible()); + + await userEvent.click(screen.getByText('Dry-run results')); + expect(screen.getByText('Files')).toBeVisible(); + + await act(async () => { + rerender( + + + + + + , + ); + }); + + await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible()); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.tsx new file mode 100644 index 0000000000..ae9374438f --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.tsx @@ -0,0 +1,91 @@ +/* + * 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 { BackstageTheme } from '@backstage/theme'; +import Accordion from '@material-ui/core/Accordion'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import Divider from '@material-ui/core/Divider'; +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import ExpandMoreIcon from '@material-ui/icons/ExpandLess'; +import { usePrevious } from '@react-hookz/web'; +import React, { useEffect, useState } from 'react'; +import { useDryRun } from '../DryRunContext'; +import { DryRunResultsList } from './DryRunResultsList'; +import { DryRunResultsView } from './DryRunResultsView'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + header: { + height: 48, + minHeight: 0, + '&.Mui-expanded': { + height: 48, + minHeight: 0, + }, + }, + content: { + display: 'grid', + background: theme.palette.background.default, + gridTemplateColumns: '180px auto 1fr', + gridTemplateRows: '1fr', + padding: 0, + height: 400, + }, +})); + +export function DryRunResults() { + const classes = useStyles(); + const dryRun = useDryRun(); + const [expanded, setExpanded] = useState(false); + const [hidden, setHidden] = useState(true); + + const resultsLength = dryRun.results.length; + const prevResultsLength = usePrevious(resultsLength); + useEffect(() => { + if (prevResultsLength === 0 && resultsLength === 1) { + setHidden(false); + setExpanded(true); + } else if (prevResultsLength === 1 && resultsLength === 0) { + setExpanded(false); + } + }, [prevResultsLength, resultsLength]); + + return ( + <> + + + ); +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx new file mode 100644 index 0000000000..706f284db1 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx @@ -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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { act, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { useEffect } from 'react'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import { DryRunProvider, useDryRun } from '../DryRunContext'; +import { DryRunResultsList } from './DryRunResultsList'; + +function DryRunRemote({ execute }: { execute?: number }) { + const dryRun = useDryRun(); + + useEffect(() => { + if (execute) { + dryRun.execute({ + templateContent: '', + values: {}, + files: [], + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [execute]); + + return null; +} + +const mockScaffolderApi = { + dryRun: async () => ({ + directoryContents: [], + log: [], + output: {}, + steps: [], + }), +}; + +describe('DryRunResultsList', () => { + it('renders without exploding', async () => { + const rendered = await renderInTestApp( + + + + + , + ); + expect(rendered.baseElement.querySelector('ul')).toBeEmptyDOMElement(); + }); + + it('adds new result items and deletes them', async () => { + const { rerender } = await renderInTestApp( + + + + + + , + ); + + expect(screen.getByText('Result 1')).toBeInTheDocument(); + expect(screen.queryByText('Result 2')).not.toBeInTheDocument(); + + await act(async () => { + rerender( + + + + + + , + ); + }); + + expect(screen.getByText('Result 1')).toBeInTheDocument(); + expect(screen.getByText('Result 2')).toBeInTheDocument(); + + await userEvent.click(screen.getAllByLabelText('delete')[0]); + + expect(screen.queryByText('Result 1')).not.toBeInTheDocument(); + expect(screen.getByText('Result 2')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx new file mode 100644 index 0000000000..462c3b58fd --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx @@ -0,0 +1,83 @@ +/* + * 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 { BackstageTheme } from '@backstage/theme'; +import IconButton from '@material-ui/core/IconButton'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import ListItemText from '@material-ui/core/ListItemText'; +import { makeStyles } from '@material-ui/core/styles'; +import CancelIcon from '@material-ui/icons/Cancel'; +import CheckIcon from '@material-ui/icons/Check'; +import DeleteIcon from '@material-ui/icons/Delete'; +import React from 'react'; +import { useDryRun } from '../DryRunContext'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + root: { + overflowY: 'auto', + background: theme.palette.background.default, + }, + iconSuccess: { + minWidth: 0, + marginRight: theme.spacing(1), + color: theme.palette.status.ok, + }, + iconFailure: { + minWidth: 0, + marginRight: theme.spacing(1), + color: theme.palette.status.error, + }, +})); + +export function DryRunResultsList() { + const classes = useStyles(); + const dryRun = useDryRun(); + + return ( + + {dryRun.results.map(result => { + const failed = result.log.some(l => l.body.status === 'failed'); + return ( + dryRun.selectResult(result.id)} + > + + {failed ? : } + + + + dryRun.deleteResult(result.id)} + > + + + + + ); + })} + + ); +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx new file mode 100644 index 0000000000..b554a1c0c0 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx @@ -0,0 +1,55 @@ +/* + * 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 React, { Children, ReactNode } from 'react'; +import classNames from 'classnames'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'grid', + gridTemplateColumns: '280px auto 3fr', + gridTemplateRows: '1fr', + }, + child: { + overflowY: 'auto', + height: '100%', + minHeight: 0, + }, + firstChild: { + background: theme.palette.background.paper, + }, +})); + +export function DryRunResultsSplitView(props: { children: ReactNode }) { + const classes = useStyles(); + const childArray = Children.toArray(props.children); + + if (childArray.length !== 2) { + throw new Error('must have exactly 2 children'); + } + + return ( +
+
+ {childArray[0]} +
+ +
{childArray[1]}
+
+ ); +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx new file mode 100644 index 0000000000..c2d8d3a2a4 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx @@ -0,0 +1,99 @@ +/* + * 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 { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React, { ReactNode, useEffect } from 'react'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import { DryRunProvider, useDryRun } from '../DryRunContext'; +import { DryRunResultsView } from './DryRunResultsView'; + +// The inside needs mocking to render in jsdom +jest.mock('react-virtualized-auto-sizer', () => ({ + __esModule: true, + default: (props: { + children: (size: { width: number; height: number }) => ReactNode; + }) => <>{props.children({ width: 400, height: 200 })}, +})); + +function DryRunRemote() { + const dryRun = useDryRun(); + + useEffect(() => { + dryRun.execute({ + templateContent: '', + values: {}, + files: [], + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +} + +describe('DryRunResultsView', () => { + it('renders a result', async () => { + await renderInTestApp( + ({ + directoryContents: [ + { + path: 'foo.txt', + base64Content: window.btoa('Foo Content'), + executable: false, + }, + ], + log: [{ body: { message: 'Foo Message', stepId: 'foo' } }], + output: { + links: [{ title: 'Foo Link', url: 'http://example.com' }], + }, + steps: [{ id: 'foo', action: 'foo', name: 'Foo' }], + }), + }, + ], + ]} + > + + + + + , + { + mountedRoutes: { + '/catalog/:kind/:namespace/:name': entityRouteRef, + }, + }, + ); + + expect(screen.getByText('foo.txt')).toBeInTheDocument(); + expect(screen.getByText('Foo Content')).toBeInTheDocument(); + + expect(screen.queryByText('Foo Message')).not.toBeInTheDocument(); + expect(screen.queryByText('Foo Link')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Log')); + expect(screen.getByText('Foo Message')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Output')); + expect(screen.getByText('Foo Link')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx new file mode 100644 index 0000000000..852ec05a82 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx @@ -0,0 +1,195 @@ +/* + * 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 { LogViewer } from '@backstage/core-components'; +import { StreamLanguage } from '@codemirror/language'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; +import Box from '@material-ui/core/Box'; +import Divider from '@material-ui/core/Divider'; +import { makeStyles } from '@material-ui/core/styles'; +import Tab from '@material-ui/core/Tab'; +import Tabs from '@material-ui/core/Tabs'; +import CodeMirror from '@uiw/react-codemirror'; +import React, { useEffect, useMemo, useState } from 'react'; +import { FileBrowser } from '../../../components/FileBrowser'; +import { TaskStatusStepper } from '../../../components/TaskPage/TaskPage'; +import { TaskPageLinks } from '../../../components/TaskPage/TaskPageLinks'; +import { useDryRun } from '../DryRunContext'; +import { DryRunResultsSplitView } from './DryRunResultsSplitView'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + flexFlow: 'column nowrap', + }, + contentWrapper: { + flex: 1, + position: 'relative', + }, + content: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + + display: 'flex', + '& > *': { + flex: 1, + }, + }, + codeMirror: { + height: '100%', + overflowY: 'auto', + }, +}); + +function FilesContent() { + const classes = useStyles(); + const { selectedResult } = useDryRun(); + const [selectedPath, setSelectedPath] = useState(''); + const selectedFile = selectedResult?.directoryContents.find( + f => f.path === selectedPath, + ); + + useEffect(() => { + if (selectedResult) { + const [firstFile] = selectedResult.directoryContents; + if (firstFile) { + setSelectedPath(firstFile.path); + } else { + setSelectedPath(''); + } + } + return undefined; + }, [selectedResult]); + + if (!selectedResult) { + return null; + } + return ( + + file.path)} + /> + + + ); +} +function LogContent() { + const { selectedResult } = useDryRun(); + const [currentStepId, setUserSelectedStepId] = useState(); + + const steps = useMemo(() => { + if (!selectedResult) { + return []; + } + return ( + selectedResult.steps.map(step => { + const stepLog = selectedResult.log.filter( + l => l.body.stepId === step.id, + ); + return { + id: step.id, + name: step.name, + logString: stepLog.map(l => l.body.message).join('\n'), + status: stepLog[stepLog.length - 1]?.body.status ?? 'completed', + }; + }) ?? [] + ); + }, [selectedResult]); + + if (!selectedResult) { + return null; + } + + const selectedStep = steps.find(s => s.id === currentStepId) ?? steps[0]; + + return ( + + + + + ); +} + +function OutputContent() { + const classes = useStyles(); + const { selectedResult } = useDryRun(); + + if (!selectedResult) { + return null; + } + + return ( + + + {selectedResult.output?.links?.length && ( + + )} + + + + ); +} + +export function DryRunResultsView() { + const classes = useStyles(); + const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>( + 'files', + ); + + return ( +
+ setSelectedTab(v)}> + + + + + + +
+
+ {selectedTab === 'files' && } + {selectedTab === 'log' && } + {selectedTab === 'output' && } +
+
+
+ ); +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/index.ts b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/index.ts new file mode 100644 index 0000000000..af8ef5214f --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { DryRunResults } from './DryRunResults'; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx new file mode 100644 index 0000000000..c5556c3b06 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx @@ -0,0 +1,94 @@ +/* + * 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'; +import React, { useState } from 'react'; +import type { + LayoutOptions, + NextFieldExtensionOptions, +} from '@backstage/plugin-scaffolder-react'; +import { TemplateDirectoryAccess } from '../../lib/filesystem'; +import { DirectoryEditorProvider } from './DirectoryEditorContext'; +import { DryRunProvider } from './DryRunContext'; +import { DryRunResults } from './DryRunResults'; +import { TemplateEditorBrowser } from './TemplateEditorBrowser'; +import { TemplateEditorForm } from './TemplateEditorForm'; +import { TemplateEditorTextArea } from './TemplateEditorTextArea'; + +const useStyles = makeStyles({ + // Reset and fix sizing to make sure scrolling behaves correctly + root: { + gridArea: 'pageContent', + + display: 'grid', + gridTemplateAreas: ` + "browser editor preview" + "results results results" + `, + gridTemplateColumns: '1fr 3fr 2fr', + gridTemplateRows: '1fr auto', + }, + browser: { + gridArea: 'browser', + overflow: 'auto', + }, + editor: { + gridArea: 'editor', + overflow: 'auto', + }, + preview: { + gridArea: 'preview', + overflow: 'auto', + }, + results: { + gridArea: 'results', + }, +}); + +export const TemplateEditor = (props: { + directory: TemplateDirectoryAccess; + fieldExtensions?: NextFieldExtensionOptions[]; + layouts?: LayoutOptions[]; + onClose?: () => void; +}) => { + const classes = useStyles(); + + const [errorText, setErrorText] = useState(); + + return ( + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +}; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.test.tsx new file mode 100644 index 0000000000..cec311a5cd --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { MockFileSystemAccess } from '../../lib/filesystem/MockFileSystemAccess'; +import { DirectoryEditorProvider } from './DirectoryEditorContext'; +import { TemplateEditorBrowser } from './TemplateEditorBrowser'; + +Blob.prototype.text = async function text() { + return new Promise(resolve => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsText(this); + }); +}; + +describe('TemplateEditorBrowser', () => { + it('should render files and expand dirs without exploding', async () => { + await renderInTestApp( + + + , + ); + + await expect(screen.findByText('foo.txt')).resolves.toBeInTheDocument(); + expect(screen.getByText('dir')).toBeInTheDocument(); + expect(screen.queryByText('bar.txt')).not.toBeInTheDocument(); + expect(screen.queryByText('baz.txt')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('dir')); + expect(screen.getByText('bar.txt')).toBeInTheDocument(); + expect(screen.getByText('baz.txt')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx new file mode 100644 index 0000000000..a668aa1868 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx @@ -0,0 +1,100 @@ +/* + * 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 { 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 from 'react'; +import { FileBrowser } from '../../components/FileBrowser'; + +import { useDirectoryEditor } from './DirectoryEditorContext'; + +const useStyles = makeStyles(theme => ({ + button: { + padding: theme.spacing(1), + }, + buttons: { + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + justifyContent: 'flex-start', + }, + buttonsGap: { + flex: '1 1 auto', + }, + buttonsDivider: { + marginBottom: theme.spacing(1), + }, +})); + +/** The local file browser for the template editor */ +export function TemplateEditorBrowser(props: { onClose?: () => void }) { + const classes = useStyles(); + const directoryEditor = useDirectoryEditor(); + const changedFiles = directoryEditor.files.filter(file => file.dirty); + + const handleClose = () => { + if (!props.onClose) { + return; + } + if (changedFiles.length > 0) { + // eslint-disable-next-line no-alert + const accepted = window.confirm( + 'Are you sure? Unsaved changes will be lost', + ); + if (!accepted) { + return; + } + } + props.onClose(); + }; + + return ( + <> +
+ + !file.dirty)} + onClick={() => directoryEditor.save()} + > + + + + + directoryEditor.reload()} + > + + + +
+ + + + + +
+ + file.path)} + /> + + ); +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx new file mode 100644 index 0000000000..0ac604c75d --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx @@ -0,0 +1,234 @@ +/* + * 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 { makeStyles } from '@material-ui/core/styles'; +import React, { Component, ReactNode, useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; +import yaml from 'yaml'; +import { + LayoutOptions, + NextFieldExtensionOptions, + Stepper, + TemplateParameterSchema, +} from '@backstage/plugin-scaffolder-react'; + +import { useDirectoryEditor } from './DirectoryEditorContext'; +import { useDryRun } from './DryRunContext'; + +const useStyles = makeStyles({ + containerWrapper: { + position: 'relative', + width: '100%', + height: '100%', + }, + container: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + overflow: 'auto', + }, +}); + +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; + setErrorText: (errorText?: string) => void; + onDryRun?: (data: JsonObject) => Promise; + fieldExtensions?: NextFieldExtensionOptions[]; + layouts?: LayoutOptions[]; +} + +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, + onDryRun, + setErrorText, + fieldExtensions = [], + layouts = [], + } = props; + const classes = useStyles(); + const apiHolder = useApiHolder(); + + const [steps, setSteps] = useState(); + + 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; + } + + setErrorText(); + setSteps( + parameters.flatMap(param => + isJsonObject(param) + ? [ + { + title: String(param.title), + schema: param, + }, + ] + : [], + ), + ); + } catch (e) { + setErrorText(e.message); + } + }, + 250, + [contentIsSpec, content, apiHolder], + ); + + if (!steps) { + return null; + } + + return ( +
+
+ + { + await onDryRun?.(data); + }} + layouts={layouts} + components={{ createButtonText: onDryRun && 'Try It' }} + /> + +
+
+ ); +} + +/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */ +export function TemplateEditorFormDirectoryEditorDryRun( + props: Pick< + TemplateEditorFormProps, + 'setErrorText' | 'fieldExtensions' | 'layouts' + >, +) { + const { setErrorText, fieldExtensions = [], layouts } = props; + const dryRun = useDryRun(); + + const directoryEditor = useDirectoryEditor(); + const { selectedFile } = directoryEditor; + + const handleDryRun = async (values: JsonObject) => { + if (!selectedFile) { + return; + } + + try { + await dryRun.execute({ + templateContent: selectedFile.content, + values, + files: directoryEditor.files, + }); + setErrorText(); + } catch (e) { + setErrorText(String(e.cause || e)); + throw e; + } + }; + + const content = + selectedFile && selectedFile.path.match(/\.ya?ml$/) + ? selectedFile.content + : undefined; + + return ( + + ); +} + +TemplateEditorForm.DirectoryEditorDryRun = + TemplateEditorFormDirectoryEditorDryRun; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx new file mode 100644 index 0000000000..61e6c46964 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx @@ -0,0 +1,144 @@ +/* + * 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' | 'field-explorer') => void; +} + +export function TemplateEditorIntro(props: EditorIntroProps) { + const classes = useStyles(); + const supportsLoad = WebFileSystemAccess.isSupported(); + + const cardLoadLocal = ( + + props.onSelect?.('local')} + > + + + Load Template Directory + + + Load a local template directory, allowing you to both edit and try + executing your own template. + + + + {!supportsLoad && ( +
+ + + +
+ )} +
+ ); + + const cardFormEditor = ( + + props.onSelect?.('form')}> + + + Edit Template Form + + + Preview and edit a template form, either using a sample template or + by loading a template from the catalog. + + + + + ); + + const cardFieldExplorer = ( + + props.onSelect?.('field-explorer')}> + + + Custom Field Explorer + + + View and play around with available installed custom field + extensions. + + + + + ); + + return ( +
+ + Get started by choosing one of the options below + +
+ {supportsLoad && cardLoadLocal} + {cardFormEditor} + {!supportsLoad && cardLoadLocal} + {cardFieldExplorer} +
+
+ ); +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.test.tsx new file mode 100644 index 0000000000..bf9cd8c464 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.test.tsx @@ -0,0 +1,58 @@ +/* + * 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 { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TemplateEditorPage } from './TemplateEditorPage'; + +describe('TemplateEditorPage', () => { + it('renders without exploding', async () => { + await renderInTestApp(); + + expect(screen.getByText('Load Template Directory')).toBeInTheDocument(); + expect(screen.getByText('Edit Template Form')).toBeInTheDocument(); + }); + + it('template directory loading should not be supported in Jest', async () => { + await renderInTestApp(); + + expect( + screen.getByRole('button', { name: /Load Template Directory/ }), + ).toBeDisabled(); + }); + + it('should be able to continue to form preview', async () => { + await renderInTestApp( + + + , + ); + + await userEvent.click(screen.getByText('Edit Template Form')); + + expect(screen.getByLabelText('Load Existing Template')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx new file mode 100644 index 0000000000..03e1619bc8 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx @@ -0,0 +1,107 @@ +/* + * 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, { useState } from 'react'; +import { Content, Header, Page } from '@backstage/core-components'; +import { + TemplateDirectoryAccess, + WebFileSystemAccess, +} from '../../lib/filesystem'; +import { CustomFieldExplorer } from './CustomFieldExplorer'; +import { TemplateEditorIntro } from './TemplateEditorIntro'; +import { TemplateEditor } from './TemplateEditor'; +import { TemplateFormPreviewer } from './TemplateFormPreviewer'; +import { + NextFieldExtensionOptions, + type LayoutOptions, +} from '@backstage/plugin-scaffolder-react'; + +type Selection = + | { + type: 'local'; + directory: TemplateDirectoryAccess; + } + | { + type: 'form'; + } + | { + type: 'field-explorer'; + }; + +interface TemplateEditorPageProps { + defaultPreviewTemplate?: string; + customFieldExtensions?: NextFieldExtensionOptions[]; + layouts?: LayoutOptions[]; +} + +export function TemplateEditorPage(props: TemplateEditorPageProps) { + const [selection, setSelection] = useState(); + + let content: JSX.Element | null = null; + if (selection?.type === 'local') { + content = ( + setSelection(undefined)} + layouts={props.layouts} + /> + ); + } else if (selection?.type === 'form') { + content = ( + setSelection(undefined)} + layouts={props.layouts} + /> + ); + } else if (selection?.type === 'field-explorer') { + content = ( + setSelection(undefined)} + /> + ); + } else { + content = ( + + { + if (option === 'local') { + WebFileSystemAccess.requestDirectoryAccess() + .then(directory => setSelection({ type: 'local', directory })) + .catch(() => {}); + } else if (option === 'form') { + setSelection({ type: 'form' }); + } else if (option === 'field-explorer') { + setSelection({ type: 'field-explorer' }); + } + }} + /> + + ); + } + + return ( + +
+ {content} + + ); +} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx new file mode 100644 index 0000000000..588b0cd4ba --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx @@ -0,0 +1,153 @@ +/* + * 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 { StreamLanguage } from '@codemirror/language'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; +import { showPanel } from '@codemirror/view'; +import { IconButton, makeStyles, Paper, Tooltip } from '@material-ui/core'; +import RefreshIcon from '@material-ui/icons/Refresh'; +import SaveIcon from '@material-ui/icons/Save'; +import { useKeyboardEvent } from '@react-hookz/web'; +import CodeMirror from '@uiw/react-codemirror'; +import React, { useMemo } from 'react'; +import { useDirectoryEditor } from './DirectoryEditorContext'; + +const useStyles = makeStyles(theme => ({ + container: { + position: 'relative', + width: '100%', + height: '100%', + }, + codeMirror: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + errorPanel: { + color: theme.palette.error.main, + lineHeight: 2, + margin: theme.spacing(0, 1), + }, + floatingButtons: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(3), + }, + floatingButton: { + padding: theme.spacing(1), + }, +})); + +/** A wrapper around CodeMirror with an error panel and extra actions available */ +export function TemplateEditorTextArea(props: { + content?: string; + onUpdate?: (content: string) => void; + errorText?: string; + onSave?: () => void; + onReload?: () => void; +}) { + const { errorText } = props; + const classes = useStyles(); + + const panelExtension = useMemo(() => { + if (!errorText) { + return showPanel.of(null); + } + + const dom = document.createElement('div'); + dom.classList.add(classes.errorPanel); + dom.textContent = errorText; + return showPanel.of(() => ({ dom, bottom: true })); + }, [classes, errorText]); + + useKeyboardEvent( + e => e.key === 's' && (e.ctrlKey || e.metaKey), + e => { + e.preventDefault(); + if (props.onSave) { + props.onSave(); + } + }, + ); + + return ( +
+ + {(props.onSave || props.onReload) && ( +
+ + {props.onSave && ( + + props.onSave?.()} + > + + + + )} + {props.onReload && ( + + props.onReload?.()} + > + + + + )} + +
+ )} +
+ ); +} + +/** A version of the TemplateEditorTextArea that is connected to the DirectoryEditor context */ +export function TemplateEditorDirectoryEditorTextArea(props: { + errorText?: string; +}) { + const directoryEditor = useDirectoryEditor(); + + const actions = directoryEditor.selectedFile?.dirty + ? { + onSave: () => directoryEditor.save(), + onReload: () => directoryEditor.reload(), + } + : { + onReload: () => directoryEditor.reload(), + }; + + return ( + directoryEditor.selectedFile?.updateContent(content)} + {...actions} + /> + ); +} + +TemplateEditorTextArea.DirectoryEditor = TemplateEditorDirectoryEditorTextArea; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx new file mode 100644 index 0000000000..c5f84a4b66 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -0,0 +1,219 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; +import { + catalogApiRef, + humanizeEntityRef, +} from '@backstage/plugin-catalog-react'; +import { + FormControl, + IconButton, + InputLabel, + LinearProgress, + makeStyles, + MenuItem, + Select, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import React, { useCallback, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import yaml from 'yaml'; +import { + NextFieldExtensionOptions, + type LayoutOptions, +} from '@backstage/plugin-scaffolder-react'; +import { TemplateEditorForm } from './TemplateEditorForm'; +import { TemplateEditorTextArea } from './TemplateEditorTextArea'; + +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: + catalogFilter: + kind: 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 => ({ + root: { + gridArea: 'pageContent', + display: 'grid', + gridTemplateAreas: ` + "controls controls" + "textArea preview" + `, + gridTemplateRows: 'auto 1fr', + gridTemplateColumns: '1fr 1fr', + }, + controls: { + gridArea: 'controls', + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + margin: theme.spacing(1), + }, + textArea: { + gridArea: 'textArea', + }, + preview: { + gridArea: 'preview', + }, +})); + +export const TemplateFormPreviewer = ({ + defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML, + customFieldExtensions = [], + onClose, + layouts = [], +}: { + defaultPreviewTemplate?: string; + customFieldExtensions?: NextFieldExtensionOptions[]; + onClose?: () => void; + layouts?: LayoutOptions[]; +}) => { + const classes = useStyles(); + const alertApi = useApi(alertApiRef); + const catalogApi = useApi(catalogApiRef); + const [selectedTemplate, setSelectedTemplate] = useState(''); + const [errorText, setErrorText] = useState(); + const [templateOptions, setTemplateOptions] = useState([]); + const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate); + + 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 handleSelectChange = useCallback( + selected => { + setSelectedTemplate(selected); + setTemplateYaml(yaml.stringify(selected.spec)); + }, + [setTemplateYaml], + ); + + return ( + <> + {loading && } +
+
+ + + Load Existing Template + + + + + + + +
+
+ +
+
+ +
+
+ + ); +}; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/index.ts b/plugins/scaffolder/src/next/TemplateEditorPage/index.ts new file mode 100644 index 0000000000..7ec6bddb64 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateEditorPage/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { TemplateEditorPage } from './TemplateEditorPage'; diff --git a/yarn.lock b/yarn.lock index 1621cdc5b1..24c2ea1f70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7589,6 +7589,7 @@ __metadata: "@rjsf/core": ^3.2.1 "@rjsf/material-ui": ^3.2.1 "@rjsf/utils": 5.1.0 + "@rjsf/validator-ajv8": 5.1.0 "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 "@testing-library/react-hooks": ^8.0.0 From a5213796886dd87616cd9e584021d2efaca3e0e8 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 10 Feb 2023 14:41:26 +0100 Subject: [PATCH 4/6] chore: added changeset Signed-off-by: blam --- .changeset/honest-timers-pull.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/honest-timers-pull.md diff --git a/.changeset/honest-timers-pull.md b/.changeset/honest-timers-pull.md new file mode 100644 index 0000000000..d11d260a22 --- /dev/null +++ b/.changeset/honest-timers-pull.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-react': minor +'@backstage/plugin-scaffolder': minor +--- + +Migrating the `TemplateEditorPage` to work with the new components from `@backstage/plugin-scaffolder-react` From 73981f9407db1985536bac01da29b0e870a93548 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 10 Feb 2023 15:42:47 +0100 Subject: [PATCH 5/6] chore: removed some unneeded code, just use the one that exists Signed-off-by: blam --- .../DirectoryEditorContext.tsx | 243 ------------------ .../TemplateEditorPage/DryRunContext.test.tsx | 44 ---- .../next/TemplateEditorPage/DryRunContext.tsx | 179 ------------- .../DryRunResults/DryRunResults.test.tsx | 118 --------- .../DryRunResults/DryRunResults.tsx | 91 ------- .../DryRunResults/DryRunResultsList.test.tsx | 95 ------- .../DryRunResults/DryRunResultsList.tsx | 83 ------ .../DryRunResults/DryRunResultsSplitView.tsx | 55 ---- .../DryRunResults/DryRunResultsView.test.tsx | 99 ------- .../DryRunResults/DryRunResultsView.tsx | 195 -------------- .../TemplateEditorPage/DryRunResults/index.ts | 17 -- .../TemplateEditorPage/TemplateEditor.tsx | 10 +- .../TemplateEditorBrowser.test.tsx | 55 ---- .../TemplateEditorBrowser.tsx | 100 ------- .../TemplateEditorPage/TemplateEditorForm.tsx | 5 +- .../TemplateEditorPage.test.tsx | 58 ----- .../TemplateEditorTextArea.tsx | 153 ----------- .../TemplateFormPreviewer.tsx | 2 +- 18 files changed, 8 insertions(+), 1594 deletions(-) delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.test.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/index.ts delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.test.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.test.tsx delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx deleted file mode 100644 index 0edf390bfe..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DirectoryEditorContext.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/* - * 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; - /** Reload the staged content of the file from disk */ - reload(): Promise; -} - -interface DirectoryEditor { - /** A list of all files in the edited directory */ - files: Array; - - /** The currently selected file */ - selectedFile: DirectoryEditorFile | undefined; - /** Switch the selected file */ - setSelectedFile(path: string | undefined): void; - - /** Save all files to disk */ - save(): Promise; - /** Reload all files from disk */ - reload(): Promise; - - 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 { - if (this.#content !== undefined) { - await this.#access.save(this.#content); - this.#savedContent = this.#content; - this.#signalUpdate(); - } - } - - async reload(): Promise { - 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 { - await Promise.all(this.#files.map(file => file.save())); - } - - async reload(): Promise { - 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( - 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 ; - } else if (!result) { - return ; - } - - return ( - - {props.children} - - ); -} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.test.tsx deleted file mode 100644 index 36a62f1b8a..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 { base64EncodeContent } from './DryRunContext'; - -// eslint-disable-next-line no-restricted-imports -import { TextEncoder } from 'util'; - -window.TextEncoder = TextEncoder; - -describe('base64EncodeContent', () => { - it('encodes text files', () => { - expect(base64EncodeContent('abc')).toBe('YWJj'); - expect(base64EncodeContent('abc'.repeat(1000000))).toBe( - window.btoa(''), - ); - }); - - it('encodes binary files', () => { - expect(base64EncodeContent('\x00\x01\x02')).toBe('AAEC'); - expect(base64EncodeContent('😅')).toBe('8J+YhQ=='); - // Triggers chunking - expect(base64EncodeContent('😅'.repeat(18000))).toBe( - '8J+YhfCfmIXwn5iF8J+YhfCfmIXwn5iF'.repeat(3000), - ); - // Triggers size check - expect(base64EncodeContent('😅'.repeat(1000000))).toBe( - window.btoa(''), - ); - }); -}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.tsx deleted file mode 100644 index a617eebe1e..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunContext.tsx +++ /dev/null @@ -1,179 +0,0 @@ -/* - * 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, - useCallback, - useContext, - useMemo, - useRef, - useState, -} from 'react'; -import { - scaffolderApiRef, - ScaffolderDryRunResponse, -} from '@backstage/plugin-scaffolder-react'; - -const MAX_CONTENT_SIZE = 64 * 1024; -const CHUNK_SIZE = 32 * 1024; - -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; -} - -const DryRunContext = createContext(undefined); - -interface DryRunProviderProps { - children: ReactNode; -} - -export function base64EncodeContent(content: string): string { - if (content.length > MAX_CONTENT_SIZE) { - return window.btoa(''); - } - - try { - return window.btoa(content); - } catch { - const decoder = new TextEncoder(); - const buffer = decoder.encode(content); - - const chunks = new Array(); - for (let offset = 0; offset < buffer.length; offset += CHUNK_SIZE) { - chunks.push( - String.fromCharCode(...buffer.slice(offset, offset + CHUNK_SIZE)), - ); - } - return window.btoa(chunks.join('')); - } -} - -export function DryRunProvider(props: DryRunProviderProps) { - const scaffolderApi = useApi(scaffolderApiRef); - - const [state, setState] = useState< - Pick - >({ - results: [], - selectedResult: undefined, - }); - const idRef = useRef(1); - - const selectResult = useCallback((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, - }; - }); - }, []); - - const deleteResult = useCallback((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, - }; - }); - }, []); - - const execute = useCallback( - 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: {}, - directoryContents: options.files.map(file => ({ - path: file.path, - base64Content: base64EncodeContent(file.content), - })), - }); - - const result = { - ...response, - id: idRef.current++, - }; - - setState(prevState => ({ - results: [...prevState.results, result], - selectedResult: prevState.selectedResult ?? result, - })); - }, - [scaffolderApi], - ); - - const dryRun = useMemo( - () => ({ - ...state, - selectResult, - deleteResult, - execute, - }), - [state, selectResult, deleteResult, execute], - ); - - return ( - - {props.children} - - ); -} - -export function useDryRun(): DryRun { - const value = useContext(DryRunContext); - if (!value) { - throw new Error('must be used within a DryRunProvider'); - } - return value; -} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx deleted file mode 100644 index 0267093ee6..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.test.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { act, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React, { useEffect } from 'react'; -import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; -import { DryRunProvider, useDryRun } from '../DryRunContext'; -import { DryRunResults } from './DryRunResults'; - -function DryRunRemote({ - execute, - remove, -}: { - execute?: boolean; - remove?: boolean; -}) { - const dryRun = useDryRun(); - - useEffect(() => { - if (execute) { - dryRun.execute({ - templateContent: '', - values: {}, - files: [], - }); - } - if (remove) { - if (dryRun.selectedResult) { - dryRun.deleteResult(dryRun.selectedResult.id); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [execute, remove]); - - return null; -} - -const mockScaffolderApi = { - dryRun: async () => ({ - directoryContents: [], - log: [], - output: {}, - steps: [], - }), -}; - -describe('DryRunResults', () => { - it('renders without exploding', async () => { - await renderInTestApp( - - - - - , - ); - expect(screen.getByText('Dry-run results')).toBeInTheDocument(); - }); - - it('expands when dry-run result is added and toggles on click, and disappears when results are gone', async () => { - const { rerender } = await renderInTestApp( - - - - - - , - ); - - expect(screen.getByText('Files')).not.toBeVisible(); - - await act(async () => { - rerender( - - - - - - , - ); - }); - - expect(screen.getByText('Files')).toBeVisible(); - - await userEvent.click(screen.getByText('Dry-run results')); - await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible()); - - await userEvent.click(screen.getByText('Dry-run results')); - expect(screen.getByText('Files')).toBeVisible(); - - await act(async () => { - rerender( - - - - - - , - ); - }); - - await waitFor(() => expect(screen.getByText('Files')).not.toBeVisible()); - }); -}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.tsx deleted file mode 100644 index ae9374438f..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResults.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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 { BackstageTheme } from '@backstage/theme'; -import Accordion from '@material-ui/core/Accordion'; -import AccordionDetails from '@material-ui/core/AccordionDetails'; -import AccordionSummary from '@material-ui/core/AccordionSummary'; -import Divider from '@material-ui/core/Divider'; -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; -import ExpandMoreIcon from '@material-ui/icons/ExpandLess'; -import { usePrevious } from '@react-hookz/web'; -import React, { useEffect, useState } from 'react'; -import { useDryRun } from '../DryRunContext'; -import { DryRunResultsList } from './DryRunResultsList'; -import { DryRunResultsView } from './DryRunResultsView'; - -const useStyles = makeStyles((theme: BackstageTheme) => ({ - header: { - height: 48, - minHeight: 0, - '&.Mui-expanded': { - height: 48, - minHeight: 0, - }, - }, - content: { - display: 'grid', - background: theme.palette.background.default, - gridTemplateColumns: '180px auto 1fr', - gridTemplateRows: '1fr', - padding: 0, - height: 400, - }, -})); - -export function DryRunResults() { - const classes = useStyles(); - const dryRun = useDryRun(); - const [expanded, setExpanded] = useState(false); - const [hidden, setHidden] = useState(true); - - const resultsLength = dryRun.results.length; - const prevResultsLength = usePrevious(resultsLength); - useEffect(() => { - if (prevResultsLength === 0 && resultsLength === 1) { - setHidden(false); - setExpanded(true); - } else if (prevResultsLength === 1 && resultsLength === 0) { - setExpanded(false); - } - }, [prevResultsLength, resultsLength]); - - return ( - <> - - - ); -} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx deleted file mode 100644 index 706f284db1..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { act, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React, { useEffect } from 'react'; -import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; -import { DryRunProvider, useDryRun } from '../DryRunContext'; -import { DryRunResultsList } from './DryRunResultsList'; - -function DryRunRemote({ execute }: { execute?: number }) { - const dryRun = useDryRun(); - - useEffect(() => { - if (execute) { - dryRun.execute({ - templateContent: '', - values: {}, - files: [], - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [execute]); - - return null; -} - -const mockScaffolderApi = { - dryRun: async () => ({ - directoryContents: [], - log: [], - output: {}, - steps: [], - }), -}; - -describe('DryRunResultsList', () => { - it('renders without exploding', async () => { - const rendered = await renderInTestApp( - - - - - , - ); - expect(rendered.baseElement.querySelector('ul')).toBeEmptyDOMElement(); - }); - - it('adds new result items and deletes them', async () => { - const { rerender } = await renderInTestApp( - - - - - - , - ); - - expect(screen.getByText('Result 1')).toBeInTheDocument(); - expect(screen.queryByText('Result 2')).not.toBeInTheDocument(); - - await act(async () => { - rerender( - - - - - - , - ); - }); - - expect(screen.getByText('Result 1')).toBeInTheDocument(); - expect(screen.getByText('Result 2')).toBeInTheDocument(); - - await userEvent.click(screen.getAllByLabelText('delete')[0]); - - expect(screen.queryByText('Result 1')).not.toBeInTheDocument(); - expect(screen.getByText('Result 2')).toBeInTheDocument(); - }); -}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx deleted file mode 100644 index 462c3b58fd..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsList.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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 { BackstageTheme } from '@backstage/theme'; -import IconButton from '@material-ui/core/IconButton'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; -import ListItemText from '@material-ui/core/ListItemText'; -import { makeStyles } from '@material-ui/core/styles'; -import CancelIcon from '@material-ui/icons/Cancel'; -import CheckIcon from '@material-ui/icons/Check'; -import DeleteIcon from '@material-ui/icons/Delete'; -import React from 'react'; -import { useDryRun } from '../DryRunContext'; - -const useStyles = makeStyles((theme: BackstageTheme) => ({ - root: { - overflowY: 'auto', - background: theme.palette.background.default, - }, - iconSuccess: { - minWidth: 0, - marginRight: theme.spacing(1), - color: theme.palette.status.ok, - }, - iconFailure: { - minWidth: 0, - marginRight: theme.spacing(1), - color: theme.palette.status.error, - }, -})); - -export function DryRunResultsList() { - const classes = useStyles(); - const dryRun = useDryRun(); - - return ( - - {dryRun.results.map(result => { - const failed = result.log.some(l => l.body.status === 'failed'); - return ( - dryRun.selectResult(result.id)} - > - - {failed ? : } - - - - dryRun.deleteResult(result.id)} - > - - - - - ); - })} - - ); -} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx deleted file mode 100644 index b554a1c0c0..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsSplitView.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 React, { Children, ReactNode } from 'react'; -import classNames from 'classnames'; - -const useStyles = makeStyles(theme => ({ - root: { - display: 'grid', - gridTemplateColumns: '280px auto 3fr', - gridTemplateRows: '1fr', - }, - child: { - overflowY: 'auto', - height: '100%', - minHeight: 0, - }, - firstChild: { - background: theme.palette.background.paper, - }, -})); - -export function DryRunResultsSplitView(props: { children: ReactNode }) { - const classes = useStyles(); - const childArray = Children.toArray(props.children); - - if (childArray.length !== 2) { - throw new Error('must have exactly 2 children'); - } - - return ( -
-
- {childArray[0]} -
- -
{childArray[1]}
-
- ); -} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx deleted file mode 100644 index c2d8d3a2a4..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - * 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 { entityRouteRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React, { ReactNode, useEffect } from 'react'; -import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; -import { DryRunProvider, useDryRun } from '../DryRunContext'; -import { DryRunResultsView } from './DryRunResultsView'; - -// The inside needs mocking to render in jsdom -jest.mock('react-virtualized-auto-sizer', () => ({ - __esModule: true, - default: (props: { - children: (size: { width: number; height: number }) => ReactNode; - }) => <>{props.children({ width: 400, height: 200 })}, -})); - -function DryRunRemote() { - const dryRun = useDryRun(); - - useEffect(() => { - dryRun.execute({ - templateContent: '', - values: {}, - files: [], - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return null; -} - -describe('DryRunResultsView', () => { - it('renders a result', async () => { - await renderInTestApp( - ({ - directoryContents: [ - { - path: 'foo.txt', - base64Content: window.btoa('Foo Content'), - executable: false, - }, - ], - log: [{ body: { message: 'Foo Message', stepId: 'foo' } }], - output: { - links: [{ title: 'Foo Link', url: 'http://example.com' }], - }, - steps: [{ id: 'foo', action: 'foo', name: 'Foo' }], - }), - }, - ], - ]} - > - - - - - , - { - mountedRoutes: { - '/catalog/:kind/:namespace/:name': entityRouteRef, - }, - }, - ); - - expect(screen.getByText('foo.txt')).toBeInTheDocument(); - expect(screen.getByText('Foo Content')).toBeInTheDocument(); - - expect(screen.queryByText('Foo Message')).not.toBeInTheDocument(); - expect(screen.queryByText('Foo Link')).not.toBeInTheDocument(); - - await userEvent.click(screen.getByText('Log')); - expect(screen.getByText('Foo Message')).toBeInTheDocument(); - - await userEvent.click(screen.getByText('Output')); - expect(screen.getByText('Foo Link')).toBeInTheDocument(); - }); -}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx deleted file mode 100644 index 852ec05a82..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/DryRunResultsView.tsx +++ /dev/null @@ -1,195 +0,0 @@ -/* - * 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 { LogViewer } from '@backstage/core-components'; -import { StreamLanguage } from '@codemirror/language'; -import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; -import Box from '@material-ui/core/Box'; -import Divider from '@material-ui/core/Divider'; -import { makeStyles } from '@material-ui/core/styles'; -import Tab from '@material-ui/core/Tab'; -import Tabs from '@material-ui/core/Tabs'; -import CodeMirror from '@uiw/react-codemirror'; -import React, { useEffect, useMemo, useState } from 'react'; -import { FileBrowser } from '../../../components/FileBrowser'; -import { TaskStatusStepper } from '../../../components/TaskPage/TaskPage'; -import { TaskPageLinks } from '../../../components/TaskPage/TaskPageLinks'; -import { useDryRun } from '../DryRunContext'; -import { DryRunResultsSplitView } from './DryRunResultsSplitView'; - -const useStyles = makeStyles({ - root: { - display: 'flex', - flexFlow: 'column nowrap', - }, - contentWrapper: { - flex: 1, - position: 'relative', - }, - content: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - - display: 'flex', - '& > *': { - flex: 1, - }, - }, - codeMirror: { - height: '100%', - overflowY: 'auto', - }, -}); - -function FilesContent() { - const classes = useStyles(); - const { selectedResult } = useDryRun(); - const [selectedPath, setSelectedPath] = useState(''); - const selectedFile = selectedResult?.directoryContents.find( - f => f.path === selectedPath, - ); - - useEffect(() => { - if (selectedResult) { - const [firstFile] = selectedResult.directoryContents; - if (firstFile) { - setSelectedPath(firstFile.path); - } else { - setSelectedPath(''); - } - } - return undefined; - }, [selectedResult]); - - if (!selectedResult) { - return null; - } - return ( - - file.path)} - /> - - - ); -} -function LogContent() { - const { selectedResult } = useDryRun(); - const [currentStepId, setUserSelectedStepId] = useState(); - - const steps = useMemo(() => { - if (!selectedResult) { - return []; - } - return ( - selectedResult.steps.map(step => { - const stepLog = selectedResult.log.filter( - l => l.body.stepId === step.id, - ); - return { - id: step.id, - name: step.name, - logString: stepLog.map(l => l.body.message).join('\n'), - status: stepLog[stepLog.length - 1]?.body.status ?? 'completed', - }; - }) ?? [] - ); - }, [selectedResult]); - - if (!selectedResult) { - return null; - } - - const selectedStep = steps.find(s => s.id === currentStepId) ?? steps[0]; - - return ( - - - - - ); -} - -function OutputContent() { - const classes = useStyles(); - const { selectedResult } = useDryRun(); - - if (!selectedResult) { - return null; - } - - return ( - - - {selectedResult.output?.links?.length && ( - - )} - - - - ); -} - -export function DryRunResultsView() { - const classes = useStyles(); - const [selectedTab, setSelectedTab] = useState<'files' | 'log' | 'output'>( - 'files', - ); - - return ( -
- setSelectedTab(v)}> - - - - - - -
-
- {selectedTab === 'files' && } - {selectedTab === 'log' && } - {selectedTab === 'output' && } -
-
-
- ); -} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/index.ts b/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/index.ts deleted file mode 100644 index af8ef5214f..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/DryRunResults/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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. - */ - -export { DryRunResults } from './DryRunResults'; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx index c5556c3b06..4cdd383f83 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditor.tsx @@ -20,12 +20,12 @@ import type { NextFieldExtensionOptions, } from '@backstage/plugin-scaffolder-react'; import { TemplateDirectoryAccess } from '../../lib/filesystem'; -import { DirectoryEditorProvider } from './DirectoryEditorContext'; -import { DryRunProvider } from './DryRunContext'; -import { DryRunResults } from './DryRunResults'; -import { TemplateEditorBrowser } from './TemplateEditorBrowser'; +import { DirectoryEditorProvider } from '../../components/TemplateEditorPage/DirectoryEditorContext'; +import { DryRunProvider } from '../../components/TemplateEditorPage/DryRunContext'; +import { DryRunResults } from '../../components/TemplateEditorPage/DryRunResults'; +import { TemplateEditorBrowser } from '../../components/TemplateEditorPage/TemplateEditorBrowser'; import { TemplateEditorForm } from './TemplateEditorForm'; -import { TemplateEditorTextArea } from './TemplateEditorTextArea'; +import { TemplateEditorTextArea } from '../../components/TemplateEditorPage/TemplateEditorTextArea'; const useStyles = makeStyles({ // Reset and fix sizing to make sure scrolling behaves correctly diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.test.tsx deleted file mode 100644 index cec311a5cd..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 { renderInTestApp } from '@backstage/test-utils'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import { MockFileSystemAccess } from '../../lib/filesystem/MockFileSystemAccess'; -import { DirectoryEditorProvider } from './DirectoryEditorContext'; -import { TemplateEditorBrowser } from './TemplateEditorBrowser'; - -Blob.prototype.text = async function text() { - return new Promise(resolve => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.readAsText(this); - }); -}; - -describe('TemplateEditorBrowser', () => { - it('should render files and expand dirs without exploding', async () => { - await renderInTestApp( - - - , - ); - - await expect(screen.findByText('foo.txt')).resolves.toBeInTheDocument(); - expect(screen.getByText('dir')).toBeInTheDocument(); - expect(screen.queryByText('bar.txt')).not.toBeInTheDocument(); - expect(screen.queryByText('baz.txt')).not.toBeInTheDocument(); - - await userEvent.click(screen.getByText('dir')); - expect(screen.getByText('bar.txt')).toBeInTheDocument(); - expect(screen.getByText('baz.txt')).toBeInTheDocument(); - }); -}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx deleted file mode 100644 index a668aa1868..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 { 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 from 'react'; -import { FileBrowser } from '../../components/FileBrowser'; - -import { useDirectoryEditor } from './DirectoryEditorContext'; - -const useStyles = makeStyles(theme => ({ - button: { - padding: theme.spacing(1), - }, - buttons: { - display: 'flex', - flexFlow: 'row nowrap', - alignItems: 'center', - justifyContent: 'flex-start', - }, - buttonsGap: { - flex: '1 1 auto', - }, - buttonsDivider: { - marginBottom: theme.spacing(1), - }, -})); - -/** The local file browser for the template editor */ -export function TemplateEditorBrowser(props: { onClose?: () => void }) { - const classes = useStyles(); - const directoryEditor = useDirectoryEditor(); - const changedFiles = directoryEditor.files.filter(file => file.dirty); - - const handleClose = () => { - if (!props.onClose) { - return; - } - if (changedFiles.length > 0) { - // eslint-disable-next-line no-alert - const accepted = window.confirm( - 'Are you sure? Unsaved changes will be lost', - ); - if (!accepted) { - return; - } - } - props.onClose(); - }; - - return ( - <> -
- - !file.dirty)} - onClick={() => directoryEditor.save()} - > - - - - - directoryEditor.reload()} - > - - - -
- - - - - -
- - file.path)} - /> - - ); -} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx index 0ac604c75d..5dc841cf20 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorForm.tsx @@ -25,9 +25,8 @@ import { Stepper, TemplateParameterSchema, } from '@backstage/plugin-scaffolder-react'; - -import { useDirectoryEditor } from './DirectoryEditorContext'; -import { useDryRun } from './DryRunContext'; +import { useDryRun } from '../../components/TemplateEditorPage/DryRunContext'; +import { useDirectoryEditor } from '../../components/TemplateEditorPage/DirectoryEditorContext'; const useStyles = makeStyles({ containerWrapper: { diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.test.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.test.tsx deleted file mode 100644 index bf9cd8c464..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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 { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import { TemplateEditorPage } from './TemplateEditorPage'; - -describe('TemplateEditorPage', () => { - it('renders without exploding', async () => { - await renderInTestApp(); - - expect(screen.getByText('Load Template Directory')).toBeInTheDocument(); - expect(screen.getByText('Edit Template Form')).toBeInTheDocument(); - }); - - it('template directory loading should not be supported in Jest', async () => { - await renderInTestApp(); - - expect( - screen.getByRole('button', { name: /Load Template Directory/ }), - ).toBeDisabled(); - }); - - it('should be able to continue to form preview', async () => { - await renderInTestApp( - - - , - ); - - await userEvent.click(screen.getByText('Edit Template Form')); - - expect(screen.getByLabelText('Load Existing Template')).toBeInTheDocument(); - }); -}); diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx deleted file mode 100644 index 588b0cd4ba..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorTextArea.tsx +++ /dev/null @@ -1,153 +0,0 @@ -/* - * 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 { StreamLanguage } from '@codemirror/language'; -import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; -import { showPanel } from '@codemirror/view'; -import { IconButton, makeStyles, Paper, Tooltip } from '@material-ui/core'; -import RefreshIcon from '@material-ui/icons/Refresh'; -import SaveIcon from '@material-ui/icons/Save'; -import { useKeyboardEvent } from '@react-hookz/web'; -import CodeMirror from '@uiw/react-codemirror'; -import React, { useMemo } from 'react'; -import { useDirectoryEditor } from './DirectoryEditorContext'; - -const useStyles = makeStyles(theme => ({ - container: { - position: 'relative', - width: '100%', - height: '100%', - }, - codeMirror: { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, - }, - errorPanel: { - color: theme.palette.error.main, - lineHeight: 2, - margin: theme.spacing(0, 1), - }, - floatingButtons: { - position: 'absolute', - top: theme.spacing(1), - right: theme.spacing(3), - }, - floatingButton: { - padding: theme.spacing(1), - }, -})); - -/** A wrapper around CodeMirror with an error panel and extra actions available */ -export function TemplateEditorTextArea(props: { - content?: string; - onUpdate?: (content: string) => void; - errorText?: string; - onSave?: () => void; - onReload?: () => void; -}) { - const { errorText } = props; - const classes = useStyles(); - - const panelExtension = useMemo(() => { - if (!errorText) { - return showPanel.of(null); - } - - const dom = document.createElement('div'); - dom.classList.add(classes.errorPanel); - dom.textContent = errorText; - return showPanel.of(() => ({ dom, bottom: true })); - }, [classes, errorText]); - - useKeyboardEvent( - e => e.key === 's' && (e.ctrlKey || e.metaKey), - e => { - e.preventDefault(); - if (props.onSave) { - props.onSave(); - } - }, - ); - - return ( -
- - {(props.onSave || props.onReload) && ( -
- - {props.onSave && ( - - props.onSave?.()} - > - - - - )} - {props.onReload && ( - - props.onReload?.()} - > - - - - )} - -
- )} -
- ); -} - -/** A version of the TemplateEditorTextArea that is connected to the DirectoryEditor context */ -export function TemplateEditorDirectoryEditorTextArea(props: { - errorText?: string; -}) { - const directoryEditor = useDirectoryEditor(); - - const actions = directoryEditor.selectedFile?.dirty - ? { - onSave: () => directoryEditor.save(), - onReload: () => directoryEditor.reload(), - } - : { - onReload: () => directoryEditor.reload(), - }; - - return ( - directoryEditor.selectedFile?.updateContent(content)} - {...actions} - /> - ); -} - -TemplateEditorTextArea.DirectoryEditor = TemplateEditorDirectoryEditorTextArea; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx index c5f84a4b66..1476cda2d0 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -37,7 +37,7 @@ import { type LayoutOptions, } from '@backstage/plugin-scaffolder-react'; import { TemplateEditorForm } from './TemplateEditorForm'; -import { TemplateEditorTextArea } from './TemplateEditorTextArea'; +import { TemplateEditorTextArea } from '../../components/TemplateEditorPage/TemplateEditorTextArea'; const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI parameters: From e9a8ce97d0f53a6b52294e5e8b280e5419d366c6 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 10 Feb 2023 15:45:30 +0100 Subject: [PATCH 6/6] chore: remove some more uneeded code Signed-off-by: blam --- .../TemplateEditorIntro.tsx | 144 ------------------ .../TemplateEditorPage/TemplateEditorPage.tsx | 2 +- 2 files changed, 1 insertion(+), 145 deletions(-) delete mode 100644 plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx deleted file mode 100644 index 61e6c46964..0000000000 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorIntro.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/* - * 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' | 'field-explorer') => void; -} - -export function TemplateEditorIntro(props: EditorIntroProps) { - const classes = useStyles(); - const supportsLoad = WebFileSystemAccess.isSupported(); - - const cardLoadLocal = ( - - props.onSelect?.('local')} - > - - - Load Template Directory - - - Load a local template directory, allowing you to both edit and try - executing your own template. - - - - {!supportsLoad && ( -
- - - -
- )} -
- ); - - const cardFormEditor = ( - - props.onSelect?.('form')}> - - - Edit Template Form - - - Preview and edit a template form, either using a sample template or - by loading a template from the catalog. - - - - - ); - - const cardFieldExplorer = ( - - props.onSelect?.('field-explorer')}> - - - Custom Field Explorer - - - View and play around with available installed custom field - extensions. - - - - - ); - - return ( -
- - Get started by choosing one of the options below - -
- {supportsLoad && cardLoadLocal} - {cardFormEditor} - {!supportsLoad && cardLoadLocal} - {cardFieldExplorer} -
-
- ); -} diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx index 03e1619bc8..92387fd69d 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx @@ -20,13 +20,13 @@ import { WebFileSystemAccess, } from '../../lib/filesystem'; import { CustomFieldExplorer } from './CustomFieldExplorer'; -import { TemplateEditorIntro } from './TemplateEditorIntro'; import { TemplateEditor } from './TemplateEditor'; import { TemplateFormPreviewer } from './TemplateFormPreviewer'; import { NextFieldExtensionOptions, type LayoutOptions, } from '@backstage/plugin-scaffolder-react'; +import { TemplateEditorIntro } from '../../components/TemplateEditorPage/TemplateEditorIntro'; type Selection = | {