Merge pull request #20221 from backstage/blam/promote-scaffolder

[scaffolder] Make the `next` `Router` the new default and upgrade to `rjsf@v5`
This commit is contained in:
Ben Lambert
2023-10-17 14:00:12 +02:00
committed by GitHub
116 changed files with 1252 additions and 758 deletions
+6 -6
View File
@@ -64,7 +64,7 @@ import { GcpProjectsPage } from '@backstage/plugin-gcp-projects';
import { HomepageCompositionRoot, VisitListener } from '@backstage/plugin-home';
import { LighthousePage } from '@backstage/plugin-lighthouse';
import { NewRelicPage } from '@backstage/plugin-newrelic';
import { NextScaffolderPage } from '@backstage/plugin-scaffolder/alpha';
import { LegacyScaffolderPage } from '@backstage/plugin-scaffolder/alpha';
import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder';
import {
ScaffolderFieldExtensions,
@@ -236,11 +236,11 @@ const routes = (
<LightBox />
</TechDocsAddons>
</Route>
<FeatureFlagged with="scaffolder-next-preview">
<FeatureFlagged with="scaffolder-legacy">
<Route
path="/create"
element={
<NextScaffolderPage
<LegacyScaffolderPage
groups={[
{
title: 'Recommended',
@@ -252,14 +252,14 @@ const routes = (
}
>
<ScaffolderFieldExtensions>
<DelayingComponentFieldExtension />
<LowerCaseValuePickerFieldExtension />
</ScaffolderFieldExtensions>
<ScaffolderLayouts>
<TwoColumnLayout />
</ScaffolderLayouts>
</Route>
</FeatureFlagged>
<FeatureFlagged without="scaffolder-next-preview">
<FeatureFlagged without="scaffolder-legacy">
<Route
path="/create"
element={
@@ -276,7 +276,7 @@ const routes = (
}
>
<ScaffolderFieldExtensions>
<LowerCaseValuePickerFieldExtension />
<DelayingComponentFieldExtension />
</ScaffolderFieldExtensions>
<ScaffolderLayouts>
<TwoColumnLayout />
@@ -17,10 +17,6 @@ import React from 'react';
import type { FieldValidation } from '@rjsf/utils';
import { scaffolderPlugin } from '@backstage/plugin-scaffolder';
import { TextField } from '@material-ui/core';
import {
NextFieldExtensionComponentProps,
createNextScaffolderFieldExtension,
} from '@backstage/plugin-scaffolder-react/alpha';
import {
createScaffolderFieldExtension,
FieldExtensionComponentProps,
@@ -67,7 +63,7 @@ export const LowerCaseValuePickerFieldExtension = scaffolderPlugin.provide(
);
const MockDelayComponent = (
props: NextFieldExtensionComponentProps<{ test?: string }>,
props: FieldExtensionComponentProps<{ test?: string }>,
) => {
const { onChange, formData, rawErrors = [] } = props;
return (
@@ -83,7 +79,7 @@ const MockDelayComponent = (
};
export const DelayingComponentFieldExtension = scaffolderPlugin.provide(
createNextScaffolderFieldExtension({
createScaffolderFieldExtension({
name: 'DelayingComponent',
component: MockDelayComponent,
validation: async (
+2 -2
View File
@@ -46,8 +46,8 @@
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"@rjsf/core-v5": "npm:@rjsf/core@5.13.0",
"@rjsf/material-ui-v5": "npm:@rjsf/material-ui@5.13.0",
"@rjsf/core": "5.13.0",
"@rjsf/material-ui": "5.13.0",
"@rjsf/utils": "5.13.0",
"@rjsf/validator-ajv8": "5.13.0",
"@types/react": "^16.13.1 || ^17.0.0",
@@ -27,10 +27,11 @@ import SettingsIcon from '@material-ui/icons/Settings';
import DeleteIcon from '@material-ui/icons/Delete';
import React from 'react';
import { Widget } from './types';
import { withTheme } from '@rjsf/core-v5';
import { withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import validator from '@rjsf/validator-ajv8';
const Form = withTheme(require('@rjsf/material-ui-v5').Theme);
const Form = withTheme(MuiTheme);
const useStyles = makeStyles((theme: Theme) =>
createStyles({
+46 -43
View File
@@ -6,13 +6,15 @@
/// <reference types="react" />
import { ApiHolder } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { CustomFieldExtensionSchema } from '@backstage/plugin-scaffolder-react';
import { CustomFieldValidator } from '@backstage/plugin-scaffolder-react';
import { Dispatch } from 'react';
import { Extension } from '@backstage/core-plugin-api';
import { FieldExtensionComponent } from '@backstage/plugin-scaffolder-react';
import { FieldProps } from '@rjsf/utils';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { FieldValidation } from '@rjsf/utils';
import { FormProps as FormProps_2 } from '@rjsf/core-v5';
import { FormProps } from '@backstage/plugin-scaffolder-react';
import { IconComponent } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
@@ -21,24 +23,39 @@ import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
import { ScaffolderRJSFFieldProps } from '@backstage/plugin-scaffolder-react';
import { ScaffolderRJSFFormProps } from '@backstage/plugin-scaffolder-react';
import { ScaffolderStep } from '@backstage/plugin-scaffolder-react';
import { ScaffolderTaskOutput } from '@backstage/plugin-scaffolder-react';
import { SetStateAction } from 'react';
import { TaskStep } from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react';
import { UIOptionsType } from '@rjsf/utils';
import { UiSchema } from '@rjsf/utils';
// @alpha (undocumented)
export const createAsyncValidators: (
rootSchema: JsonObject,
validators: Record<
string,
undefined | CustomFieldValidator<unknown, unknown>
>,
context: {
apiHolder: ApiHolder;
},
) => (formData: JsonObject) => Promise<FormValidation>;
// @alpha
export const createFieldValidation: () => FieldValidation;
// @alpha
export function createNextScaffolderFieldExtension<
export function createLegacyScaffolderFieldExtension<
TReturnValue = unknown,
TInputProps extends UIOptionsType = {},
TInputProps = unknown,
>(
options: NextFieldExtensionOptions<TReturnValue, TInputProps>,
options: LegacyFieldExtensionOptions<TReturnValue, TInputProps>,
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>>;
// @alpha
@@ -57,59 +74,47 @@ export const extractSchemaFromStep: (inputStep: JsonObject) => {
// @alpha
export const Form: (
props: PropsWithChildren<FormProps_2>,
props: PropsWithChildren<ScaffolderRJSFFormProps>,
) => React_2.JSX.Element;
// @alpha
export type FormProps = Pick<
FormProps_2,
'transformErrors' | 'noHtml5Validate'
>;
// @alpha (undocumented)
export type FormValidation = {
[name: string]: FieldValidation | FormValidation;
};
// @alpha
export type NextCustomFieldValidator<
TFieldReturnValue,
TUiOptions = unknown,
> = (
export type LegacyCustomFieldValidator<TFieldReturnValue> = (
data: TFieldReturnValue,
field: FieldValidation,
context: {
apiHolder: ApiHolder;
formData: JsonObject;
schema: JsonObject;
uiSchema?: NextFieldExtensionUiSchema<TFieldReturnValue, TUiOptions>;
},
) => void | Promise<void>;
// @alpha
export interface NextFieldExtensionComponentProps<
export interface LegacyFieldExtensionComponentProps<
TFieldReturnValue,
TUiOptions = {},
> extends PropsWithChildren<FieldProps<TFieldReturnValue>> {
TUiOptions = unknown,
> extends ScaffolderRJSFFieldProps<TFieldReturnValue> {
// (undocumented)
uiSchema?: NextFieldExtensionUiSchema<TFieldReturnValue, TUiOptions>;
uiSchema: ScaffolderRJSFFieldProps['uiSchema'] & {
'ui:options'?: TUiOptions;
};
}
// @alpha
export type NextFieldExtensionOptions<
export type LegacyFieldExtensionOptions<
TFieldReturnValue = unknown,
TUiOptions = unknown,
TInputProps = unknown,
> = {
name: string;
component: (
props: NextFieldExtensionComponentProps<TFieldReturnValue, TUiOptions>,
props: LegacyFieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
) => JSX.Element | null;
validation?: NextCustomFieldValidator<TFieldReturnValue, TUiOptions>;
validation?: LegacyCustomFieldValidator<TFieldReturnValue>;
schema?: CustomFieldExtensionSchema;
};
// @alpha
export interface NextFieldExtensionUiSchema<TFieldReturnValue, TUiOptions>
extends UiSchema<TFieldReturnValue> {
// (undocumented)
'ui:options'?: TUiOptions & UIOptionsType;
}
// @alpha
export interface ParsedTemplateSchema {
// (undocumented)
@@ -176,12 +181,13 @@ export const Stepper: (stepperProps: StepperProps) => React_2.JSX.Element;
// @alpha
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;
@@ -236,12 +242,6 @@ export const TemplateGroup: (
props: TemplateGroupProps,
) => React_2.JSX.Element | null;
// @alpha (undocumented)
export type TemplateGroupFilter = {
title?: React_2.ReactNode;
filter: (entity: TemplateEntityV1beta3) => boolean;
};
// @alpha
export interface TemplateGroupProps {
// (undocumented)
@@ -314,11 +314,14 @@ export type WorkflowProps = {
description?: string;
namespace: string;
templateName: string;
components?: {
ReviewStepComponent?: React_2.ComponentType<ReviewStepProps>;
};
onError(error: Error | undefined): JSX.Element | null;
} & Pick<
StepperProps,
| 'extensions'
| 'FormProps'
| 'formProps'
| 'components'
| 'onCreate'
| 'initialState'
+181 -16
View File
@@ -7,18 +7,42 @@
import { ApiHolder } from '@backstage/core-plugin-api';
import { ApiRef } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { CustomValidator } from '@rjsf/utils';
import { ElementType } from 'react';
import { ErrorSchema } from '@rjsf/utils';
import { ErrorTransformer } from '@rjsf/utils';
import { Experimental_DefaultFormStateBehavior } from '@rjsf/utils';
import { Extension } from '@backstage/core-plugin-api';
import { FieldProps } from '@rjsf/core';
import { FieldValidation } from '@rjsf/core';
import type { FormProps } from '@rjsf/core-v5';
import { FieldValidation } from '@rjsf/utils';
import Form from '@rjsf/core';
import { FormContextType } from '@rjsf/utils';
import { FormEvent } from 'react';
import type { FormProps as FormProps_2 } from '@rjsf/core';
import { GenericObjectType } from '@rjsf/utils';
import { HTMLAttributes } from 'react';
import { IChangeEvent } from '@rjsf/core';
import { IdSchema } from '@rjsf/utils';
import { JsonObject } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
import { JsonValue } from '@backstage/types';
import { Observable } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { Ref } from 'react';
import { Registry } from '@rjsf/utils';
import { RegistryWidgetsType } from '@rjsf/utils';
import { RJSFSchema } from '@rjsf/utils';
import { RJSFValidationError } from '@rjsf/utils';
import { StrictRJSFSchema } from '@rjsf/utils';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import { TaskStep } from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplatesType } from '@rjsf/utils';
import { UIOptionsType } from '@rjsf/utils';
import { UiSchema } from '@rjsf/utils';
import { ValidatorType } from '@rjsf/utils';
// @public
export type Action = {
@@ -40,7 +64,7 @@ export type ActionExample = {
// @public
export function createScaffolderFieldExtension<
TReturnValue = unknown,
TInputProps = unknown,
TInputProps extends UIOptionsType = {},
>(
options: FieldExtensionOptions<TReturnValue, TInputProps>,
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>>;
@@ -57,11 +81,14 @@ export type CustomFieldExtensionSchema = {
};
// @public
export type CustomFieldValidator<TFieldReturnValue> = (
export type CustomFieldValidator<TFieldReturnValue, TUiOptions = unknown> = (
data: TFieldReturnValue,
field: FieldValidation,
context: {
apiHolder: ApiHolder;
formData: JsonObject;
schema: JsonObject;
uiSchema?: FieldExtensionUiSchema<TFieldReturnValue, TUiOptions>;
},
) => void | Promise<void>;
@@ -71,27 +98,38 @@ export type FieldExtensionComponent<_TReturnValue, _TInputProps> = () => null;
// @public
export interface FieldExtensionComponentProps<
TFieldReturnValue,
TUiOptions = unknown,
> extends FieldProps<TFieldReturnValue> {
TUiOptions = {},
> extends PropsWithChildren<ScaffolderRJSFFieldProps<TFieldReturnValue>> {
// (undocumented)
uiSchema: FieldProps['uiSchema'] & {
'ui:options'?: TUiOptions;
};
uiSchema: FieldExtensionUiSchema<TFieldReturnValue, TUiOptions>;
}
// @public
export type FieldExtensionOptions<
TFieldReturnValue = unknown,
TInputProps = unknown,
TUiOptions = unknown,
> = {
name: string;
component: (
props: FieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
props: FieldExtensionComponentProps<TFieldReturnValue, TUiOptions>,
) => JSX.Element | null;
validation?: CustomFieldValidator<TFieldReturnValue>;
validation?: CustomFieldValidator<TFieldReturnValue, TUiOptions>;
schema?: CustomFieldExtensionSchema;
};
// @public
export interface FieldExtensionUiSchema<TFieldReturnValue, TUiOptions>
extends UiSchema<TFieldReturnValue> {
// (undocumented)
'ui:options'?: TUiOptions & UIOptionsType<TFieldReturnValue>;
}
// @public
export type FormProps = Pick<
FormProps_2,
'transformErrors' | 'noHtml5Validate'
>;
// @public
export type LayoutComponent<_TInputProps> = () => null;
@@ -105,7 +143,7 @@ export interface LayoutOptions<P = any> {
// @public
export type LayoutTemplate<T = any> = NonNullable<
FormProps<T>['uiSchema']
FormProps_2<T>['uiSchema']
>['ui:ObjectFieldTemplate'];
// @public
@@ -124,6 +162,20 @@ export type LogEvent = {
taskId: string;
};
// @public
export type ReviewStepProps = {
disableButtons: boolean;
formData: JsonObject;
handleBack: () => void;
handleReset: () => void;
handleCreate: () => void;
steps: {
uiSchema: UiSchema;
mergedSchema: JsonObject;
schema: JsonObject;
}[];
};
// @public
export interface ScaffolderApi {
cancelTask(taskId: string): Promise<void>;
@@ -186,8 +238,8 @@ export interface ScaffolderDryRunResponse {
}
// @public
export const ScaffolderFieldExtensions: React_2.ComponentType<
React_2.PropsWithChildren<{}>
export const ScaffolderFieldExtensions: React.ComponentType<
React.PropsWithChildren<{}>
>;
// @public
@@ -226,6 +278,113 @@ export type ScaffolderOutputText = {
content?: string;
};
// @public
export type ScaffolderRJSFField<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> = ComponentType<ScaffolderRJSFFieldProps<T, S, F>>;
// @public
export interface ScaffolderRJSFFieldProps<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> extends GenericObjectType,
Pick<
HTMLAttributes<HTMLElement>,
Exclude<
keyof HTMLAttributes<HTMLElement>,
'onBlur' | 'onFocus' | 'onChange'
>
> {
autofocus?: boolean;
disabled: boolean;
errorSchema?: ErrorSchema<T>;
formContext?: F;
formData: T;
hideError?: boolean;
idPrefix?: string;
idSchema: IdSchema<T>;
idSeparator?: string;
name: string;
onBlur: (id: string, value: any) => void;
onChange: (
newFormData: T | undefined,
es?: ErrorSchema<T>,
id?: string,
) => any;
onFocus: (id: string, value: any) => void;
rawErrors: string[];
readonly: boolean;
registry: Registry<T, S, F>;
required?: boolean;
schema: S;
uiSchema: UiSchema<T, S, F>;
}
// @public
export interface ScaffolderRJSFFormProps<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> {
acceptcharset?: string;
action?: string;
autoComplete?: string;
children?: ReactNode;
className?: string;
customValidate?: CustomValidator<T, S, F>;
disabled?: boolean;
enctype?: string;
experimental_defaultFormStateBehavior?: Experimental_DefaultFormStateBehavior;
extraErrors?: ErrorSchema<T>;
fields?: ScaffolderRJSFRegistryFieldsType<T, S, F>;
focusOnFirstError?: boolean | ((error: RJSFValidationError) => void);
formContext?: F;
formData?: T;
id?: string;
idPrefix?: string;
idSeparator?: string;
_internalFormWrapper?: ElementType;
liveOmit?: boolean;
liveValidate?: boolean;
method?: string;
name?: string;
noHtml5Validate?: boolean;
// @deprecated
noValidate?: boolean;
omitExtraData?: boolean;
onBlur?: (id: string, data: any) => void;
onChange?: (data: IChangeEvent<T, S, F>, id?: string) => void;
onError?: (errors: RJSFValidationError[]) => void;
onFocus?: (id: string, data: any) => void;
onSubmit?: (data: IChangeEvent<T, S, F>, event: FormEvent<any>) => void;
readonly?: boolean;
ref?: Ref<Form<T, S, F>>;
schema: S;
showErrorList?: false | 'top' | 'bottom';
tagName?: ElementType;
target?: string;
templates?: Partial<Omit<TemplatesType<T, S, F>, 'ButtonTemplates'>> & {
ButtonTemplates?: Partial<TemplatesType<T, S, F>['ButtonTemplates']>;
};
transformErrors?: ErrorTransformer<T, S, F>;
translateString?: Registry['translateString'];
uiSchema?: UiSchema<T, S, F>;
validator: ValidatorType<T, S, F>;
widgets?: RegistryWidgetsType<T, S, F>;
}
// @public
export type ScaffolderRJSFRegistryFieldsType<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> = {
[name: string]: ScaffolderRJSFField<T, S, F>;
};
// @public
export interface ScaffolderScaffoldOptions {
// (undocumented)
@@ -313,6 +472,12 @@ export type TaskStream = {
output?: ScaffolderTaskOutput;
};
// @public (undocumented)
export type TemplateGroupFilter = {
title?: React.ReactNode;
filter: (entity: TemplateEntityV1beta3) => boolean;
};
// @public
export type TemplateParameterSchema = {
title: string;
+2 -4
View File
@@ -60,10 +60,8 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"@react-hookz/web": "^20.0.0",
"@rjsf/core": "^3.2.1",
"@rjsf/core-v5": "npm:@rjsf/core@5.13.0",
"@rjsf/material-ui": "^3.2.1",
"@rjsf/material-ui-v5": "npm:@rjsf/material-ui@5.13.0",
"@rjsf/core": "5.13.0",
"@rjsf/material-ui": "5.13.0",
"@rjsf/utils": "5.13.0",
"@rjsf/validator-ajv8": "5.13.0",
"@types/json-schema": "^7.0.9",
+1 -1
View File
@@ -15,4 +15,4 @@
*/
export * from './next';
export type { FormProps } from './next';
export * from './legacy';
@@ -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';
@@ -0,0 +1,55 @@
/*
* 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.
*/
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import type { FormProps as SchemaFormProps } from '@rjsf/core';
import { UiSchema } from '@rjsf/utils';
import { JsonObject } from '@backstage/types';
/** @public */
export type TemplateGroupFilter = {
title?: React.ReactNode;
filter: (entity: TemplateEntityV1beta3) => boolean;
};
/**
* Any `@rjsf/core` form properties that are publicly exposed to the `ScaffolderPage`
*
* @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;
}[];
};
@@ -14,23 +14,17 @@
* limitations under the License.
*/
import React from 'react';
import {
CustomFieldExtensionSchema,
CustomFieldValidator,
FieldExtensionOptions,
FieldExtensionComponentProps,
FieldExtensionUiSchema,
CustomFieldExtensionSchema,
} from './types';
import { Extension, attachComponentData } from '@backstage/core-plugin-api';
import { UIOptionsType } from '@rjsf/utils';
import { FIELD_EXTENSION_KEY, FIELD_EXTENSION_WRAPPER_KEY } from './keys';
/**
* The type used to wrap up the Layout and embed the input props
*
* @public
*/
export type FieldExtensionComponent<_TReturnValue, _TInputProps> = () => null;
/**
* Method for creating field extensions that can be used in the scaffolder
* frontend form.
@@ -38,7 +32,7 @@ export type FieldExtensionComponent<_TReturnValue, _TInputProps> = () => null;
*/
export function createScaffolderFieldExtension<
TReturnValue = unknown,
TInputProps = unknown,
TInputProps extends UIOptionsType = {},
>(
options: FieldExtensionOptions<TReturnValue, TInputProps>,
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>> {
@@ -72,9 +66,19 @@ attachComponentData(
true,
);
/**
* The type used to wrap up the Layout and embed the input props
*
* @public
*/
export type FieldExtensionComponent<_TReturnValue, _TInputProps> = () => null;
export type {
CustomFieldExtensionSchema,
CustomFieldValidator,
FieldExtensionOptions,
FieldExtensionComponentProps,
FieldExtensionUiSchema,
CustomFieldExtensionSchema,
};
export * from './rjsf';
@@ -0,0 +1,292 @@
/*
* 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.
*/
import { ComponentType, ElementType, FormEvent, ReactNode, Ref } from 'react';
import {
ErrorSchema,
FormContextType,
GenericObjectType,
IdSchema,
Registry,
RJSFSchema,
StrictRJSFSchema,
UiSchema,
ValidatorType,
TemplatesType,
RegistryWidgetsType,
RJSFValidationError,
CustomValidator,
Experimental_DefaultFormStateBehavior,
ErrorTransformer,
} from '@rjsf/utils';
import { HTMLAttributes } from 'react';
import Form, { IChangeEvent } from '@rjsf/core';
/**
* The props for the `Field` components
* @public
*/
export interface ScaffolderRJSFFieldProps<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> extends GenericObjectType,
Pick<
HTMLAttributes<HTMLElement>,
Exclude<
keyof HTMLAttributes<HTMLElement>,
'onBlur' | 'onFocus' | 'onChange'
>
> {
/** The JSON subschema object for this field */
schema: S;
/** The uiSchema for this field */
uiSchema: UiSchema<T, S, F>;
/** The tree of unique ids for every child field */
idSchema: IdSchema<T>;
/** The data for this field */
formData: T;
/** The tree of errors for this field and its children */
errorSchema?: ErrorSchema<T>;
/** The field change event handler; called with the updated form data and an optional `ErrorSchema` */
onChange: (
newFormData: T | undefined,
es?: ErrorSchema<T>,
id?: string,
) => any;
/** The input blur event handler; call it with the field id and value */
onBlur: (id: string, value: any) => void;
/** The input focus event handler; call it with the field id and value */
onFocus: (id: string, value: any) => void;
/** The `formContext` object that you passed to `Form` */
formContext?: F;
/** A boolean value stating if the field should autofocus */
autofocus?: boolean;
/** A boolean value stating if the field is disabled */
disabled: boolean;
/** A boolean value stating if the field is hiding its errors */
hideError?: boolean;
/** A boolean value stating if the field is read-only */
readonly: boolean;
/** The required status of this field */
required?: boolean;
/** The unique name of the field, usually derived from the name of the property in the JSONSchema */
name: string;
/** To avoid collisions with existing ids in the DOM, it is possible to change the prefix used for ids;
* Default is `root`
*/
idPrefix?: string;
/** To avoid using a path separator that is present in field names, it is possible to change the separator used for
* ids (Default is `_`)
*/
idSeparator?: string;
/** An array of strings listing all generated error messages from encountered errors for this field */
rawErrors: string[];
/** The `registry` object */
registry: Registry<T, S, F>;
}
/**
* The properties that are passed to the `Form`
* @public
*/
export interface ScaffolderRJSFFormProps<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> {
/** The JSON schema object for the form */
schema: S;
/** An implementation of the `ValidatorType` interface that is needed for form validation to work */
validator: ValidatorType<T, S, F>;
/** The optional children for the form, if provided, it will replace the default `SubmitButton` */
children?: ReactNode;
/** The uiSchema for the form */
uiSchema?: UiSchema<T, S, F>;
/** The data for the form, used to prefill a form with existing data */
formData?: T;
/** You can provide a `formContext` object to the form, which is passed down to all fields and widgets. Useful for
* implementing context aware fields and widgets.
*
* NOTE: Setting `{readonlyAsDisabled: false}` on the formContext will make the antd theme treat readOnly fields as
* disabled.
*/
formContext?: F;
/** To avoid collisions with existing ids in the DOM, it is possible to change the prefix used for ids;
* Default is `root`
*/
idPrefix?: string;
/** To avoid using a path separator that is present in field names, it is possible to change the separator used for
* ids (Default is `_`)
*/
idSeparator?: string;
/** It's possible to disable the whole form by setting the `disabled` prop. The `disabled` prop is then forwarded down
* to each field of the form. If you just want to disable some fields, see the `ui:disabled` parameter in `uiSchema`
*/
disabled?: boolean;
/** It's possible to make the whole form read-only by setting the `readonly` prop. The `readonly` prop is then
* forwarded down to each field of the form. If you just want to make some fields read-only, see the `ui:readonly`
* parameter in `uiSchema`
*/
readonly?: boolean;
/** The dictionary of registered fields in the form */
fields?: ScaffolderRJSFRegistryFieldsType<T, S, F>;
/** The dictionary of registered templates in the form; Partial allows a subset to be provided beyond the defaults */
templates?: Partial<Omit<TemplatesType<T, S, F>, 'ButtonTemplates'>> & {
ButtonTemplates?: Partial<TemplatesType<T, S, F>['ButtonTemplates']>;
};
/** The dictionary of registered widgets in the form */
widgets?: RegistryWidgetsType<T, S, F>;
/** If you plan on being notified every time the form data are updated, you can pass an `onChange` handler, which will
* receive the same args as `onSubmit` any time a value is updated in the form. Can also return the `id` of the field
* that caused the change
*/
onChange?: (data: IChangeEvent<T, S, F>, id?: string) => void;
/** To react when submitted form data are invalid, pass an `onError` handler. It will be passed the list of
* encountered errors
*/
onError?: (errors: RJSFValidationError[]) => void;
/** You can pass a function as the `onSubmit` prop of your `Form` component to listen to when the form is submitted
* and its data are valid. It will be passed a result object having a `formData` attribute, which is the valid form
* data you're usually after. The original event will also be passed as a second parameter
*/
onSubmit?: (data: IChangeEvent<T, S, F>, event: FormEvent<any>) => void;
/** Sometimes you may want to trigger events or modify external state when a field has been touched, so you can pass
* an `onBlur` handler, which will receive the id of the input that was blurred and the field value
*/
onBlur?: (id: string, data: any) => void;
/** Sometimes you may want to trigger events or modify external state when a field has been focused, so you can pass
* an `onFocus` handler, which will receive the id of the input that is focused and the field value
*/
onFocus?: (id: string, data: any) => void;
/** The value of this prop will be passed to the `accept-charset` HTML attribute on the form */
acceptcharset?: string;
/** The value of this prop will be passed to the `action` HTML attribute on the form
*
* NOTE: this just renders the `action` attribute in the HTML markup. There is no real network request being sent to
* this `action` on submit. Instead, react-jsonschema-form catches the submit event with `event.preventDefault()`
* and then calls the `onSubmit` function, where you could send a request programmatically with `fetch` or similar.
*/
action?: string;
/** The value of this prop will be passed to the `autocomplete` HTML attribute on the form */
autoComplete?: string;
/** The value of this prop will be passed to the `class` HTML attribute on the form */
className?: string;
/** The value of this prop will be passed to the `enctype` HTML attribute on the form */
enctype?: string;
/** The value of this prop will be passed to the `id` HTML attribute on the form */
id?: string;
/** The value of this prop will be passed to the `name` HTML attribute on the form */
name?: string;
/** The value of this prop will be passed to the `method` HTML attribute on the form */
method?: string;
/** It's possible to change the default `form` tag name to a different HTML tag, which can be helpful if you are
* nesting forms. However, native browser form behaviour, such as submitting when the `Enter` key is pressed, may no
* longer work
*/
tagName?: ElementType;
/** The value of this prop will be passed to the `target` HTML attribute on the form */
target?: string;
/** Formerly the `validate` prop; Takes a function that specifies custom validation rules for the form */
customValidate?: CustomValidator<T, S, F>;
/** This prop allows passing in custom errors that are augmented with the existing JSON Schema errors on the form; it
* can be used to implement asynchronous validation
*/
extraErrors?: ErrorSchema<T>;
/** If set to true, turns off HTML5 validation on the form; Set to `false` by default */
noHtml5Validate?: boolean;
/** If set to true, turns off all validation. Set to `false` by default
*
* @deprecated - In a future release, this switch may be replaced by making `validator` prop optional
*/
noValidate?: boolean;
/** If set to true, the form will perform validation and show any validation errors whenever the form data is changed,
* rather than just on submit
*/
liveValidate?: boolean;
/** If `omitExtraData` and `liveOmit` are both set to true, then extra form data values that are not in any form field
* will be removed whenever `onChange` is called. Set to `false` by default
*/
liveOmit?: boolean;
/** If set to true, then extra form data values that are not in any form field will be removed whenever `onSubmit` is
* called. Set to `false` by default.
*/
omitExtraData?: boolean;
/** When this prop is set to `top` or 'bottom', a list of errors (or the custom error list defined in the `ErrorList`) will also
* show. When set to false, only inline input validation errors will be shown. Set to `top` by default
*/
showErrorList?: false | 'top' | 'bottom';
/** A function can be passed to this prop in order to make modifications to the default errors resulting from JSON
* Schema validation
*/
transformErrors?: ErrorTransformer<T, S, F>;
/** If set to true, then the first field with an error will receive the focus when the form is submitted with errors
*/
focusOnFirstError?: boolean | ((error: RJSFValidationError) => void);
/** Optional string translation function, if provided, allows users to change the translation of the RJSF internal
* strings. Some strings contain replaceable parameter values as indicated by `%1`, `%2`, etc. The number after the
* `%` indicates the order of the parameter. The ordering of parameters is important because some languages may choose
* to put the second parameter before the first in its translation.
*/
translateString?: Registry['translateString'];
/** Optional configuration object with flags, if provided, allows users to override default form state behavior
* Currently only affecting minItems on array fields and handling of setting defaults based on the value of
* `emptyObjectFields`
*/
experimental_defaultFormStateBehavior?: Experimental_DefaultFormStateBehavior;
/**
* _internalFormWrapper is currently used by the semantic-ui theme to provide a custom wrapper around `<Form />`
* that supports the proper rendering of those themes. To use this prop, one must pass a component that takes two
* props: `children` and `as`. That component, at minimum, should render the `children` inside of a <form /> tag
* unless `as` is provided, in which case, use the `as` prop in place of `<form />`.
* i.e.:
* ```
* export default function InternalForm({ children, as }) {
* const FormTag = as || 'form';
* return <FormTag>{children}</FormTag>;
* }
* ```
*
* Use at your own risk as this prop is private and may change at any time without notice.
*/
_internalFormWrapper?: ElementType;
/** Support receiving a React ref to the Form
*/
ref?: Ref<Form<T, S, F>>;
}
/**
* The set of `Fields` stored in the `Registry`
* @public
*/
export type ScaffolderRJSFRegistryFieldsType<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> = {
/** A `Field` indexed by `name` */
[name: string]: ScaffolderRJSFField<T, S, F>;
};
/**
* The `Field` type for Field Extensions
* @public
*/
export type ScaffolderRJSFField<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any,
> = ComponentType<ScaffolderRJSFFieldProps<T, S, F>>;
@@ -14,19 +14,23 @@
* limitations under the License.
*/
import { ApiHolder } from '@backstage/core-plugin-api';
import { FieldValidation, FieldProps } from '@rjsf/core';
import { PropsWithChildren } from 'react';
import { JsonObject } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
import { UiSchema, UIOptionsType, FieldValidation } from '@rjsf/utils';
import { ScaffolderRJSFFieldProps } from './rjsf';
/**
* Field validation type for Custom Field Extensions.
* Type for Field Extension Props for RJSF v5
*
* @public
*/
export type CustomFieldValidator<TFieldReturnValue> = (
data: TFieldReturnValue,
field: FieldValidation,
context: { apiHolder: ApiHolder },
) => void | Promise<void>;
export interface FieldExtensionComponentProps<
TFieldReturnValue,
TUiOptions = {},
> extends PropsWithChildren<ScaffolderRJSFFieldProps<TFieldReturnValue>> {
uiSchema: FieldExtensionUiSchema<TFieldReturnValue, TUiOptions>;
}
/**
* Type for the Custom Field Extension schema.
@@ -38,6 +42,32 @@ export type CustomFieldExtensionSchema = {
uiOptions?: JSONSchema7;
};
/**
* Type for Field Extension UiSchema
*
* @public
*/
export interface FieldExtensionUiSchema<TFieldReturnValue, TUiOptions>
extends UiSchema<TFieldReturnValue> {
'ui:options'?: TUiOptions & UIOptionsType<TFieldReturnValue>;
}
/**
* Field validation type for Custom Field Extensions.
*
* @public
*/
export type CustomFieldValidator<TFieldReturnValue, TUiOptions = unknown> = (
data: TFieldReturnValue,
field: FieldValidation,
context: {
apiHolder: ApiHolder;
formData: JsonObject;
schema: JsonObject;
uiSchema?: FieldExtensionUiSchema<TFieldReturnValue, TUiOptions>;
},
) => void | Promise<void>;
/**
* Type for the Custom Field Extension with the
* name and components and validation function.
@@ -46,27 +76,12 @@ export type CustomFieldExtensionSchema = {
*/
export type FieldExtensionOptions<
TFieldReturnValue = unknown,
TInputProps = unknown,
TUiOptions = unknown,
> = {
name: string;
component: (
props: FieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
props: FieldExtensionComponentProps<TFieldReturnValue, TUiOptions>,
) => JSX.Element | null;
validation?: CustomFieldValidator<TFieldReturnValue>;
validation?: CustomFieldValidator<TFieldReturnValue, TUiOptions>;
schema?: CustomFieldExtensionSchema;
};
/**
* Type for field extensions and being able to type
* incoming props easier.
*
* @public
*/
export interface FieldExtensionComponentProps<
TFieldReturnValue,
TUiOptions = unknown,
> extends FieldProps<TFieldReturnValue> {
uiSchema: FieldProps['uiSchema'] & {
'ui:options'?: TUiOptions;
};
}
+1
View File
@@ -15,6 +15,7 @@
*/
export * from './extensions';
export * from './components';
export * from './types';
export * from './secrets';
export * from './api';
@@ -16,7 +16,7 @@
import { LAYOUTS_KEY, LAYOUTS_WRAPPER_KEY } from './keys';
import { attachComponentData, Extension } from '@backstage/core-plugin-api';
import type { FormProps as SchemaFormProps } from '@rjsf/core-v5';
import type { FormProps as SchemaFormProps } from '@rjsf/core';
import React from 'react';
/**
@@ -15,13 +15,11 @@
*/
import {
NextCustomFieldValidator,
NextFieldExtensionOptions,
NextFieldExtensionComponentProps,
NextFieldExtensionUiSchema,
LegacyCustomFieldValidator,
LegacyFieldExtensionOptions,
LegacyFieldExtensionComponentProps,
} from './types';
import { Extension, attachComponentData } from '@backstage/core-plugin-api';
import { UIOptionsType } from '@rjsf/utils';
import { FIELD_EXTENSION_KEY } from '../../extensions/keys';
import { FieldExtensionComponent } from '@backstage/plugin-scaffolder-react';
@@ -30,11 +28,11 @@ import { FieldExtensionComponent } from '@backstage/plugin-scaffolder-react';
* frontend form.
* @alpha
*/
export function createNextScaffolderFieldExtension<
export function createLegacyScaffolderFieldExtension<
TReturnValue = unknown,
TInputProps extends UIOptionsType = {},
TInputProps = unknown,
>(
options: NextFieldExtensionOptions<TReturnValue, TInputProps>,
options: LegacyFieldExtensionOptions<TReturnValue, TInputProps>,
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>> {
return {
expose() {
@@ -52,8 +50,7 @@ export function createNextScaffolderFieldExtension<
}
export type {
NextCustomFieldValidator,
NextFieldExtensionOptions,
NextFieldExtensionComponentProps,
NextFieldExtensionUiSchema,
LegacyCustomFieldValidator,
LegacyFieldExtensionOptions,
LegacyFieldExtensionComponentProps,
};
@@ -0,0 +1,65 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiHolder } from '@backstage/core-plugin-api';
import { FieldValidation } from '@rjsf/utils';
import {
CustomFieldExtensionSchema,
ScaffolderRJSFFieldProps,
} from '@backstage/plugin-scaffolder-react';
/**
* Field validation type for Custom Field Extensions.
*
* @alpha
*/
export type LegacyCustomFieldValidator<TFieldReturnValue> = (
data: TFieldReturnValue,
field: FieldValidation,
context: { apiHolder: ApiHolder },
) => void | Promise<void>;
/**
* Type for the Custom Field Extension with the
* name and components and validation function.
*
* @alpha
*/
export type LegacyFieldExtensionOptions<
TFieldReturnValue = unknown,
TInputProps = unknown,
> = {
name: string;
component: (
props: LegacyFieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
) => JSX.Element | null;
validation?: LegacyCustomFieldValidator<TFieldReturnValue>;
schema?: CustomFieldExtensionSchema;
};
/**
* Type for field extensions and being able to type
* incoming props easier.
*
* @alpha
*/
export interface LegacyFieldExtensionComponentProps<
TFieldReturnValue,
TUiOptions = unknown,
> extends ScaffolderRJSFFieldProps<TFieldReturnValue> {
uiSchema: ScaffolderRJSFFieldProps['uiSchema'] & {
'ui:options'?: TUiOptions;
};
}
@@ -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 './extensions';
@@ -14,26 +14,47 @@
* limitations under the License.
*/
import { FormProps, withTheme } from '@rjsf/core-v5';
import { withTheme } from '@rjsf/core';
import React from 'react';
import { PropsWithChildren } from 'react';
import { FieldTemplate } from './FieldTemplate';
import { DescriptionFieldTemplate } from './DescriptionFieldTemplate';
import { FieldProps } from '@rjsf/utils';
import { ScaffolderRJSFFormProps } from '@backstage/plugin-scaffolder-react';
import { Theme as MuiTheme } from '@rjsf/material-ui';
// TODO(blam): We require here, as the types in this package depend on @rjsf/core explicitly
// which is what we're using here as the default types, it needs to depend on @rjsf/core-v5 because
// of the re-writing we're doing. Once we've migrated, we can import this the exact same as before.
const WrappedForm = withTheme(require('@rjsf/material-ui-v5').Theme);
const WrappedForm = withTheme(MuiTheme);
/**
* 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(
Object.entries(props.fields ?? {}).map(([key, Component]) => [
key,
(wrapperProps: FieldProps) => {
return (
<Component
{...wrapperProps}
uiSchema={wrapperProps.uiSchema ?? {}}
formData={wrapperProps.formData}
rawErrors={wrapperProps.rawErrors ?? []}
/>
);
},
]),
);
const templates = {
FieldTemplate,
DescriptionFieldTemplate,
...props.templates,
};
return <WrappedForm {...props} templates={templates} />;
return (
<WrappedForm {...props} templates={templates} fields={wrappedFields} />
);
};
@@ -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 }}
/>,
);
@@ -23,10 +23,15 @@ import {
makeStyles,
LinearProgress,
} from '@material-ui/core';
import { type IChangeEvent } from '@rjsf/core-v5';
import { type IChangeEvent } from '@rjsf/core';
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>
@@ -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,22 +19,23 @@ 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 '@backstage/plugin-scaffolder-react';
import { isObject } from './utils';
import { NextFieldExtensionUiSchema } from '../../extensions/types';
/**
* @internal
*/
/** @alpha */
export type FormValidation = {
[name: string]: FieldValidation | FormValidation;
};
/** @alpha */
export const createAsyncValidators = (
rootSchema: JsonObject,
validators: Record<
string,
undefined | NextCustomFieldValidator<unknown, unknown>
undefined | CustomFieldValidator<unknown, unknown>
>,
context: {
apiHolder: ApiHolder;
@@ -53,7 +54,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 +83,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,3 +14,7 @@
* limitations under the License.
*/
export { Stepper, type StepperProps } from './Stepper';
export {
createAsyncValidators,
type FormValidation,
} from './createAsyncValidators';
@@ -18,7 +18,7 @@ jest.mock('@backstage/plugin-catalog-react', () => ({
useEntityList: jest.fn(),
}));
jest.mock('@backstage/plugin-scaffolder-react/alpha', () => ({
jest.mock('../TemplateGroup/TemplateGroup', () => ({
TemplateGroup: jest.fn(() => null),
}));
@@ -27,7 +27,7 @@ import { useEntityList } from '@backstage/plugin-catalog-react';
import { TemplateGroups } from './TemplateGroups';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { errorApiRef } from '@backstage/core-plugin-api';
import { TemplateGroup } from '@backstage/plugin-scaffolder-react/alpha';
import { TemplateGroup } from '../TemplateGroup/TemplateGroup';
describe('TemplateGroups', () => {
beforeEach(() => jest.clearAllMocks());
@@ -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 '@backstage/plugin-scaffolder-react';
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 '@backstage/plugin-scaffolder-react';
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'
@@ -1,84 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiHolder } from '@backstage/core-plugin-api';
import {
UIOptionsType,
FieldProps as FieldPropsV5,
UiSchema as UiSchemaV5,
FieldValidation as FieldValidationV5,
} from '@rjsf/utils';
import { PropsWithChildren } from 'react';
import { JsonObject } from '@backstage/types';
import { CustomFieldExtensionSchema } from '@backstage/plugin-scaffolder-react';
/**
* Type for Field Extension Props for RJSF v5
*
* @alpha
*/
export interface NextFieldExtensionComponentProps<
TFieldReturnValue,
TUiOptions = {},
> extends PropsWithChildren<FieldPropsV5<TFieldReturnValue>> {
uiSchema?: NextFieldExtensionUiSchema<TFieldReturnValue, TUiOptions>;
}
/**
* Type for Field Extension UiSchema
*
* @alpha
*/
export interface NextFieldExtensionUiSchema<TFieldReturnValue, TUiOptions>
extends UiSchemaV5<TFieldReturnValue> {
'ui:options'?: TUiOptions & UIOptionsType;
}
/**
* Field validation type for Custom Field Extensions.
*
* @alpha
*/
export type NextCustomFieldValidator<
TFieldReturnValue,
TUiOptions = unknown,
> = (
data: TFieldReturnValue,
field: FieldValidationV5,
context: {
apiHolder: ApiHolder;
formData: JsonObject;
schema: JsonObject;
uiSchema?: NextFieldExtensionUiSchema<TFieldReturnValue, TUiOptions>;
},
) => void | Promise<void>;
/**
* Type for the Custom Field Extension with the
* name and components and validation function.
*
* @alpha
*/
export type NextFieldExtensionOptions<
TFieldReturnValue = unknown,
TUiOptions = unknown,
> = {
name: string;
component: (
props: NextFieldExtensionComponentProps<TFieldReturnValue, TUiOptions>,
) => JSX.Element | null;
validation?: NextCustomFieldValidator<TFieldReturnValue, TUiOptions>;
schema?: CustomFieldExtensionSchema;
};
@@ -14,7 +14,5 @@
* limitations under the License.
*/
export * from './components';
export * from './extensions';
export * from './types';
export * from './lib';
export * from './hooks';
@@ -1,31 +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 type { FormProps as SchemaFormProps } from '@rjsf/core-v5';
// 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.
/**
* Any `@rjsf/core` form properties that are publicly exposed to the `NextScaffolderpage`
*
* @alpha
*/
export type FormProps = Pick<
SchemaFormProps,
'transformErrors' | 'noHtml5Validate'
>;
+35 -24
View File
@@ -5,50 +5,58 @@
```ts
/// <reference types="react" />
import { FormProps as FormProps_2 } from '@backstage/plugin-scaffolder-react/alpha';
import type { FormProps as FormProps_3 } from '@rjsf/core-v5';
import { ComponentType } from 'react';
import { Entity } from '@backstage/catalog-model';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import type { FormProps as FormProps_2 } from '@rjsf/core';
import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react';
import { JSX as JSX_2 } from 'react';
import { LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { NextFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ScaffolderTaskOutput } from '@backstage/plugin-scaffolder-react';
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react/alpha';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
// @alpha @deprecated
export type FormProps = Pick<
FormProps_3,
FormProps_2,
'transformErrors' | 'noHtml5Validate'
>;
// @alpha
export type NextRouterProps = {
export const LegacyRouter: (props: LegacyRouterProps) => React_2.JSX.Element;
// @alpha
export type LegacyRouterProps = {
components?: {
TemplateCardComponent?: React_2.ComponentType<{
template: TemplateEntityV1beta3;
}>;
TaskPageComponent?: React_2.ComponentType<PropsWithChildren<{}>>;
TemplateOutputsComponent?: React_2.ComponentType<{
output?: ScaffolderTaskOutput;
}>;
TemplateListPageComponent?: React_2.ComponentType<TemplateListPageProps>;
TemplateWizardPageComponent?: React_2.ComponentType<TemplateWizardPageProps>;
ReviewStepComponent?: ComponentType<ReviewStepProps>;
TemplateCardComponent?:
| ComponentType<{
template: TemplateEntityV1beta3;
}>
| undefined;
TaskPageComponent?: ComponentType<PropsWithChildren<{}>>;
};
groups?: TemplateGroupFilter[];
groups?: Array<{
title?: React_2.ReactNode;
filter: (entity: Entity) => boolean;
}>;
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
FormProps?: FormProps_2;
defaultPreviewTemplate?: string;
headerOptions?: {
pageTitleOverride?: string;
title?: string;
subtitle?: string;
};
contextMenu?: {
editor?: boolean;
actions?: boolean;
tasks?: boolean;
};
};
// @alpha
export const NextScaffolderPage: (
props: PropsWithChildren<NextRouterProps>,
) => JSX_2.Element;
export const LegacyScaffolderPage: (props: LegacyRouterProps) => JSX_2.Element;
// @alpha (undocumented)
export type TemplateListPageProps = {
@@ -66,9 +74,12 @@ export type TemplateListPageProps = {
// @alpha (undocumented)
export type TemplateWizardPageProps = {
customFieldExtensions: NextFieldExtensionOptions<any, any>[];
customFieldExtensions: FieldExtensionOptions<any, any>[];
components?: {
ReviewStepComponent?: React_2.ComponentType<ReviewStepProps>;
};
layouts?: LayoutOptions[];
FormProps?: FormProps_2;
formProps?: FormProps_3;
};
// (No @packageDocumentation comment for this package)
+31 -33
View File
@@ -14,15 +14,14 @@ import { createScaffolderLayout as createScaffolderLayout_2 } from '@backstage/p
import { CustomFieldExtensionSchema as CustomFieldExtensionSchema_2 } from '@backstage/plugin-scaffolder-react';
import { CustomFieldValidator as CustomFieldValidator_2 } from '@backstage/plugin-scaffolder-react';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { FetchApi } from '@backstage/core-plugin-api';
import { FieldExtensionComponent as FieldExtensionComponent_2 } from '@backstage/plugin-scaffolder-react';
import { FieldExtensionComponentProps as FieldExtensionComponentProps_2 } from '@backstage/plugin-scaffolder-react';
import { FieldExtensionOptions as FieldExtensionOptions_2 } from '@backstage/plugin-scaffolder-react';
import { FieldValidation } from '@rjsf/core';
import { FieldValidation } from '@rjsf/utils';
import { FormProps } from '@backstage/plugin-scaffolder-react';
import { IdentityApi } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
import { LayoutOptions as LayoutOptions_2 } from '@backstage/plugin-scaffolder-react';
import { LayoutTemplate as LayoutTemplate_2 } from '@backstage/plugin-scaffolder-react';
@@ -33,6 +32,7 @@ import { PathParams } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
import { RouteRef } from '@backstage/core-plugin-api';
import { ScaffolderApi as ScaffolderApi_2 } from '@backstage/plugin-scaffolder-react';
import { ScaffolderDryRunOptions as ScaffolderDryRunOptions_2 } from '@backstage/plugin-scaffolder-react';
@@ -50,8 +50,10 @@ import { ScaffolderUseTemplateSecrets as ScaffolderUseTemplateSecrets_2 } from '
import { ScmIntegrationRegistry } from '@backstage/integration';
import { SubRouteRef } from '@backstage/core-plugin-api';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
import { TemplateListPageProps } from '@backstage/plugin-scaffolder/alpha';
import { TemplateParameterSchema as TemplateParameterSchema_2 } from '@backstage/plugin-scaffolder-react';
import { UiSchema } from '@rjsf/utils';
import { TemplateWizardPageProps } from '@backstage/plugin-scaffolder/alpha';
import { z } from 'zod';
// @public @deprecated (undocumented)
@@ -419,19 +421,7 @@ export const RepoUrlPickerFieldSchema: FieldSchema<
export type RepoUrlPickerUiOptions =
typeof RepoUrlPickerFieldSchema.uiOptionsType;
// @public
export type ReviewStepProps = {
disableButtons: boolean;
formData: JsonObject;
handleBack: () => void;
handleReset: () => void;
handleCreate: () => void;
steps: {
uiSchema: UiSchema;
mergedSchema: JsonObject;
schema: JsonObject;
}[];
};
export { ReviewStepProps };
// @public @deprecated (undocumented)
export const rootRouteRef: RouteRef<undefined>;
@@ -439,28 +429,30 @@ export const rootRouteRef: RouteRef<undefined>;
// @public
export type RouterProps = {
components?: {
ReviewStepComponent?: ComponentType<ReviewStepProps>;
TemplateCardComponent?:
| ComponentType<{
template: TemplateEntityV1beta3;
}>
| undefined;
TaskPageComponent?: ComponentType<PropsWithChildren<{}>>;
ReviewStepComponent?: React_2.ComponentType<ReviewStepProps>;
TemplateCardComponent?: React_2.ComponentType<{
template: TemplateEntityV1beta3;
}>;
TaskPageComponent?: React_2.ComponentType<PropsWithChildren<{}>>;
EXPERIMENTAL_TemplateOutputsComponent?: React_2.ComponentType<{
output?: ScaffolderTaskOutput_2;
}>;
EXPERIMENTAL_TemplateListPageComponent?: React_2.ComponentType<TemplateListPageProps>;
EXPERIMENTAL_TemplateWizardPageComponent?: React_2.ComponentType<TemplateWizardPageProps>;
};
groups?: Array<{
title?: React_2.ReactNode;
filter: (entity: Entity) => boolean;
}>;
groups?: TemplateGroupFilter[];
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
defaultPreviewTemplate?: string;
headerOptions?: {
pageTitleOverride?: string;
title?: string;
subtitle?: string;
};
defaultPreviewTemplate?: string;
formProps?: FormProps;
contextMenu?: {
editor?: boolean;
actions?: boolean;
tasks?: boolean;
};
};
@@ -537,7 +529,9 @@ export const ScaffolderLayouts: ComponentType<{
export type ScaffolderOutputlink = ScaffolderOutputLink;
// @public
export const ScaffolderPage: (props: RouterProps) => JSX_2.Element;
export const ScaffolderPage: (
props: PropsWithChildren<RouterProps>,
) => JSX_2.Element;
// @public
export const scaffolderPlugin: BackstagePlugin<
@@ -585,10 +579,14 @@ export type ScaffolderTaskStatus = ScaffolderTaskStatus_2;
// @public @deprecated (undocumented)
export type ScaffolderUseTemplateSecrets = ScaffolderUseTemplateSecrets_2;
// @public
export const TaskPage: (props: TaskPageProps) => React_2.JSX.Element;
// @public (undocumented)
export const TaskPage: (props: {
TemplateOutputsComponent?: React_2.ComponentType<{
output?: ScaffolderTaskOutput_2;
}>;
}) => React_2.JSX.Element;
// @public
// @public @deprecated
export type TaskPageProps = {
loadingText?: string;
};
+5 -5
View File
@@ -24,7 +24,7 @@ import {
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { scaffolderApiRef, ScaffolderClient } from '../src';
import { NextScaffolderPage, ScaffolderPage } from '../src/plugin';
import { ScaffolderPage, LegacyScaffolderPage } from '../src/plugin';
import {
discoveryApiRef,
fetchApiRef,
@@ -69,9 +69,9 @@ createDevApp()
element: <ScaffolderPage />,
})
.addPage({
path: '/next-create',
path: '/legacy-create',
title: 'Create (next)',
element: <NextScaffolderPage />,
element: <LegacyScaffolderPage />,
})
.addPage({
path: '/create-groups',
@@ -92,10 +92,10 @@ createDevApp()
),
})
.addPage({
path: '/next-create-groups',
path: '/legacy-create-groups',
title: 'Groups (next)',
element: (
<NextScaffolderPage
<LegacyScaffolderPage
groups={[
{
filter: e => e.metadata.tags?.includes('techdocs') || false,
+2 -2
View File
@@ -68,8 +68,8 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"@react-hookz/web": "^20.0.0",
"@rjsf/core": "^3.2.1",
"@rjsf/material-ui": "^3.2.1",
"@rjsf/core": "5.13.0",
"@rjsf/material-ui": "5.13.0",
"@rjsf/utils": "5.13.0",
"@rjsf/validator-ajv8": "5.13.0",
"@types/react": "^16.13.1 || ^17.0.0",
+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';
@@ -51,6 +51,9 @@ const useStyles = makeStyles(theme => ({
},
}));
/**
* @public
*/
export const OngoingTask = (props: {
TemplateOutputsComponent?: React.ComponentType<{
output?: ScaffolderTaskOutput;
@@ -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,13 +25,11 @@ import {
createScaffolderLayout,
ScaffolderLayouts,
} from '@backstage/plugin-scaffolder-react';
import { TemplateListPage, TemplateWizardPage } from '../../next';
jest.mock('../TemplateListPage', () => ({
TemplateListPage: jest.fn(() => null),
}));
jest.mock('../TemplateWizardPage', () => ({
jest.mock('../../next', () => ({
TemplateWizardPage: jest.fn(() => null),
TemplateListPage: jest.fn(() => null),
}));
describe('Router', () => {
@@ -52,7 +48,7 @@ describe('Router', () => {
const { getByText } = await renderInTestApp(
<Router
components={{
TemplateListPageComponent: () => <>foobar</>,
EXPERIMENTAL_TemplateListPageComponent: () => <>foobar</>,
}}
/>,
{
@@ -77,7 +73,7 @@ describe('Router', () => {
const { getByText } = await renderInTestApp(
<Router
components={{
TemplateWizardPageComponent: () => <>foobar</>,
EXPERIMENTAL_TemplateWizardPageComponent: () => <>foobar</>,
}}
/>,
{
@@ -93,7 +89,7 @@ describe('Router', () => {
await renderInTestApp(
<Router
FormProps={{
formProps={{
transformErrors: transformErrorsMock,
noHtml5Validate: true,
}}
@@ -105,9 +101,9 @@ describe('Router', () => {
const mock = TemplateWizardPage as jest.Mock;
const [{ FormProps }] = mock.mock.calls[0];
const [{ formProps }] = mock.mock.calls[0];
expect(FormProps).toEqual({
expect(formProps).toEqual({
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,45 @@ 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 {
TemplateListPageProps,
TemplateWizardPageProps,
} from '@backstage/plugin-scaffolder/alpha';
import { TemplateListPage, TemplateWizardPage } 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;
headerOptions?: {
pageTitleOverride?: string;
title?: string;
subtitle?: string;
};
defaultPreviewTemplate?: string;
formProps?: FormProps;
contextMenu?: {
/** Whether to show a link to the template editor */
editor?: boolean;
@@ -82,21 +92,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 +119,7 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
customFieldExtension => customFieldExtension.name === name,
),
),
] as NextFieldExtensionOptions[];
] as FieldExtensionOptions[];
const customLayouts = useCustomLayouts(outlet);
@@ -130,7 +143,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,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();
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { FieldValidation } from '@rjsf/core';
import { FieldValidation } from '@rjsf/utils';
import { KubernetesValidatorFunctions } from '@backstage/catalog-model';
import { entityNamePickerValidation } from './validation';
@@ -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 { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
apiVersion: 'scaffolder.backstage.io/v1beta3',
@@ -39,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(),
@@ -76,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
@@ -238,7 +238,7 @@ describe('<MyGroupsPicker />', () => {
onChange,
schema,
required,
} as unknown as FieldProps<any>;
} as unknown as FieldProps<string>;
const { 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,8 @@
*/
import React from 'react';
import { RepoUrlPicker } from './RepoUrlPicker';
import Form from '@rjsf/core';
import { Form } from '@backstage/plugin-scaffolder-react/alpha';
import validator from '@rjsf/validator-ajv8';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
scmIntegrationsApiRef,
@@ -29,6 +30,7 @@ import {
scaffolderApiRef,
ScaffolderApi,
useTemplateSecrets,
ScaffolderRJSFField,
} from '@backstage/plugin-scaffolder-react';
import { act, fireEvent } from '@testing-library/react';
@@ -73,9 +75,12 @@ describe('RepoUrlPicker', () => {
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{ 'ui:field': 'RepoUrlPicker' }}
fields={{ RepoUrlPicker: RepoUrlPicker }}
fields={{
RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField<string>,
}}
onSubmit={onSubmit}
/>
</SecretsContextProvider>
@@ -109,12 +114,15 @@ describe('RepoUrlPicker', () => {
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoUrlPicker',
'ui:options': { allowedHosts: ['dev.azure.com'] },
}}
fields={{ RepoUrlPicker: RepoUrlPicker }}
fields={{
RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField<string>,
}}
/>
</SecretsContextProvider>
</TestApiProvider>,
@@ -144,6 +152,7 @@ describe('RepoUrlPicker', () => {
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoUrlPicker',
@@ -154,7 +163,9 @@ describe('RepoUrlPicker', () => {
},
},
}}
fields={{ RepoUrlPicker: RepoUrlPicker }}
fields={{
RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField<string>,
}}
/>
<SecretsComponent />
</SecretsContextProvider>
@@ -206,6 +217,7 @@ describe('RepoUrlPicker', () => {
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoUrlPicker',
@@ -216,7 +228,9 @@ describe('RepoUrlPicker', () => {
},
},
}}
fields={{ RepoUrlPicker: RepoUrlPicker }}
fields={{
RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField<string>,
}}
/>
<SecretsComponent />
</SecretsContextProvider>
@@ -259,6 +273,7 @@ describe('RepoUrlPicker', () => {
>
<SecretsContextProvider>
<Form
validator={validator}
schema={{ type: 'string' }}
uiSchema={{
'ui:field': 'RepoUrlPicker',
@@ -269,7 +284,9 @@ describe('RepoUrlPicker', () => {
},
},
}}
fields={{ RepoUrlPicker: RepoUrlPicker }}
fields={{
RepoUrlPicker: RepoUrlPicker as ScaffolderRJSFField<string>,
}}
/>
<SecretsComponent />
</SecretsContextProvider>
@@ -15,7 +15,7 @@
*/
import { repoPickerValidation } from './validation';
import { FieldValidation } from '@rjsf/core';
import { FieldValidation } from '@rjsf/utils';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/core-app-api';
import { ApiHolder } from '@backstage/core-plugin-api';
@@ -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;
}[];
};
@@ -16,7 +16,7 @@
import React from 'react';
import { MarkdownContent } from '@backstage/core-components';
import { FieldProps } from '@rjsf/core';
import { FieldProps } from '@rjsf/utils';
export const DescriptionField = ({ description }: FieldProps) =>
description && <MarkdownContent content={description} linkTarget="_blank" />;
@@ -29,17 +29,20 @@ import {
useRouteRefParams,
useApi,
} from '@backstage/core-plugin-api';
import { FormProps, IChangeEvent, ISubmitEvent, withTheme } from '@rjsf/core';
import { FormProps, IChangeEvent, withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
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 validator from '@rjsf/validator-ajv8';
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);
@@ -178,6 +181,7 @@ export const MultistepJsonForm = (props: MultistepJsonFormProps) => {
</StepLabel>
<StepContent key={title}>
<Form
validator={validator}
showErrorList={false}
fields={{ ...fieldOverrides, ...fields }}
widgets={widgets}
@@ -185,7 +189,7 @@ export const MultistepJsonForm = (props: MultistepJsonFormProps) => {
formData={formData}
formContext={{ formData }}
onChange={onChange}
onSubmit={(e: ISubmitEvent<any>) => {
onSubmit={(e: IChangeEvent<any>) => {
if (e.errors.length === 0) handleNext();
}}
{...formProps}
@@ -16,9 +16,9 @@
import { Box, Button, Paper, Typography } from '@material-ui/core';
import React from 'react';
import { Content, StructuredMetadataTable } from '@backstage/core-components';
import { UiSchema } from '@rjsf/core';
import { UiSchema } from '@rjsf/utils';
import { JsonObject } from '@backstage/types';
import { ReviewStepProps } from '../types';
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
export function getReviewData(
formData: Record<string, any>,
@@ -15,8 +15,9 @@
*/
import { JsonObject } from '@backstage/types';
import { FormProps, UiSchema } from '@rjsf/core';
import { UiSchema } from '@rjsf/utils';
import type { LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { FormProps } from '@rjsf/core';
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -120,8 +121,7 @@ export function transformSchemaToProps(
)?.component;
if (Layout) {
uiSchema['ui:ObjectFieldTemplate'] =
Layout as unknown as FormProps<any>['ObjectFieldTemplate'];
uiSchema['ui:ObjectFieldTemplate'] = Layout;
}
}
@@ -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?:
@@ -236,8 +236,8 @@ 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;
@@ -28,16 +28,13 @@ import {
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 { Form } from '@backstage/plugin-scaffolder-react/alpha';
import { TemplateEditorForm } from './TemplateEditorForm';
const Form = withTheme(MuiTheme);
import validator from '@rjsf/validator-ajv8';
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
const useStyles = makeStyles(theme => ({
root: {
@@ -69,15 +66,15 @@ export const CustomFieldExplorer = ({
customFieldExtensions = [],
onClose,
}: {
customFieldExtensions?: FieldExtensionOptions<any, any>[];
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 [formState, setFormState] = useState({});
const [refreshKey, setRefreshKey] = useState(Date.now());
const [formState, setFormState] = useState({});
const sampleFieldTemplate = useMemo(
() =>
yaml.stringify({
@@ -104,18 +101,17 @@ export const CustomFieldExplorer = ({
}, [customFieldExtensions]);
const handleSelectionChange = useCallback(
(selection: FieldExtensionOptions) => {
selection => {
setSelectedField(selection);
setFieldFormState({});
setFormState({});
},
[setFieldFormState, setFormState, setSelectedField],
[setFieldFormState, setSelectedField, setFormState],
);
const handleFieldConfigChange = useCallback(
(state: {}) => {
state => {
setFieldFormState(state);
setFormState({});
// Force TemplateEditorForm to re-render since some fields
// may not be responsive to ui:option changes
setRefreshKey(Date.now());
@@ -134,9 +130,7 @@ export const CustomFieldExplorer = ({
value={selectedField}
label="Choose Custom Field Extension"
labelId="select-field-label"
onChange={e =>
handleSelectionChange(e.target.value as FieldExtensionOptions)
}
onChange={e => handleSelectionChange(e.target.value)}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
@@ -156,13 +150,12 @@ export const CustomFieldExplorer = ({
<CardContent>
<Form
showErrorList={false}
fields={{ ...fieldOverrides, ...fieldComponents }}
fields={{ ...fieldComponents }}
noHtml5Validate
formData={fieldFormState}
formContext={{ fieldFormState }}
onSubmit={(e: ISubmitEvent<any>) =>
handleFieldConfigChange(e.formData)
}
onSubmit={e => handleFieldConfigChange(e.formData)}
validator={validator}
schema={selectedField.schema?.uiOptions || {}}
>
<Button
@@ -191,12 +184,12 @@ export const CustomFieldExplorer = ({
</CardContent>
</Card>
<TemplateEditorForm
data={formState}
onUpdate={setFormState}
key={refreshKey}
content={sampleFieldTemplate}
contentIsSpec
fieldExtensions={customFieldExtensions}
data={formState}
onUpdate={setFormState}
setErrorText={() => null}
/>
</div>
@@ -15,17 +15,15 @@
*/
import { makeStyles } from '@material-ui/core';
import React, { useState } from 'react';
import type {
FieldExtensionOptions,
LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
import { TemplateDirectoryAccess } from '../../lib/filesystem';
import { DirectoryEditorProvider } from './DirectoryEditorContext';
import { DryRunProvider } from './DryRunContext';
import { DryRunResults } from './DryRunResults';
import { TemplateEditorBrowser } from './TemplateEditorBrowser';
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 { TemplateEditorTextArea } from './TemplateEditorTextArea';
import { DryRunResults } from '../../next/TemplateEditorPage/DryRunResults';
const useStyles = makeStyles({
// Reset and fix sizing to make sure scrolling behaves correctly
@@ -59,7 +57,7 @@ const useStyles = makeStyles({
export const TemplateEditor = (props: {
directory: TemplateDirectoryAccess;
fieldExtensions?: FieldExtensionOptions<any, any>[];
fieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
onClose?: () => void;
}) => {
@@ -19,15 +19,16 @@ 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,
import {
LayoutOptions,
TemplateParameterSchema,
} from '@backstage/plugin-scaffolder-react';
import { useDryRun } from '../../next/TemplateEditorPage/DryRunContext';
import { useDirectoryEditor } from '../../next/TemplateEditorPage/DirectoryEditorContext';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { createValidator } from '../TemplatePage';
import { useDirectoryEditor } from './DirectoryEditorContext';
import { useDryRun } from './DryRunContext';
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
const useStyles = makeStyles({
containerWrapper: {
@@ -85,7 +86,7 @@ interface TemplateEditorFormProps {
setErrorText: (errorText?: string) => void;
onDryRun?: (data: JsonObject) => Promise<void>;
fieldExtensions?: FieldExtensionOptions<any, any>[];
fieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
}
@@ -191,7 +192,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) {
<ErrorBoundary invalidator={steps} setErrorText={setErrorText}>
<MultistepJsonForm
steps={steps}
fields={fields}
fields={fields as any}
formData={data}
onChange={e => onUpdate(e.formData)}
onReset={() => onUpdate({})}
@@ -19,14 +19,12 @@ import {
TemplateDirectoryAccess,
WebFileSystemAccess,
} from '../../lib/filesystem';
import { CustomFieldExplorer } from './CustomFieldExplorer';
import { TemplateEditorIntro } from './TemplateEditorIntro';
import { type LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
import { TemplateEditorIntro } from '../../next/TemplateEditorPage/TemplateEditorIntro';
import { TemplateEditor } from './TemplateEditor';
import { TemplateFormPreviewer } from './TemplateFormPreviewer';
import {
type FieldExtensionOptions,
type LayoutOptions,
} from '@backstage/plugin-scaffolder-react';
import { CustomFieldExplorer } from './CustomFieldExplorer';
type Selection =
| {
@@ -42,7 +40,7 @@ type Selection =
interface TemplateEditorPageProps {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
customFieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
}
@@ -32,12 +32,10 @@ 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 { LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
import { TemplateEditorTextArea } from '../../next/TemplateEditorPage/TemplateEditorTextArea';
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:
@@ -116,7 +114,7 @@ export const TemplateFormPreviewer = ({
layouts = [],
}: {
defaultPreviewTemplate?: string;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
customFieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
onClose?: () => void;
layouts?: LayoutOptions[];
}) => {
@@ -164,8 +162,7 @@ export const TemplateFormPreviewer = ({
);
const handleSelectChange = useCallback(
// TODO(Rugvip): Afaik this should be Entity, but didn't want to make runtime changes while fixing types
(selected: any) => {
selected => {
setSelectedTemplate(selected);
setTemplateYaml(yaml.stringify(selected.spec));
},
@@ -195,7 +192,7 @@ export const TemplateFormPreviewer = ({
</Select>
</FormControl>
<IconButton size="medium" onClick={onClose} aria-label="Close">
<IconButton size="medium" onClick={onClose}>
<CloseIcon />
</IconButton>
</div>
@@ -208,11 +205,11 @@ export const TemplateFormPreviewer = ({
</div>
<div className={classes.preview}>
<TemplateEditorForm
data={formState}
onUpdate={setFormState}
content={templateYaml}
contentIsSpec
fieldExtensions={customFieldExtensions}
data={formState}
onUpdate={setFormState}
setErrorText={setErrorText}
layouts={layouts}
/>
@@ -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;
@@ -162,7 +162,7 @@ export const TemplatePage = ({
<MultistepJsonForm
ReviewStepComponent={ReviewStepComponent}
formData={formState}
fields={customFieldComponents}
fields={customFieldComponents as any}
onChange={handleChange}
onReset={handleFormReset}
onFinish={handleCreate}
@@ -15,9 +15,9 @@
*/
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';
import { FieldValidation, FormValidation } from '@rjsf/utils';
type CustomLinkType = {
url: string;
@@ -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(() => {
@@ -109,7 +111,7 @@ describe('createValidator', () => {
/* THEN */
expect(result).not.toBeNull();
expect(result.p1.addError).toHaveBeenCalledTimes(1);
expect(result.p1?.addError).toHaveBeenCalledTimes(1);
});
it('should call validator for array property from a custom field extension', () => {
@@ -144,7 +146,7 @@ describe('createValidator', () => {
/* THEN */
expect(result).not.toBeNull();
expect(result.tags.addError).toHaveBeenCalledTimes(1);
expect(result.tags?.addError).toHaveBeenCalledTimes(1);
});
it('should call validator for array object property from a custom field extension', () => {
@@ -193,6 +195,6 @@ describe('createValidator', () => {
/* THEN */
expect(result).not.toBeNull();
expect(result.links.addError).toHaveBeenCalledTimes(1);
expect(result.links?.addError).toHaveBeenCalledTimes(1);
});
});
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { CustomFieldValidator } from '@backstage/plugin-scaffolder-react';
import { FormValidation } from '@rjsf/core';
import { LegacyCustomFieldValidator } from '@backstage/plugin-scaffolder-react/alpha';
import { FieldValidation, FormValidation } from '@rjsf/utils';
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;
},
@@ -56,7 +56,7 @@ export const createValidator = (
if (fieldName && typeof validators[fieldName] === 'function') {
validators[fieldName]!(
propData as JsonObject[],
propValidation,
propValidation as FieldValidation,
context,
);
}
+17
View File
@@ -0,0 +1,17 @@
/*
* 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';
export { LegacyScaffolderPage } from '../plugin';
@@ -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) => (

Some files were not shown because too many files have changed in this diff Show More