diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index f2b7d66b21..cc3787a8bc 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -64,7 +64,7 @@ import { GcpProjectsPage } from '@backstage/plugin-gcp-projects'; import { HomepageCompositionRoot, VisitListener } from '@backstage/plugin-home'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; -import { NextScaffolderPage } from '@backstage/plugin-scaffolder/alpha'; +import { LegacyScaffolderPage } from '@backstage/plugin-scaffolder/alpha'; import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; import { ScaffolderFieldExtensions, @@ -236,11 +236,11 @@ const routes = ( - + - + - + - + diff --git a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx index b15d12979a..e236e01d79 100644 --- a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx +++ b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx @@ -17,10 +17,6 @@ import React from 'react'; import type { FieldValidation } from '@rjsf/utils'; import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; import { TextField } from '@material-ui/core'; -import { - NextFieldExtensionComponentProps, - createNextScaffolderFieldExtension, -} from '@backstage/plugin-scaffolder-react/alpha'; import { createScaffolderFieldExtension, FieldExtensionComponentProps, @@ -67,7 +63,7 @@ export const LowerCaseValuePickerFieldExtension = scaffolderPlugin.provide( ); const MockDelayComponent = ( - props: NextFieldExtensionComponentProps<{ test?: string }>, + props: FieldExtensionComponentProps<{ test?: string }>, ) => { const { onChange, formData, rawErrors = [] } = props; return ( @@ -83,7 +79,7 @@ const MockDelayComponent = ( }; export const DelayingComponentFieldExtension = scaffolderPlugin.provide( - createNextScaffolderFieldExtension({ + createScaffolderFieldExtension({ name: 'DelayingComponent', component: MockDelayComponent, validation: async ( diff --git a/plugins/scaffolder-react/src/alpha.ts b/plugins/scaffolder-react/src/alpha.ts index f60ced6b35..224ee97c06 100644 --- a/plugins/scaffolder-react/src/alpha.ts +++ b/plugins/scaffolder-react/src/alpha.ts @@ -15,6 +15,4 @@ */ export * from './next'; -export type { FormProps } from './next'; - export * from './legacy'; diff --git a/plugins/scaffolder-react/src/next/components/Form/Form.tsx b/plugins/scaffolder-react/src/next/components/Form/Form.tsx index adfb8db930..ec644333b9 100644 --- a/plugins/scaffolder-react/src/next/components/Form/Form.tsx +++ b/plugins/scaffolder-react/src/next/components/Form/Form.tsx @@ -14,12 +14,13 @@ * limitations under the License. */ -import { FormProps, withTheme } from '@rjsf/core-v5'; +import { withTheme } from '@rjsf/core-v5'; import React from 'react'; import { PropsWithChildren } from 'react'; import { FieldTemplate } from './FieldTemplate'; import { DescriptionFieldTemplate } from './DescriptionFieldTemplate'; import { FieldProps } from '@rjsf/utils'; +import { ScaffolderRJSFFormProps } from '../../../extensions'; // 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 @@ -30,7 +31,7 @@ const WrappedForm = withTheme(require('@rjsf/material-ui-v5').Theme); * The Form component * @alpha */ -export const Form = (props: PropsWithChildren) => { +export const Form = (props: PropsWithChildren) => { // This is where we unbreak the changes from RJSF, and make it work with our custom fields so we don't pass on this // breaking change to our users. We will look more into a better API for this in scaffolderv2. const wrappedFields = Object.fromEntries( diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx index 2dd3825cea..24126152ed 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx @@ -20,7 +20,7 @@ import { renderInTestApp } from '@backstage/test-utils'; import { act, fireEvent } from '@testing-library/react'; import type { RJSFValidationError } from '@rjsf/utils'; import { JsonValue } from '@backstage/types'; -import { NextFieldExtensionComponentProps } from '../../../extensions'; +import { FieldExtensionComponentProps } from '../../../extensions'; import { LayoutTemplate } from '../../../layouts'; describe('Stepper', () => { @@ -115,7 +115,7 @@ describe('Stepper', () => { it('should merge nested formData correctly in multiple steps', async () => { const Repo = ({ onChange, - }: NextFieldExtensionComponentProps<{ repository: string }, any>) => ( + }: FieldExtensionComponentProps<{ repository: string }, any>) => ( { const Owner = ({ onChange, - }: NextFieldExtensionComponentProps<{ owner: string }, any>) => ( + }: FieldExtensionComponentProps<{ owner: string }, any>) => ( { manifest={manifest} extensions={[]} onCreate={jest.fn()} - FormProps={{ transformErrors }} + formProps={{ transformErrors }} />, ); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts index 65e7a5b4fd..f4dab376bc 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { JsonObject } from '@backstage/types'; -import { NextCustomFieldValidator } from '../../../extensions'; +import { CustomFieldValidator } from '../../../extensions'; import { createAsyncValidators } from './createAsyncValidators'; describe('createAsyncValidators', () => { @@ -158,16 +158,13 @@ describe('createAsyncValidators', () => { }, }; - const NameField: NextCustomFieldValidator = ( - value, - { addError }, - ) => { + const NameField: CustomFieldValidator = (value, { addError }) => { if (!value) { addError('something is broken here!'); } }; - const AddressField: NextCustomFieldValidator<{ + const AddressField: CustomFieldValidator<{ street?: string; postcode?: string; }> = (value, { addError }) => { @@ -183,8 +180,8 @@ describe('createAsyncValidators', () => { const validate = createAsyncValidators( schema, { - NameField: NameField as NextCustomFieldValidator, - AddressField: AddressField as NextCustomFieldValidator, + NameField: NameField as CustomFieldValidator, + AddressField: AddressField as CustomFieldValidator, }, { apiHolder: { get: jest.fn() }, @@ -298,7 +295,7 @@ describe('createAsyncValidators', () => { }, }; - const AddressField: NextCustomFieldValidator<{ + const AddressField: CustomFieldValidator<{ street?: string; postcode?: string; }> = (value, { addError }) => { @@ -311,18 +308,15 @@ describe('createAsyncValidators', () => { } }; - const NameField: NextCustomFieldValidator = ( - value, - { addError }, - ) => { + const NameField: CustomFieldValidator = (value, { addError }) => { if (!value) { addError('something is broken here!'); } }; const validators = { - AddressField: AddressField as NextCustomFieldValidator, - NameField: NameField as NextCustomFieldValidator, + AddressField: AddressField as CustomFieldValidator, + NameField: NameField as CustomFieldValidator, }; const validate = createAsyncValidators(schema, validators, { diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts index 5531ff26b4..a606f01d6b 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts @@ -19,9 +19,11 @@ import type { JsonObject, JsonValue } from '@backstage/types'; import { ApiHolder } from '@backstage/core-plugin-api'; import { Draft07 as JSONSchema } from 'json-schema-library'; import { createFieldValidation, extractSchemaFromStep } from '../../lib'; -import { NextCustomFieldValidator } from '../../../extensions'; +import { + CustomFieldValidator, + FieldExtensionUiSchema, +} from '../../../extensions'; import { isObject } from './utils'; -import { NextFieldExtensionUiSchema } from '../../extensions/types'; /** * @internal @@ -34,7 +36,7 @@ export const createAsyncValidators = ( rootSchema: JsonObject, validators: Record< string, - undefined | NextCustomFieldValidator + undefined | CustomFieldValidator >, context: { apiHolder: ApiHolder; @@ -53,7 +55,7 @@ export const createAsyncValidators = ( key: string, value: JsonValue | undefined, schema: JsonObject, - uiSchema: NextFieldExtensionUiSchema, + uiSchema: FieldExtensionUiSchema, ) => { const validator = validators[validatorName]; if (validator) { @@ -82,7 +84,7 @@ export const createAsyncValidators = ( const doValidateItem = async ( propValue: JsonObject, itemSchema: JsonObject, - itemUiSchema: NextFieldExtensionUiSchema, + itemUiSchema: FieldExtensionUiSchema, ) => { await validateForm( propValue['ui:field'] as string, diff --git a/plugins/scaffolder-react/src/next/index.ts b/plugins/scaffolder-react/src/next/index.ts index 5e4c9f91c1..f13bb76d7a 100644 --- a/plugins/scaffolder-react/src/next/index.ts +++ b/plugins/scaffolder-react/src/next/index.ts @@ -14,6 +14,5 @@ * limitations under the License. */ export * from './components'; -export * from './types'; export * from './lib'; export * from './hooks'; diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 68c59eec2d..b683345ff5 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -24,7 +24,7 @@ import { } from '@backstage/plugin-catalog-react'; import React from 'react'; import { scaffolderApiRef, ScaffolderClient } from '../src'; -import { NextScaffolderPage, ScaffolderPage } from '../src/plugin'; +import { ScaffolderPage, LegacyScaffolderPage } from '../src/plugin'; import { discoveryApiRef, fetchApiRef, @@ -69,9 +69,9 @@ createDevApp() element: , }) .addPage({ - path: '/next-create', + path: '/legacy-create', title: 'Create (next)', - element: , + element: , }) .addPage({ path: '/create-groups', @@ -92,10 +92,10 @@ createDevApp() ), }) .addPage({ - path: '/next-create-groups', + path: '/legacy-create-groups', title: 'Groups (next)', element: ( - e.metadata.tags?.includes('techdocs') || false, diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx index 300e23ee89..4329b2208b 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx @@ -23,7 +23,7 @@ import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; import { EntityPicker } from './EntityPicker'; import { EntityPickerProps } from './schema'; -import { FieldProps } from '@rjsf/utils'; +import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ apiVersion: 'scaffolder.backstage.io/v1beta3', @@ -40,7 +40,7 @@ describe('', () => { const rawErrors: string[] = []; const formData = undefined; - let props: FieldProps; + let props: FieldProps; const catalogApi: jest.Mocked = { getLocationById: jest.fn(), @@ -77,7 +77,7 @@ describe('', () => { uiSchema, rawErrors, formData, - } as unknown as FieldProps; + } as unknown as FieldProps; catalogApi.getEntities.mockResolvedValue({ items: entities }); }); diff --git a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx index 099d6fa6ed..be59d4d1cf 100644 --- a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx @@ -17,7 +17,6 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { CatalogApi } from '@backstage/catalog-client'; -import { FieldProps } from '@rjsf/core'; import { MyGroupsPicker } from './MyGroupsPicker'; import { TestApiProvider } from '@backstage/test-utils'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; @@ -29,6 +28,7 @@ import { identityApiRef, } from '@backstage/core-plugin-api'; import userEvent from '@testing-library/user-event'; +import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react'; // Create a mock IdentityApi const mockIdentityApi: IdentityApi = { @@ -109,7 +109,7 @@ describe('', () => { onChange, schema, required, - } as unknown as FieldProps; + } as unknown as FieldProps; render( ', () => { onChange, schema, required, - } as unknown as FieldProps; + } as unknown as FieldProps; const { queryByText, getByRole } = render( ', () => { const rawErrors: string[] = []; const formData = undefined; - let props: FieldProps; + let props: FieldProps; const catalogApi: jest.Mocked = { getLocationById: jest.fn(), diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 4b44a4fa3e..e9c52907f9 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; import { RepoUrlPicker } from './RepoUrlPicker'; -import Form from '@rjsf/core'; +import { Form } from '@backstage/plu'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { scmIntegrationsApiRef, diff --git a/plugins/scaffolder/src/legacy/TemplateEditorPage/CustomFieldExplorer.tsx b/plugins/scaffolder/src/legacy/TemplateEditorPage/CustomFieldExplorer.tsx new file mode 100644 index 0000000000..c790dbd72c --- /dev/null +++ b/plugins/scaffolder/src/legacy/TemplateEditorPage/CustomFieldExplorer.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 { StreamLanguage } from '@codemirror/language'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; +import { + Button, + Card, + CardContent, + CardHeader, + FormControl, + IconButton, + InputLabel, + makeStyles, + MenuItem, + Select, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import CodeMirror from '@uiw/react-codemirror'; +import React, { useCallback, useMemo, useState } from 'react'; +import yaml from 'yaml'; +import { Form } from '@backstage/plugin-scaffolder-react/alpha'; +import { TemplateEditorForm } from './TemplateEditorForm'; +import validator from '@rjsf/validator-ajv8'; +import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha'; + +const useStyles = makeStyles(theme => ({ + 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?: LegacyFieldExtensionOptions[]; + 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/legacy/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateEditor.tsx new file mode 100644 index 0000000000..f6e7b38d7d --- /dev/null +++ b/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateEditor.tsx @@ -0,0 +1,92 @@ +/* + * 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 { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha'; +import { TemplateDirectoryAccess } from '../../lib/filesystem'; +import { LayoutOptions } from '@backstage/plugin-scaffolder-react'; +import { DirectoryEditorProvider } from '../../next/TemplateEditorPage/DirectoryEditorContext'; +import { DryRunProvider } from '../../next/TemplateEditorPage/DryRunContext'; +import { TemplateEditorBrowser } from '../../next/TemplateEditorPage/TemplateEditorBrowser'; +import { TemplateEditorTextArea } from '../../next/TemplateEditorPage/TemplateEditorTextArea'; +import { TemplateEditorForm } from './TemplateEditorForm'; +import { DryRunResults } from '../../next/TemplateEditorPage/DryRunResults'; + +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?: LegacyFieldExtensionOptions[]; + layouts?: LayoutOptions[]; + onClose?: () => void; +}) => { + const classes = useStyles(); + + const [errorText, setErrorText] = useState(); + + return ( + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +}; diff --git a/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateEditorForm.tsx b/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateEditorForm.tsx new file mode 100644 index 0000000000..80121c40db --- /dev/null +++ b/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateEditorForm.tsx @@ -0,0 +1,236 @@ +/* + * 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 { + FieldExtensionOptions, + LayoutOptions, + TemplateParameterSchema, +} from '@backstage/plugin-scaffolder-react'; +import { + Stepper, + LegacyFieldExtensionOptions, +} from '@backstage/plugin-scaffolder-react/alpha'; +import { useDryRun } from '../../next/TemplateEditorPage/DryRunContext'; +import { useDirectoryEditor } from '../../next/TemplateEditorPage/DirectoryEditorContext'; + +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?: LegacyFieldExtensionOptions[]; + 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/legacy/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateEditorPage.tsx index e65869ad0e..127c73dcf5 100644 --- a/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateEditorPage.tsx @@ -21,10 +21,10 @@ import { } from '../../lib/filesystem'; import { type LayoutOptions } from '@backstage/plugin-scaffolder-react'; import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha'; -import { CustomFieldExplorer } from '../../next/TemplateEditorPage/CustomFieldExplorer'; -import { TemplateFormPreviewer } from '../../next/TemplateEditorPage/TemplateFormPreviewer'; -import { TemplateEditor } from '../../next/TemplateEditorPage/TemplateEditor'; import { TemplateEditorIntro } from '../../next/TemplateEditorPage/TemplateEditorIntro'; +import { TemplateEditor } from './TemplateEditor'; +import { TemplateFormPreviewer } from './TemplateFormPreviewer'; +import { CustomFieldExplorer } from './CustomFieldExplorer'; type Selection = | { diff --git a/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateFormPreviewer.tsx new file mode 100644 index 0000000000..21df43cf8c --- /dev/null +++ b/plugins/scaffolder/src/legacy/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -0,0 +1,217 @@ +/* + * 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 { LayoutOptions } from '@backstage/plugin-scaffolder-react'; +import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha'; +import { TemplateEditorTextArea } from '../../next/TemplateEditorPage/TemplateEditorTextArea'; +import { TemplateEditorForm } from './TemplateEditorForm'; + +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?: LegacyFieldExtensionOptions[]; + 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/legacy/index.ts b/plugins/scaffolder/src/legacy/index.ts index 83b5717fbe..2393be0f69 100644 --- a/plugins/scaffolder/src/legacy/index.ts +++ b/plugins/scaffolder/src/legacy/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { LegacyRouter, type LegacyRouterProps } from './Router'; +export { LegacyScaffolderPage } from '../plugin'; diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx index 177ad11c2b..996da98d4a 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorBrowser.tsx @@ -18,8 +18,8 @@ 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 '../FileBrowser'; import { useDirectoryEditor } from './DirectoryEditorContext'; +import { FileBrowser } from '../../components/FileBrowser'; const useStyles = makeStyles(theme => ({ button: { diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx index 464e23a201..3b14f2c07f 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx @@ -26,10 +26,11 @@ import { scaffolderApiRef, useTemplateSecrets, type LayoutOptions, + FormProps, FieldExtensionOptions, ReviewStepProps, } from '@backstage/plugin-scaffolder-react'; -import { FormProps, Workflow } from '@backstage/plugin-scaffolder-react/alpha'; +import { Workflow } from '@backstage/plugin-scaffolder-react/alpha'; import { JsonValue } from '@backstage/types'; import { Header, Page } from '@backstage/core-components';