chore: fixing some more work

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2023-09-28 13:50:23 +02:00
committed by Patrik Oldsberg
parent 48db8c25eb
commit f0e021aedd
21 changed files with 792 additions and 60 deletions
+6 -6
View File
@@ -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 = (
<LightBox />
</TechDocsAddons>
</Route>
<FeatureFlagged with="scaffolder-next-preview">
<FeatureFlagged with="scaffolder-legacy">
<Route
path="/create"
element={
<NextScaffolderPage
<LegacyScaffolderPage
groups={[
{
title: 'Recommended',
@@ -252,14 +252,14 @@ const routes = (
}
>
<ScaffolderFieldExtensions>
<DelayingComponentFieldExtension />
<LowerCaseValuePickerFieldExtension />
</ScaffolderFieldExtensions>
<ScaffolderLayouts>
<TwoColumnLayout />
</ScaffolderLayouts>
</Route>
</FeatureFlagged>
<FeatureFlagged without="scaffolder-next-preview">
<FeatureFlagged without="scaffolder-legacy">
<Route
path="/create"
element={
@@ -276,7 +276,7 @@ const routes = (
}
>
<ScaffolderFieldExtensions>
<LowerCaseValuePickerFieldExtension />
<DelayingComponentFieldExtension />
</ScaffolderFieldExtensions>
<ScaffolderLayouts>
<TwoColumnLayout />
@@ -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 (
-2
View File
@@ -15,6 +15,4 @@
*/
export * from './next';
export type { FormProps } from './next';
export * from './legacy';
@@ -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<FormProps>) => {
export const Form = (props: PropsWithChildren<ScaffolderRJSFFormProps>) => {
// 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(
@@ -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>) => (
<input
aria-label="repo"
type="text"
@@ -126,7 +126,7 @@ describe('Stepper', () => {
const Owner = ({
onChange,
}: NextFieldExtensionComponentProps<{ owner: string }, any>) => (
}: FieldExtensionComponentProps<{ owner: string }, any>) => (
<input
aria-label="owner"
type="text"
@@ -312,7 +312,7 @@ describe('Stepper', () => {
manifest={manifest}
extensions={[]}
onCreate={jest.fn()}
FormProps={{ transformErrors }}
formProps={{ transformErrors }}
/>,
);
@@ -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<string> = (
value,
{ addError },
) => {
const NameField: CustomFieldValidator<string> = (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<unknown>,
AddressField: AddressField as NextCustomFieldValidator<unknown>,
NameField: NameField as CustomFieldValidator<unknown>,
AddressField: AddressField as CustomFieldValidator<unknown>,
},
{
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<string> = (
value,
{ addError },
) => {
const NameField: CustomFieldValidator<string> = (value, { addError }) => {
if (!value) {
addError('something is broken here!');
}
};
const validators = {
AddressField: AddressField as NextCustomFieldValidator<unknown>,
NameField: NameField as NextCustomFieldValidator<unknown>,
AddressField: AddressField as CustomFieldValidator<unknown>,
NameField: NameField as CustomFieldValidator<unknown>,
};
const validate = createAsyncValidators(schema, validators, {
@@ -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<unknown, unknown>
undefined | CustomFieldValidator<unknown, unknown>
>,
context: {
apiHolder: ApiHolder;
@@ -53,7 +55,7 @@ export const createAsyncValidators = (
key: string,
value: JsonValue | undefined,
schema: JsonObject,
uiSchema: NextFieldExtensionUiSchema<unknown, unknown>,
uiSchema: FieldExtensionUiSchema<unknown, unknown>,
) => {
const validator = validators[validatorName];
if (validator) {
@@ -82,7 +84,7 @@ export const createAsyncValidators = (
const doValidateItem = async (
propValue: JsonObject,
itemSchema: JsonObject,
itemUiSchema: NextFieldExtensionUiSchema<unknown, unknown>,
itemUiSchema: FieldExtensionUiSchema<unknown, unknown>,
) => {
await validateForm(
propValue['ui:field'] as string,
@@ -14,6 +14,5 @@
* limitations under the License.
*/
export * from './components';
export * from './types';
export * from './lib';
export * from './hooks';
+5 -5
View File
@@ -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: <ScaffolderPage />,
})
.addPage({
path: '/next-create',
path: '/legacy-create',
title: 'Create (next)',
element: <NextScaffolderPage />,
element: <LegacyScaffolderPage />,
})
.addPage({
path: '/create-groups',
@@ -92,10 +92,10 @@ createDevApp()
),
})
.addPage({
path: '/next-create-groups',
path: '/legacy-create-groups',
title: 'Groups (next)',
element: (
<NextScaffolderPage
<LegacyScaffolderPage
groups={[
{
filter: e => e.metadata.tags?.includes('techdocs') || false,
@@ -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('<EntityPicker />', () => {
const rawErrors: string[] = [];
const formData = undefined;
let props: FieldProps;
let props: FieldProps<string>;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
@@ -77,7 +77,7 @@ describe('<EntityPicker />', () => {
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
} as unknown as FieldProps;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
@@ -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('<MyGroupsPicker />', () => {
onChange,
schema,
required,
} as unknown as FieldProps<any>;
} as unknown as FieldProps<string>;
render(
<TestApiProvider
@@ -181,7 +181,7 @@ describe('<MyGroupsPicker />', () => {
onChange,
schema,
required,
} as unknown as FieldProps<any>;
} as unknown as FieldProps<string>;
const { queryByText, getByRole } = render(
<TestApiProvider
@@ -18,7 +18,7 @@ import { type EntityFilterQuery } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { FieldProps } from '@rjsf/core';
import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
import React from 'react';
import { OwnerPicker } from './OwnerPicker';
@@ -45,7 +45,7 @@ describe('<OwnerPicker />', () => {
const rawErrors: string[] = [];
const formData = undefined;
let props: FieldProps;
let props: FieldProps<string>;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
@@ -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,
@@ -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<any, any>[];
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 (
<main className={classes.root}>
<div className={classes.controls}>
<FormControl variant="outlined" size="small" fullWidth>
<InputLabel id="select-field-label">
Choose Custom Field Extension
</InputLabel>
<Select
value={selectedField}
label="Choose Custom Field Extension"
labelId="select-field-label"
onChange={e => handleSelectionChange(e.target.value)}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton size="medium" onClick={onClose} aria-label="Close">
<CloseIcon />
</IconButton>
</div>
<div className={classes.fieldForm}>
<Card>
<CardHeader title="Field Options" />
<CardContent>
<Form
showErrorList={false}
// @ts-ignore
fields={{ ...fieldComponents }}
noHtml5Validate
formData={fieldFormState}
formContext={{ fieldFormState }}
onSubmit={e => handleFieldConfigChange(e.formData)}
validator={validator}
schema={selectedField.schema?.uiOptions || {}}
>
<Button
variant="contained"
color="primary"
type="submit"
disabled={!selectedField.schema?.uiOptions}
>
Apply
</Button>
</Form>
</CardContent>
</Card>
</div>
<div className={classes.preview}>
<Card>
<CardHeader title="Example Template Spec" />
<CardContent>
<CodeMirror
readOnly
theme="dark"
height="100%"
extensions={[StreamLanguage.define(yamlSupport)]}
value={sampleFieldTemplate}
/>
</CardContent>
</Card>
<TemplateEditorForm
key={refreshKey}
content={sampleFieldTemplate}
contentIsSpec
fieldExtensions={customFieldExtensions}
setErrorText={() => null}
/>
</div>
</main>
);
};
@@ -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<any, any>[];
layouts?: LayoutOptions[];
onClose?: () => void;
}) => {
const classes = useStyles();
const [errorText, setErrorText] = useState<string>();
return (
<DirectoryEditorProvider directory={props.directory}>
<DryRunProvider>
<main className={classes.root}>
<section className={classes.browser}>
<TemplateEditorBrowser onClose={props.onClose} />
</section>
<section className={classes.editor}>
<TemplateEditorTextArea.DirectoryEditor errorText={errorText} />
</section>
<section className={classes.preview}>
<TemplateEditorForm.DirectoryEditorDryRun
setErrorText={setErrorText}
fieldExtensions={props.fieldExtensions}
layouts={props.layouts}
/>
</section>
<section className={classes.results}>
<DryRunResults />
</section>
</main>
</DryRunProvider>
</DirectoryEditorProvider>
);
};
@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
state = {
shouldRender: true,
};
componentDidUpdate(prevProps: { invalidator: unknown }) {
if (prevProps.invalidator !== this.props.invalidator) {
this.setState({ shouldRender: true });
}
}
componentDidCatch(error: Error) {
this.props.setErrorText(error.message);
this.setState({ shouldRender: false });
}
render() {
return this.state.shouldRender ? this.props.children : null;
}
}
interface TemplateEditorFormProps {
content?: string;
/** Setting this to true will cause the content to be parsed as if it is the template entity spec */
contentIsSpec?: boolean;
setErrorText: (errorText?: string) => void;
onDryRun?: (data: JsonObject) => Promise<void>;
fieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
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<TemplateParameterSchema['steps']>();
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 (
<div className={classes.containerWrapper}>
<div className={classes.container}>
<ErrorBoundary invalidator={steps} setErrorText={setErrorText}>
<Stepper
manifest={{ steps, title: 'Template Editor' }}
extensions={fieldExtensions as unknown as FieldExtensionOptions[]}
onCreate={async data => {
await onDryRun?.(data);
}}
layouts={layouts}
components={{ createButtonText: onDryRun && 'Try It' }}
/>
</ErrorBoundary>
</div>
</div>
);
}
/** 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
onDryRun={handleDryRun}
fieldExtensions={fieldExtensions}
setErrorText={setErrorText}
content={content}
layouts={layouts}
/>
);
}
TemplateEditorForm.DirectoryEditorDryRun =
TemplateEditorFormDirectoryEditorDryRun;
@@ -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 =
| {
@@ -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<any, any>[];
onClose?: () => void;
layouts?: LayoutOptions[];
}) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
const catalogApi = useApi(catalogApiRef);
const [selectedTemplate, setSelectedTemplate] = useState('');
const [errorText, setErrorText] = useState<string>();
const [templateOptions, setTemplateOptions] = useState<TemplateOption[]>([]);
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 && <LinearProgress />}
<main className={classes.root}>
<div className={classes.controls}>
<FormControl variant="outlined" size="small" fullWidth>
<InputLabel id="select-template-label">
Load Existing Template
</InputLabel>
<Select
value={selectedTemplate}
label="Load Existing Template"
labelId="select-template-label"
onChange={e => handleSelectChange(e.target.value)}
>
{templateOptions.map((option, idx) => (
<MenuItem key={idx} value={option.value as any}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton size="medium" onClick={onClose}>
<CloseIcon />
</IconButton>
</div>
<div className={classes.textArea}>
<TemplateEditorTextArea
content={templateYaml}
onUpdate={setTemplateYaml}
errorText={errorText}
/>
</div>
<div className={classes.preview}>
<TemplateEditorForm
content={templateYaml}
contentIsSpec
fieldExtensions={customFieldExtensions}
setErrorText={setErrorText}
layouts={layouts}
/>
</div>
</main>
</>
);
};
+1
View File
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { LegacyRouter, type LegacyRouterProps } from './Router';
export { LegacyScaffolderPage } from '../plugin';
@@ -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: {
@@ -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';