feat: started some more work on getting this into shape

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2023-09-28 13:14:35 +02:00
committed by Patrik Oldsberg
parent 95415da622
commit 48db8c25eb
85 changed files with 311 additions and 1065 deletions
@@ -0,0 +1,15 @@
/*
* Copyright 2023 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.
*/
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './types';
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The Backstage Authors
* Copyright 2023 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.
@@ -14,18 +14,42 @@
* limitations under the License.
*/
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import type { FormProps as SchemaFormProps } from '@rjsf/core-v5';
import { UiSchema } from '@rjsf/utils';
import { JsonObject } from '@backstage/types';
// TODO(Rugvip): The FormProps type is actually supposed to be alpha, but since we want to
// refer to it from @backstage/plugin-scaffolder, it needs to be public for now.
// Once we support internal alpha re-exports this should be switched to an alpha export.
/** @public */
export type TemplateGroupFilter = {
title?: React.ReactNode;
filter: (entity: TemplateEntityV1beta3) => boolean;
};
/**
* Any `@rjsf/core` form properties that are publicly exposed to the `NextScaffolderpage`
* Any `@rjsf/core` form properties that are publicly exposed to the `ScaffolderPage`
*
* @alpha
* @public
*/
export type FormProps = Pick<
SchemaFormProps,
'transformErrors' | 'noHtml5Validate'
>;
/**
* The props for the Last Step in scaffolder template form.
* Which represents the summary of the input provided by the end user.
*
* @public
*/
export type ReviewStepProps = {
disableButtons: boolean;
formData: JsonObject;
handleBack: () => void;
handleReset: () => void;
handleCreate: () => void;
steps: {
uiSchema: UiSchema;
mergedSchema: JsonObject;
schema: JsonObject;
}[];
};
@@ -19,6 +19,7 @@ import {
FieldExtensionOptions,
FieldExtensionComponentProps,
FieldExtensionUiSchema,
CustomFieldExtensionSchema,
} from './types';
import { Extension, attachComponentData } from '@backstage/core-plugin-api';
import { UIOptionsType } from '@rjsf/utils';
@@ -77,4 +78,7 @@ export type {
FieldExtensionOptions,
FieldExtensionComponentProps,
FieldExtensionUiSchema,
CustomFieldExtensionSchema,
};
export * from './rjsf';
+1
View File
@@ -15,6 +15,7 @@
*/
export * from './extensions';
export * from './components';
export * from './types';
export * from './secrets';
export * from './api';
@@ -15,9 +15,9 @@
*/
import {
CustomFieldValidator,
FieldExtensionOptions,
FieldExtensionComponentProps,
LegacyCustomFieldValidator,
LegacyFieldExtensionOptions,
LegacyFieldExtensionComponentProps,
} from './types';
import { Extension, attachComponentData } from '@backstage/core-plugin-api';
import { FIELD_EXTENSION_KEY } from '../../extensions/keys';
@@ -32,7 +32,7 @@ export function createLegacyScaffolderFieldExtension<
TReturnValue = unknown,
TInputProps = unknown,
>(
options: FieldExtensionOptions<TReturnValue, TInputProps>,
options: LegacyFieldExtensionOptions<TReturnValue, TInputProps>,
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>> {
return {
expose() {
@@ -50,7 +50,7 @@ export function createLegacyScaffolderFieldExtension<
}
export type {
CustomFieldValidator,
FieldExtensionOptions,
FieldExtensionComponentProps,
LegacyCustomFieldValidator,
LegacyFieldExtensionOptions,
LegacyFieldExtensionComponentProps,
};
@@ -22,7 +22,7 @@ import { CustomFieldExtensionSchema } from '../../extensions/types';
*
* @alpha
*/
export type CustomFieldValidator<TFieldReturnValue> = (
export type LegacyCustomFieldValidator<TFieldReturnValue> = (
data: TFieldReturnValue,
field: FieldValidation,
context: { apiHolder: ApiHolder },
@@ -34,15 +34,15 @@ export type CustomFieldValidator<TFieldReturnValue> = (
*
* @alpha
*/
export type FieldExtensionOptions<
export type LegacyFieldExtensionOptions<
TFieldReturnValue = unknown,
TInputProps = unknown,
> = {
name: string;
component: (
props: FieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
props: LegacyFieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
) => JSX.Element | null;
validation?: CustomFieldValidator<TFieldReturnValue>;
validation?: LegacyCustomFieldValidator<TFieldReturnValue>;
schema?: CustomFieldExtensionSchema;
};
@@ -52,7 +52,7 @@ export type FieldExtensionOptions<
*
* @alpha
*/
export interface FieldExtensionComponentProps<
export interface LegacyFieldExtensionComponentProps<
TFieldReturnValue,
TUiOptions = unknown,
> extends FieldProps<TFieldReturnValue> {
@@ -25,8 +25,13 @@ import {
} from '@material-ui/core';
import { type IChangeEvent } from '@rjsf/core-v5';
import { ErrorSchema } from '@rjsf/utils';
import React, { useCallback, useMemo, useState, type ReactNode } from 'react';
import { NextFieldExtensionOptions } from '../../../extensions';
import React, {
useCallback,
useMemo,
useState,
type ReactNode,
ComponentType,
} from 'react';
import {
createAsyncValidators,
type FormValidation,
@@ -35,7 +40,6 @@ import { ReviewState, type ReviewStateProps } from '../ReviewState';
import { useTemplateSchema } from '../../hooks/useTemplateSchema';
import validator from '@rjsf/validator-ajv8';
import { useFormDataFromQuery } from '../../hooks';
import { FormProps } from '../../types';
import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps';
import { hasErrors } from './utils';
import * as FieldOverrides from './FieldOverrides';
@@ -43,7 +47,10 @@ import { Form } from '../Form';
import {
TemplateParameterSchema,
LayoutOptions,
FieldExtensionOptions,
FormProps,
} from '@backstage/plugin-scaffolder-react';
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
const useStyles = makeStyles(theme => ({
backButton: {
@@ -66,12 +73,13 @@ const useStyles = makeStyles(theme => ({
*/
export type StepperProps = {
manifest: TemplateParameterSchema;
extensions: NextFieldExtensionOptions<any, any>[];
extensions: FieldExtensionOptions<any, any>[];
templateName?: string;
FormProps?: FormProps;
formProps?: FormProps;
initialState?: Record<string, JsonValue>;
onCreate: (values: Record<string, JsonValue>) => Promise<void>;
components?: {
ReviewStepComponent?: ComponentType<ReviewStepProps>;
ReviewStateComponent?: (props: ReviewStateProps) => JSX.Element;
createButtonText?: ReactNode;
reviewButtonText?: ReactNode;
@@ -87,6 +95,7 @@ export const Stepper = (stepperProps: StepperProps) => {
const { layouts = [], components = {}, ...props } = stepperProps;
const {
ReviewStateComponent = ReviewState,
ReviewStepComponent,
createButtonText = 'Create',
reviewButtonText = 'Review',
} = components;
@@ -128,6 +137,13 @@ export const Stepper = (stepperProps: StepperProps) => {
[setFormState],
);
const handleCreate = useCallback(() => {
props.onCreate(formState);
const name =
typeof formState.name === 'string' ? formState.name : undefined;
analytics.captureEvent('create', name ?? props.templateName ?? 'unknown');
}, [props, formState, analytics]);
const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts });
const handleNext = async ({
@@ -171,6 +187,7 @@ export const Stepper = (stepperProps: StepperProps) => {
</MuiStep>
</MuiStepper>
<div className={styles.formWrapper}>
{/* eslint-disable-next-line no-nested-ternary */}
{activeStep < steps.length ? (
<Form
validator={validator}
@@ -183,7 +200,7 @@ export const Stepper = (stepperProps: StepperProps) => {
fields={{ ...FieldOverrides, ...extensions }}
showErrorList={false}
onChange={handleChange}
{...(props.FormProps ?? {})}
{...(props.formProps ?? {})}
>
<div className={styles.footer}>
<Button
@@ -203,6 +220,16 @@ export const Stepper = (stepperProps: StepperProps) => {
</Button>
</div>
</Form>
) : // TODO: potentially move away from this pattern, deprecate?
ReviewStepComponent ? (
<ReviewStepComponent
disableButtons={isValidating}
formData={formState}
handleBack={handleBack}
handleReset={() => {}}
steps={steps}
handleCreate={handleCreate}
/>
) : (
<>
<ReviewStateComponent formState={formState} schemas={steps} />
@@ -217,17 +244,7 @@ export const Stepper = (stepperProps: StepperProps) => {
<Button
variant="contained"
color="primary"
onClick={() => {
props.onCreate(formState);
const name =
typeof formState.name === 'string'
? formState.name
: undefined;
analytics.captureEvent(
'create',
name ?? props.templateName ?? 'unknown',
);
}}
onClick={handleCreate}
>
{createButtonText}
</Button>
@@ -23,15 +23,8 @@ import {
import { Progress, Link } from '@backstage/core-components';
import { Typography } from '@material-ui/core';
import { errorApiRef, IconComponent, useApi } from '@backstage/core-plugin-api';
import { TemplateGroup } from '@backstage/plugin-scaffolder-react/alpha';
/**
* @alpha
*/
export type TemplateGroupFilter = {
title?: React.ReactNode;
filter: (entity: TemplateEntityV1beta3) => boolean;
};
import { TemplateGroupFilter } from '../../../components';
import { TemplateGroup } from '../TemplateGroup/TemplateGroup';
/**
* @alpha
@@ -27,6 +27,7 @@ import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema';
import { Stepper, type StepperProps } from '../Stepper/Stepper';
import { SecretsContextProvider } from '../../../secrets/SecretsContext';
import { ReviewStepProps } from '../../../components';
const useStyles = makeStyles<BackstageTheme>(() => ({
markdown: {
@@ -48,11 +49,14 @@ export type WorkflowProps = {
description?: string;
namespace: string;
templateName: string;
components?: {
ReviewStepComponent?: React.ComponentType<ReviewStepProps>;
};
onError(error: Error | undefined): JSX.Element | null;
} & Pick<
StepperProps,
| 'extensions'
| 'FormProps'
| 'formProps'
| 'components'
| 'onCreate'
| 'initialState'
+2 -2
View File
@@ -14,10 +14,10 @@
* limitations under the License.
*/
export { NextScaffolderPage } from './plugin';
export {
type NextRouterProps,
type FormProps,
type TemplateListPageProps,
type TemplateWizardPageProps,
} from './next';
export * from './legacy';
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import React from 'react';
import { TemplateListPage } from '../TemplateListPage';
import { TemplateWizardPage } from '../TemplateWizardPage';
import { Router } from './Router';
import { renderInTestApp } from '@backstage/test-utils';
import {
@@ -27,6 +25,7 @@ import {
createScaffolderLayout,
ScaffolderLayouts,
} from '@backstage/plugin-scaffolder-react';
import { TemplateListPage, TemplateWizardPage } from '../../next';
jest.mock('../TemplateListPage', () => ({
TemplateListPage: jest.fn(() => null),
@@ -52,7 +51,7 @@ describe('Router', () => {
const { getByText } = await renderInTestApp(
<Router
components={{
TemplateListPageComponent: () => <>foobar</>,
EXPERIMENTAL_TemplateListPageComponent: () => <>foobar</>,
}}
/>,
{
@@ -77,7 +76,7 @@ describe('Router', () => {
const { getByText } = await renderInTestApp(
<Router
components={{
TemplateWizardPageComponent: () => <>foobar</>,
EXPERIMENTAL_TemplateWizardPageComponent: () => <>foobar</>,
}}
/>,
{
@@ -93,7 +92,7 @@ describe('Router', () => {
await renderInTestApp(
<Router
FormProps={{
formProps={{
transformErrors: transformErrorsMock,
noHtml5Validate: true,
}}
@@ -15,16 +15,13 @@
*/
import React, { PropsWithChildren } from 'react';
import { Routes, Route, useOutlet } from 'react-router-dom';
import { TemplateListPage, TemplateListPageProps } from '../TemplateListPage';
import {
TemplateWizardPage,
TemplateWizardPageProps,
} from '../TemplateWizardPage';
import {
NextFieldExtensionOptions,
FieldExtensionOptions,
FormProps,
ReviewStepProps,
TemplateGroupFilter,
} from '@backstage/plugin-scaffolder-react/alpha';
} from '@backstage/plugin-scaffolder-react';
import {
ScaffolderTaskOutput,
SecretsContextProvider,
@@ -43,32 +40,40 @@ import {
selectedTemplateRouteRef,
} from '../../routes';
import { ErrorPage } from '@backstage/core-components';
import { OngoingTask } from '../OngoingTask';
import { ActionsPage } from '../../components/ActionsPage';
import { ListTasksPage } from '../../components/ListTasksPage';
import { TemplateEditorPage } from '../TemplateEditorPage';
import {
TemplateListPage,
TemplateListPageProps,
TemplateWizardPage,
TemplateWizardPageProps,
} from '../../next';
import { OngoingTask } from '../OngoingTask';
import { TemplateEditorPage } from '../../next/TemplateEditorPage';
/**
* The Props for the Scaffolder Router
*
* @alpha
* @public
*/
export type NextRouterProps = {
export type RouterProps = {
components?: {
ReviewStepComponent?: React.ComponentType<ReviewStepProps>;
TemplateCardComponent?: React.ComponentType<{
template: TemplateEntityV1beta3;
}>;
TaskPageComponent?: React.ComponentType<PropsWithChildren<{}>>;
TemplateOutputsComponent?: React.ComponentType<{
EXPERIMENTAL_TemplateOutputsComponent?: React.ComponentType<{
output?: ScaffolderTaskOutput;
}>;
TemplateListPageComponent?: React.ComponentType<TemplateListPageProps>;
TemplateWizardPageComponent?: React.ComponentType<TemplateWizardPageProps>;
EXPERIMENTAL_TemplateListPageComponent?: React.ComponentType<TemplateListPageProps>;
EXPERIMENTAL_TemplateWizardPageComponent?: React.ComponentType<TemplateWizardPageProps>;
};
groups?: TemplateGroupFilter[];
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
// todo(blam): rename this to formProps
FormProps?: FormProps;
formProps?: FormProps;
contextMenu?: {
/** Whether to show a link to the template editor */
editor?: boolean;
@@ -82,21 +87,24 @@ export type NextRouterProps = {
/**
* The Scaffolder Router
*
* @alpha
* @public
*/
export const Router = (props: PropsWithChildren<NextRouterProps>) => {
export const Router = (props: PropsWithChildren<RouterProps>) => {
const {
components: {
TemplateCardComponent,
TemplateOutputsComponent,
TaskPageComponent = OngoingTask,
TemplateListPageComponent = TemplateListPage,
TemplateWizardPageComponent = TemplateWizardPage,
ReviewStepComponent,
EXPERIMENTAL_TemplateOutputsComponent: TemplateOutputsComponent,
EXPERIMENTAL_TemplateListPageComponent:
TemplateListPageComponent = TemplateListPage,
EXPERIMENTAL_TemplateWizardPageComponent:
TemplateWizardPageComponent = TemplateWizardPage,
} = {},
} = props;
const outlet = useOutlet() || props.children;
const customFieldExtensions =
useCustomFieldExtensions<NextFieldExtensionOptions>(outlet);
useCustomFieldExtensions<FieldExtensionOptions>(outlet);
const fieldExtensions = [
...customFieldExtensions,
@@ -106,7 +114,7 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
customFieldExtension => customFieldExtension.name === name,
),
),
] as NextFieldExtensionOptions[];
] as FieldExtensionOptions[];
const customLayouts = useCustomLayouts(outlet);
@@ -130,7 +138,8 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
<TemplateWizardPageComponent
customFieldExtensions={fieldExtensions}
layouts={customLayouts}
FormProps={props.FormProps}
components={{ ReviewStepComponent }}
formProps={props.formProps}
/>
</SecretsContextProvider>
}
@@ -13,5 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Router } from './Router';
export type { NextRouterProps } from './Router';
export { Router, type RouterProps } from './Router';
@@ -1,205 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { StreamLanguage } from '@codemirror/language';
import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
import {
Button,
Card,
CardContent,
CardHeader,
FormControl,
IconButton,
InputLabel,
makeStyles,
MenuItem,
Select,
} from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import { ISubmitEvent, withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import CodeMirror from '@uiw/react-codemirror';
import React, { useCallback, useMemo, useState } from 'react';
import yaml from 'yaml';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import * as fieldOverrides from '../MultistepJsonForm/FieldOverrides';
import { TemplateEditorForm } from './TemplateEditorForm';
const Form = withTheme(MuiTheme);
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?: FieldExtensionOptions<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 [formState, setFormState] = 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: FieldExtensionOptions) => {
setSelectedField(selection);
setFieldFormState({});
setFormState({});
},
[setFieldFormState, setFormState, setSelectedField],
);
const handleFieldConfigChange = useCallback(
(state: {}) => {
setFieldFormState(state);
setFormState({});
// 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 as FieldExtensionOptions)
}
>
{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}
fields={{ ...fieldOverrides, ...fieldComponents }}
noHtml5Validate
formData={fieldFormState}
formContext={{ fieldFormState }}
onSubmit={(e: ISubmitEvent<any>) =>
handleFieldConfigChange(e.formData)
}
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}
data={formState}
onUpdate={setFormState}
setErrorText={() => null}
/>
</div>
</main>
);
};
@@ -1,94 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { makeStyles } from '@material-ui/core';
import React, { useState } from 'react';
import type {
FieldExtensionOptions,
LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { TemplateDirectoryAccess } from '../../lib/filesystem';
import { DirectoryEditorProvider } from './DirectoryEditorContext';
import { DryRunProvider } from './DryRunContext';
import { DryRunResults } from './DryRunResults';
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
const useStyles = makeStyles({
// Reset and fix sizing to make sure scrolling behaves correctly
root: {
gridArea: 'pageContent',
display: 'grid',
gridTemplateAreas: `
"browser editor preview"
"results results results"
`,
gridTemplateColumns: '1fr 3fr 2fr',
gridTemplateRows: '1fr auto',
},
browser: {
gridArea: 'browser',
overflow: 'auto',
},
editor: {
gridArea: 'editor',
overflow: 'auto',
},
preview: {
gridArea: 'preview',
overflow: 'auto',
},
results: {
gridArea: 'results',
},
});
export const TemplateEditor = (props: {
directory: TemplateDirectoryAccess;
fieldExtensions?: FieldExtensionOptions<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>
);
};
@@ -1,260 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApiHolder } from '@backstage/core-plugin-api';
import { JsonObject, JsonValue } from '@backstage/types';
import { makeStyles } from '@material-ui/core/styles';
import React, { Component, ReactNode, useMemo, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import yaml from 'yaml';
import type {
FieldExtensionOptions,
LayoutOptions,
TemplateParameterSchema,
} from '@backstage/plugin-scaffolder-react';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { createValidator } from '../TemplatePage';
import { useDirectoryEditor } from './DirectoryEditorContext';
import { useDryRun } from './DryRunContext';
const useStyles = makeStyles({
containerWrapper: {
position: 'relative',
width: '100%',
height: '100%',
},
container: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
overflow: 'auto',
},
});
interface ErrorBoundaryProps {
invalidator: unknown;
setErrorText(errorText: string | undefined): void;
children: ReactNode;
}
interface ErrorBoundaryState {
shouldRender: boolean;
}
class ErrorBoundary extends Component<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;
data: JsonObject;
onUpdate: (data: JsonObject) => void;
setErrorText: (errorText?: string) => void;
onDryRun?: (data: JsonObject) => Promise<void>;
fieldExtensions?: FieldExtensionOptions<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,
data,
onUpdate,
onDryRun,
setErrorText,
fieldExtensions = [],
layouts = [],
} = props;
const classes = useStyles();
const apiHolder = useApiHolder();
const [steps, setSteps] = useState<TemplateParameterSchema['steps']>();
const fields = useMemo(() => {
return Object.fromEntries(
fieldExtensions.map(({ name, component }) => [name, component]),
);
}, [fieldExtensions]);
useDebounce(
() => {
try {
if (!content) {
setSteps(undefined);
return;
}
const parsed: JsonValue = yaml
.parseAllDocuments(content)
.filter(c => c)
.map(c => c.toJSON())[0];
if (!isJsonObject(parsed)) {
setSteps(undefined);
return;
}
let rootObj = parsed;
if (!contentIsSpec) {
const isTemplate =
String(parsed.kind).toLocaleLowerCase('en-US') === 'template';
if (!isTemplate) {
setSteps(undefined);
return;
}
rootObj = isJsonObject(parsed.spec) ? parsed.spec : {};
}
const { parameters } = rootObj;
if (!Array.isArray(parameters)) {
setErrorText('Template parameters must be an array');
setSteps(undefined);
return;
}
const fieldValidators = Object.fromEntries(
fieldExtensions.map(({ name, validation }) => [name, validation]),
);
setErrorText();
setSteps(
parameters.flatMap(param =>
isJsonObject(param)
? [
{
title: String(param.title),
schema: param,
validate: createValidator(param, fieldValidators, {
apiHolder,
}),
},
]
: [],
),
);
} catch (e) {
setErrorText(e.message);
}
},
250,
[contentIsSpec, content, apiHolder],
);
if (!steps) {
return null;
}
return (
<div className={classes.containerWrapper}>
<div className={classes.container}>
<ErrorBoundary invalidator={steps} setErrorText={setErrorText}>
<MultistepJsonForm
steps={steps}
fields={fields}
formData={data}
onChange={e => onUpdate(e.formData)}
onReset={() => onUpdate({})}
finishButtonLabel={onDryRun && 'Try It'}
onFinish={onDryRun && (() => onDryRun(data))}
layouts={layouts}
/>
</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 [data, setData] = useState<JsonObject>({});
const handleDryRun = async () => {
if (!selectedFile) {
return;
}
try {
await dryRun.execute({
templateContent: selectedFile.content,
values: data,
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}
data={data}
onUpdate={setData}
layouts={layouts}
/>
);
}
TemplateEditorForm.DirectoryEditorDryRun =
TemplateEditorFormDirectoryEditorDryRun;
@@ -1,58 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TemplateEditorPage } from './TemplateEditorPage';
describe('TemplateEditorPage', () => {
it('renders without exploding', async () => {
await renderInTestApp(<TemplateEditorPage />);
expect(screen.getByText('Load Template Directory')).toBeInTheDocument();
expect(screen.getByText('Edit Template Form')).toBeInTheDocument();
});
it('template directory loading should not be supported in Jest', async () => {
await renderInTestApp(<TemplateEditorPage />);
expect(
screen.getByRole('button', { name: /Load Template Directory/ }),
).toBeDisabled();
});
it('should be able to continue to form preview', async () => {
await renderInTestApp(
<TestApiProvider
apis={[
[
catalogApiRef,
{ getEntities: jest.fn().mockResolvedValue({ items: [] }) },
],
]}
>
<TemplateEditorPage />
</TestApiProvider>,
);
await userEvent.click(screen.getByText('Edit Template Form'));
expect(screen.getByLabelText('Load Existing Template')).toBeInTheDocument();
});
});
@@ -1,223 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { 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 {
type FieldExtensionOptions,
type LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
parameters:
- title: Fill in some steps
required:
- name
properties:
name:
title: Name
type: string
description: Unique name of the component
owner:
title: Owner
type: string
description: Owner of the component
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
- title: Choose a location
required:
- repoUrl
properties:
repoUrl:
title: Repository Location
type: string
ui:field: RepoUrlPicker
ui:options:
allowedHosts:
- github.com
steps:
- id: fetch-base
name: Fetch Base
action: fetch:template
input:
url: ./template
values:
name: \${{parameters.name}}
`;
type TemplateOption = {
label: string;
value: Entity;
};
const useStyles = makeStyles(theme => ({
root: {
gridArea: 'pageContent',
display: 'grid',
gridTemplateAreas: `
"controls controls"
"textArea preview"
`,
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr 1fr',
},
controls: {
gridArea: 'controls',
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
margin: theme.spacing(1),
},
textArea: {
gridArea: 'textArea',
},
preview: {
gridArea: 'preview',
},
}));
export const TemplateFormPreviewer = ({
defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
customFieldExtensions = [],
onClose,
layouts = [],
}: {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions<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 [formState, setFormState] = useState({});
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(
// TODO(Rugvip): Afaik this should be Entity, but didn't want to make runtime changes while fixing types
(selected: any) => {
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} aria-label="Close">
<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}
data={formState}
onUpdate={setFormState}
setErrorText={setErrorText}
layouts={layouts}
/>
</div>
</main>
</>
);
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { FieldValidation } from '@rjsf/core';
import { FieldValidation } from '@rjsf/utils';
import { KubernetesValidatorFunctions } from '@backstage/catalog-model';
export const entityNamePickerValidation = (
@@ -18,11 +18,12 @@ import { CATALOG_FILTER_EXISTS } 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 { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { EntityPicker } from './EntityPicker';
import { EntityPickerProps } from './schema';
import { FieldProps } from '@rjsf/utils';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
apiVersion: 'scaffolder.backstage.io/v1beta3',
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { FieldValidation } from '@rjsf/core';
import { FieldValidation } from '@rjsf/utils';
import { ApiHolder } from '@backstage/core-plugin-api';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
+7 -2
View File
@@ -15,7 +15,12 @@
*/
export * from './fields';
export type { RepoUrlPickerUiOptions } from './fields';
export { TemplateTypePicker } from './TemplateTypePicker';
export { TaskPage, type TaskPageProps } from './TaskPage';
export type { RouterProps } from './Router';
export type { ReviewStepProps } from './types';
export { OngoingTask as TaskPage } from './OngoingTask';
export type { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
export type { TaskPageProps } from '../legacy/TaskPage';
@@ -13,25 +13,3 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { UiSchema } from '@rjsf/utils';
import { JsonObject } from '@backstage/types';
/**
* The props for the Last Step in scaffolder template form.
* Which represents the summary of the input provided by the end user.
*
* @public
*/
export type ReviewStepProps = {
disableButtons: boolean;
formData: JsonObject;
handleBack: () => void;
handleReset: () => void;
handleCreate: () => void;
steps: {
uiSchema: UiSchema;
mergedSchema: JsonObject;
schema: JsonObject;
}[];
};
@@ -35,11 +35,13 @@ import React, { ComponentType, useState } from 'react';
import { transformSchemaToProps } from './schema';
import cloneDeep from 'lodash/cloneDeep';
import * as fieldOverrides from './FieldOverrides';
import { ReviewStepProps } from '../types';
import { ReviewStep } from './ReviewStep';
import { extractSchemaFromStep } from '@backstage/plugin-scaffolder-react/alpha';
import { selectedTemplateRouteRef } from '../../routes';
import { LayoutOptions } from '@backstage/plugin-scaffolder-react';
import {
LayoutOptions,
ReviewStepProps,
} from '@backstage/plugin-scaffolder-react';
const Form = withTheme(MuiTheme);
@@ -18,7 +18,7 @@ import React from 'react';
import { Content, StructuredMetadataTable } from '@backstage/core-components';
import { UiSchema } from '@rjsf/core';
import { JsonObject } from '@backstage/types';
import { ReviewStepProps } from '../types';
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
export function getReviewData(
formData: Record<string, any>,
@@ -21,18 +21,17 @@ import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { ScaffolderPage } from './ScaffolderPage';
import { TemplatePage } from './TemplatePage';
import { TaskPage } from './TaskPage';
import { ActionsPage } from './ActionsPage';
import { TemplateEditorPage } from './TemplateEditorPage';
import { ActionsPage } from '../components/ActionsPage';
import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../extensions/default';
import { useRouteRef, useRouteRefParams } from '@backstage/core-plugin-api';
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
import {
FieldExtensionOptions,
ReviewStepProps,
SecretsContextProvider,
useCustomFieldExtensions,
useCustomLayouts,
} from '@backstage/plugin-scaffolder-react';
import { ListTasksPage } from './ListTasksPage';
import { ReviewStepProps } from './types';
import { ListTasksPage } from '../components/ListTasksPage';
import {
actionsRouteRef,
editRouteRef,
@@ -41,12 +40,13 @@ import {
scaffolderTaskRouteRef,
selectedTemplateRouteRef,
} from '../routes';
import { TemplateEditorPage } from './TemplateEditorPage';
/**
* The props for the entrypoint `ScaffolderPage` component the plugin.
* @public
* @alpha
*/
export type RouterProps = {
export type LegacyRouterProps = {
components?: {
ReviewStepComponent?: ComponentType<ReviewStepProps>;
TemplateCardComponent?:
@@ -77,11 +77,11 @@ export type RouterProps = {
};
/**
* The main entrypoint `Router` for the `ScaffolderPlugin`.
* The legacy router
*
* @public
* @alpha
*/
export const Router = (props: RouterProps) => {
export const LegacyRouter = (props: LegacyRouterProps) => {
const {
groups,
templateFilter,
@@ -95,7 +95,9 @@ export const Router = (props: RouterProps) => {
const outlet = useOutlet();
const TaskPageElement = TaskPageComponent ?? TaskPage;
const customFieldExtensions = useCustomFieldExtensions(outlet);
const customFieldExtensions =
useCustomFieldExtensions<LegacyFieldExtensionOptions>(outlet);
const fieldExtensions = [
...customFieldExtensions,
...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter(
@@ -104,7 +106,7 @@ export const Router = (props: RouterProps) => {
customFieldExtension => customFieldExtension.name === name,
),
),
] as FieldExtensionOptions[];
] as LegacyFieldExtensionOptions[];
const customLayouts = useCustomLayouts(outlet);
@@ -34,11 +34,11 @@ import {
} from '@backstage/plugin-catalog-react';
import React, { ComponentType } from 'react';
import { TemplateList } from '../TemplateList';
import { TemplateTypePicker } from '../TemplateTypePicker';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha';
import { usePermission } from '@backstage/plugin-permission-react';
import { ScaffolderPageContextMenu } from './ScaffolderPageContextMenu';
import { registerComponentRouteRef } from '../../routes';
import { TemplateTypePicker } from '../../components';
export type ScaffolderPageProps = {
TemplateCardComponent?:
@@ -237,7 +237,7 @@ const hasLinks = ({ links = [] }: ScaffolderTaskOutput): boolean =>
* TaskPageProps for constructing a TaskPage
* @param loadingText - Optional loading text shown before a task begins executing.
*
* @public
* @deprecated - this is a useless type that is no longer used.
*/
export type TaskPageProps = {
loadingText?: string;
@@ -246,7 +246,7 @@ export type TaskPageProps = {
/**
* TaskPage for showing the status of the taskId provided as a param
*
* @public
* @alpha
*/
export const TaskPage = (props: TaskPageProps) => {
const { loadingText } = props;
@@ -19,14 +19,12 @@ import {
TemplateDirectoryAccess,
WebFileSystemAccess,
} from '../../lib/filesystem';
import { CustomFieldExplorer } from './CustomFieldExplorer';
import { TemplateEditorIntro } from './TemplateEditorIntro';
import { TemplateEditor } from './TemplateEditor';
import { TemplateFormPreviewer } from './TemplateFormPreviewer';
import {
type FieldExtensionOptions,
type LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
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';
type Selection =
| {
@@ -42,7 +40,7 @@ type Selection =
interface TemplateEditorPageProps {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
customFieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
}
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The Backstage Authors
* Copyright 2023 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.
@@ -13,5 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TemplateEditorPage } from './TemplateEditorPage';
@@ -20,10 +20,10 @@ import React, { ComponentType, useCallback, useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import useAsync from 'react-use/lib/useAsync';
import {
type FieldExtensionOptions,
type LayoutOptions,
scaffolderApiRef,
useTemplateSecrets,
ReviewStepProps,
} from '@backstage/plugin-scaffolder-react';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { createValidator } from './createValidator';
@@ -38,12 +38,12 @@ import {
useRouteRefParams,
} from '@backstage/core-plugin-api';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { ReviewStepProps } from '../types';
import {
rootRouteRef,
scaffolderTaskRouteRef,
selectedTemplateRouteRef,
} from '../../routes';
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
const useTemplateParameterSchema = (templateRef: string) => {
const scaffolderApi = useApi(scaffolderApiRef);
@@ -56,7 +56,7 @@ const useTemplateParameterSchema = (templateRef: string) => {
type Props = {
ReviewStepComponent?: ComponentType<ReviewStepProps>;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
customFieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
headerOptions?: {
pageTitleOverride?: string;
@@ -15,7 +15,7 @@
*/
import { createValidator } from './createValidator';
import { CustomFieldValidator } from '@backstage/plugin-scaffolder-react';
import { LegacyCustomFieldValidator } from '@backstage/plugin-scaffolder-react/alpha';
import { ApiHolder } from '@backstage/core-plugin-api';
import { FieldValidation, FormValidation } from '@rjsf/core';
@@ -26,49 +26,51 @@ type CustomLinkType = {
};
describe('createValidator', () => {
const validators: Record<string, undefined | CustomFieldValidator<unknown>> =
{
CustomPicker: (
value: unknown,
fieldValidation: FieldValidation,
_context: { apiHolder: ApiHolder },
) => {
if (!value || !(value as { value?: unknown }).value) {
fieldValidation.addError('Error !');
}
},
CustomLink: (
values: unknown,
fieldValidation: FieldValidation,
_context: { apiHolder: ApiHolder },
) => {
const input = values as CustomLinkType[];
for (const item of input) {
const validGitlabUrlRegex =
/gitlab\.(?:stg\.)?spotify\.com\?owner=.*&repo=.*/;
const validators: Record<
string,
undefined | LegacyCustomFieldValidator<unknown>
> = {
CustomPicker: (
value: unknown,
fieldValidation: FieldValidation,
_context: { apiHolder: ApiHolder },
) => {
if (!value || !(value as { value?: unknown }).value) {
fieldValidation.addError('Error !');
}
},
CustomLink: (
values: unknown,
fieldValidation: FieldValidation,
_context: { apiHolder: ApiHolder },
) => {
const input = values as CustomLinkType[];
for (const item of input) {
const validGitlabUrlRegex =
/gitlab\.(?:stg\.)?spotify\.com\?owner=.*&repo=.*/;
if (!item || !validGitlabUrlRegex.test(item.url)) {
fieldValidation.addError(
`Make sure to put in a valid gitlab clone url.`,
);
}
if (!item || !validGitlabUrlRegex.test(item.url)) {
fieldValidation.addError(
`Make sure to put in a valid gitlab clone url.`,
);
}
},
TagPicker: (
values: unknown,
fieldValidation: FieldValidation,
_context: { apiHolder: ApiHolder },
) => {
const input = values as string[];
for (const item of input) {
if (!/^[a-z0-9-]+$/.test(item)) {
fieldValidation.addError(
'A tag name can only contain lowercase letters, numeric characters or dashes',
);
}
}
},
TagPicker: (
values: unknown,
fieldValidation: FieldValidation,
_context: { apiHolder: ApiHolder },
) => {
const input = values as string[];
for (const item of input) {
if (!/^[a-z0-9-]+$/.test(item)) {
fieldValidation.addError(
'A tag name can only contain lowercase letters, numeric characters or dashes',
);
}
},
};
}
},
};
const apiHolderMock: jest.Mocked<ApiHolder> = {
get: jest.fn().mockImplementation(() => {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { CustomFieldValidator } from '@backstage/plugin-scaffolder-react';
import { LegacyCustomFieldValidator } from '@backstage/plugin-scaffolder-react/alpha';
import { FormValidation } from '@rjsf/core';
import { JsonObject, JsonValue } from '@backstage/types';
import { ApiHolder } from '@backstage/core-plugin-api';
@@ -29,7 +29,7 @@ function isArray(obj: unknown): obj is JsonObject {
export const createValidator = (
rootSchema: JsonObject,
validators: Record<string, undefined | CustomFieldValidator<unknown>>,
validators: Record<string, undefined | LegacyCustomFieldValidator<unknown>>,
context: {
apiHolder: ApiHolder;
},
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { LegacyRouter, type LegacyRouterProps } from './Router';
@@ -31,12 +31,10 @@ 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 {
NextFieldExtensionOptions,
Form,
} from '@backstage/plugin-scaffolder-react/alpha';
import { Form } from '@backstage/plugin-scaffolder-react/alpha';
import { TemplateEditorForm } from './TemplateEditorForm';
import validator from '@rjsf/validator-ajv8';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
const useStyles = makeStyles(theme => ({
root: {
@@ -68,7 +66,7 @@ export const CustomFieldExplorer = ({
customFieldExtensions = [],
onClose,
}: {
customFieldExtensions?: NextFieldExtensionOptions<any, any>[];
customFieldExtensions?: FieldExtensionOptions<any, any>[];
onClose?: () => void;
}) => {
const classes = useStyles();
@@ -102,7 +100,7 @@ export const CustomFieldExplorer = ({
}, [customFieldExtensions]);
const handleSelectionChange = useCallback(
(selection: NextFieldExtensionOptions) => {
(selection: FieldExtensionOptions) => {
setSelectedField(selection);
setFieldFormState({});
},
@@ -131,7 +129,7 @@ export const CustomFieldExplorer = ({
label="Choose Custom Field Extension"
labelId="select-field-label"
onChange={e =>
handleSelectionChange(e.target.value as NextFieldExtensionOptions)
handleSelectionChange(e.target.value as FieldExtensionOptions)
}
>
{fieldOptions.map((option, idx) => (
@@ -152,6 +150,7 @@ export const CustomFieldExplorer = ({
<CardContent>
<Form
showErrorList={false}
// @ts-ignore
fields={{ ...fieldComponents }}
noHtml5Validate
formData={fieldFormState}
@@ -24,11 +24,11 @@ import Tab from '@material-ui/core/Tab';
import Tabs from '@material-ui/core/Tabs';
import CodeMirror from '@uiw/react-codemirror';
import React, { useEffect, useMemo, useState } from 'react';
import { TaskStatusStepper } from '../../TaskPage/TaskPage';
import { TaskPageLinks } from '../../TaskPage/TaskPageLinks';
import { useDryRun } from '../DryRunContext';
import { FileBrowser } from '../../FileBrowser';
import { DryRunResultsSplitView } from './DryRunResultsSplitView';
import { FileBrowser } from '../../../components/FileBrowser';
import { TaskPageLinks } from '../../../legacy/TaskPage/TaskPageLinks';
import { TaskStatusStepper } from '../../../legacy/TaskPage/TaskPage';
const useStyles = makeStyles({
root: {
@@ -16,14 +16,14 @@
import { makeStyles } from '@material-ui/core';
import React, { useState } from 'react';
import type { LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { NextFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { TemplateDirectoryAccess } from '../../lib/filesystem';
import { DirectoryEditorProvider } from '../../components/TemplateEditorPage/DirectoryEditorContext';
import { DryRunProvider } from '../../components/TemplateEditorPage/DryRunContext';
import { DryRunResults } from '../../components/TemplateEditorPage/DryRunResults';
import { TemplateEditorBrowser } from '../../components/TemplateEditorPage/TemplateEditorBrowser';
import { DirectoryEditorProvider } from './DirectoryEditorContext';
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
import { DryRunProvider } from './DryRunContext';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from '../../components/TemplateEditorPage/TemplateEditorTextArea';
import { DryRunResults } from './DryRunResults';
const useStyles = makeStyles({
// Reset and fix sizing to make sure scrolling behaves correctly
@@ -57,7 +57,7 @@ const useStyles = makeStyles({
export const TemplateEditor = (props: {
directory: TemplateDirectoryAccess;
fieldExtensions?: NextFieldExtensionOptions<any, any>[];
fieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
onClose?: () => void;
}) => {
@@ -22,13 +22,11 @@ import yaml from 'yaml';
import {
LayoutOptions,
TemplateParameterSchema,
FieldExtensionOptions,
} from '@backstage/plugin-scaffolder-react';
import {
NextFieldExtensionOptions,
Stepper,
} from '@backstage/plugin-scaffolder-react/alpha';
import { useDryRun } from '../../components/TemplateEditorPage/DryRunContext';
import { useDirectoryEditor } from '../../components/TemplateEditorPage/DirectoryEditorContext';
import { Stepper } from '@backstage/plugin-scaffolder-react/alpha';
import { useDryRun } from './DryRunContext';
import { useDirectoryEditor } from './DirectoryEditorContext';
const useStyles = makeStyles({
containerWrapper: {
@@ -83,7 +81,7 @@ interface TemplateEditorFormProps {
contentIsSpec?: boolean;
setErrorText: (errorText?: string) => void;
onDryRun?: (data: JsonObject) => Promise<void>;
fieldExtensions?: NextFieldExtensionOptions<any, any>[];
fieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
}
@@ -22,9 +22,11 @@ import {
import { CustomFieldExplorer } from './CustomFieldExplorer';
import { TemplateEditor } from './TemplateEditor';
import { TemplateFormPreviewer } from './TemplateFormPreviewer';
import { type LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { NextFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
import { TemplateEditorIntro } from '../../components/TemplateEditorPage/TemplateEditorIntro';
import {
FieldExtensionOptions,
type LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { TemplateEditorIntro } from './TemplateEditorIntro';
type Selection =
| {
@@ -40,7 +42,7 @@ type Selection =
interface TemplateEditorPageProps {
defaultPreviewTemplate?: string;
customFieldExtensions?: NextFieldExtensionOptions<any, any>[];
customFieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
}
@@ -32,10 +32,12 @@ 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 { type LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { NextFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
import {
LayoutOptions,
FieldExtensionOptions,
} from '@backstage/plugin-scaffolder-react';
import { TemplateEditorForm } from './TemplateEditorForm';
import { TemplateEditorTextArea } from '../../components/TemplateEditorPage/TemplateEditorTextArea';
import { TemplateEditorTextArea } from './TemplateEditorTextArea';
const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
parameters:
@@ -114,7 +116,7 @@ export const TemplateFormPreviewer = ({
layouts = [],
}: {
defaultPreviewTemplate?: string;
customFieldExtensions?: NextFieldExtensionOptions<any, any>[];
customFieldExtensions?: FieldExtensionOptions<any, any>[];
onClose?: () => void;
layouts?: LayoutOptions[];
}) => {
@@ -38,7 +38,6 @@ import {
import {
ScaffolderPageContextMenu,
TemplateCategoryPicker,
TemplateGroupFilter,
TemplateGroups,
} from '@backstage/plugin-scaffolder-react/alpha';
@@ -52,6 +51,7 @@ import {
viewTechDocRouteRef,
} from '../../routes';
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
/**
* @alpha
@@ -26,12 +26,10 @@ import {
scaffolderApiRef,
useTemplateSecrets,
type LayoutOptions,
FieldExtensionOptions,
ReviewStepProps,
} from '@backstage/plugin-scaffolder-react';
import {
FormProps,
Workflow,
NextFieldExtensionOptions,
} from '@backstage/plugin-scaffolder-react/alpha';
import { FormProps, Workflow } from '@backstage/plugin-scaffolder-react/alpha';
import { JsonValue } from '@backstage/types';
import { Header, Page } from '@backstage/core-components';
@@ -45,9 +43,12 @@ import {
* @alpha
*/
export type TemplateWizardPageProps = {
customFieldExtensions: NextFieldExtensionOptions<any, any>[];
customFieldExtensions: FieldExtensionOptions<any, any>[];
components?: {
ReviewStepComponent?: React.ComponentType<ReviewStepProps>;
};
layouts?: LayoutOptions[];
FormProps?: FormProps;
formProps?: FormProps;
};
export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
@@ -90,9 +91,10 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
namespace={namespace}
templateName={templateName}
onCreate={onCreate}
components={props.components}
onError={onError}
extensions={props.customFieldExtensions}
FormProps={props.FormProps}
formProps={props.formProps}
layouts={props.layouts}
/>
</Page>
-1
View File
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './Router';
export * from './TemplateListPage';
export * from './TemplateWizardPage';
export * from './types';
+3 -3
View File
@@ -217,10 +217,10 @@ export const EntityTagsPickerFieldExtension = scaffolderPlugin.provide(
* @alpha
* The Router and main entrypoint to the Alpha Scaffolder plugin.
*/
export const NextScaffolderPage = scaffolderPlugin.provide(
export const LegacyScaffolderPage = scaffolderPlugin.provide(
createRoutableExtension({
name: 'NextScaffolderPage',
component: () => import('./next/Router').then(m => m.Router),
name: 'LegacyScaffolderPage',
component: () => import('./legacy/Router').then(m => m.LegacyRouter),
mountPoint: rootRouteRef,
}),
);