@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user