diff --git a/docs/assets/software-templates/custom-field-explorer.png b/docs/assets/software-templates/custom-field-explorer.png new file mode 100644 index 0000000000..1af897ac8c Binary files /dev/null and b/docs/assets/software-templates/custom-field-explorer.png differ diff --git a/docs/features/software-templates/writing-custom-field-extensions.md b/docs/features/software-templates/writing-custom-field-extensions.md index b4c4d2d4ce..d0a36ecaf7 100644 --- a/docs/features/software-templates/writing-custom-field-extensions.md +++ b/docs/features/software-templates/writing-custom-field-extensions.md @@ -181,3 +181,109 @@ const CustomFieldExtension = scaffolderPlugin.provide( }) ); ``` + +## Previewing Custom Field Extensions + +You can preview custom field extensions you write in the Backstage UI using the Custom Field Explorer +(accessible via the `/create/edit` route by default): + +![Custom Field Explorer](../../assets/software-templates/custom-field-explorer.png) + +In order to make your new custom field extension available in the explorer you will have to define a +JSON schema that describes the input/output types on your field like in the following example: + +```tsx +//packages/app/src/scaffolder/MyCustomExtensionWithOptions/MyCustomExtensionWithOptions.tsx +export const MyCustomExtensionWithOptionsSchema = { + uiOptions: { + type: 'object', + properties: { + focused: { + description: 'Whether to focus this field', + type: 'boolean', + }, + }, + }, + returnValue: { type: 'string' }, +}; + +export const MyCustomExtensionWithOptions = ({ + onChange, + rawErrors, + required, + formData, +}: FieldProps) => { + return ( + 0 && !formData} + onChange={onChange} + focused={focused} + /> + ); +}; +``` + +```tsx +// packages/app/src/scaffolder/MyCustomExtensionWithOptions/extensions.ts +... +import { MyCustomExtensionWithOptions, MyCustomExtensionWithOptionsSchema } from './MyCustomExtensionWithOptions'; + +export const MyCustomFieldWithOptionsExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + name: 'MyCustomExtensionWithOptions', + component: MyCustomExtensionWithOptions, + schema: MyCustomExtensionWithOptionsSchema, + }), +); +``` + +We recommend using a library like [zod](https://github.com/colinhacks/zod) to define your schema +and the provided `makeJsonSchemaFromZod` helper utility function to generate both the JSON schema +and types for your field input/output to preventing having to duplicate the definitions: + +```tsx +//packages/app/src/scaffolder/MyCustomExtensionWithOptions/MyCustomExtensionWithOptions.tsx +... +import { z } from 'zod'; +import { makeJsonSchemaFromZod } from '@backstage/plugin-scaffolder'; + +const MyCustomExtensionWithOptionsUiOptionsSchema = makeJsonSchemaFromZod( + z.object({ + focused: z + .boolean() + .optional() + .describe('Whether to focus this field'), + }), +); + +const MyCustomExtensionWithOptionsReturnValueSchema = makeJsonSchemaFromZod( + z.string(), +); + +export const MyCustomExtensionWithOptionsSchema = { + uiOptions: MyCustomExtensionWithOptionsUiOptionsSchema.schema, + returnValue: MyCustomExtensionWithOptionsReturnValueSchema.schema, +}; + +export const MyCustomExtensionWithOptions = ({ + onChange, + rawErrors, + required, + formData, +}: FieldProps< + typeof MyCustomExtensionWithOptionsReturnValueSchema.type, + typeof MyCustomExtensionWithOptionsUiOptionsSchema.type +>) => { + return ( + 0 && !formData} + onChange={onChange} + focused={focused} + /> + ); +}; +``` diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index e56b280c05..1ef699f462 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -35,6 +35,7 @@ import { TaskStep } from '@backstage/plugin-scaffolder-common'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { UIOptionsType } from '@rjsf/utils'; import { UiSchema } from '@rjsf/utils'; +import { z } from 'zod'; // @alpha export function createNextScaffolderFieldExtension< @@ -90,13 +91,12 @@ export const EntityPickerFieldExtension: FieldExtensionComponent< >; // @public -export type EntityPickerUiOptions = - typeof EntityPickerUiOptionsSchema.schemaType; +export type EntityPickerUiOptions = typeof EntityPickerUiOptionsSchema.type; // @public (undocumented) export const EntityPickerUiOptionsSchema: { - jsonSchema: JSONSchema7; - schemaType: { + schema: JSONSchema7; + type: { defaultKind?: string | undefined; defaultNamespace?: string | false | undefined; allowedKinds?: string[] | undefined; @@ -116,12 +116,12 @@ export const EntityTagsPickerFieldExtension: FieldExtensionComponent< // @public export type EntityTagsPickerUiOptions = - typeof EntityTagsPickerUiOptionsSchema.schemaType; + typeof EntityTagsPickerUiOptionsSchema.type; // @public (undocumented) export const EntityTagsPickerUiOptionsSchema: { - jsonSchema: JSONSchema7; - schemaType: { + schema: JSONSchema7; + type: { showCounts?: boolean | undefined; kinds?: string[] | undefined; helperText?: string | undefined; @@ -192,6 +192,14 @@ export type LogEvent = { taskId: string; }; +// @public +export function makeJsonSchemaFromZod( + schema: T, +): { + schema: JSONSchema7; + type: T extends z.ZodType ? I : never; +}; + // @alpha export type NextCustomFieldValidator = ( data: TFieldReturnValue, @@ -262,12 +270,12 @@ export const OwnedEntityPickerFieldExtension: FieldExtensionComponent< // @public export type OwnedEntityPickerUiOptions = - typeof OwnedEntityPickerUiOptionsSchema.schemaType; + typeof OwnedEntityPickerUiOptionsSchema.type; // @public (undocumented) export const OwnedEntityPickerUiOptionsSchema: { - jsonSchema: JSONSchema7; - schemaType: { + schema: JSONSchema7; + type: { defaultKind?: string | undefined; defaultNamespace?: string | false | undefined; allowedKinds?: string[] | undefined; @@ -286,12 +294,12 @@ export const OwnerPickerFieldExtension: FieldExtensionComponent< >; // @public -export type OwnerPickerUiOptions = typeof OwnerPickerUiOptionsSchema.schemaType; +export type OwnerPickerUiOptions = typeof OwnerPickerUiOptionsSchema.type; // @public (undocumented) export const OwnerPickerUiOptionsSchema: { - jsonSchema: JSONSchema7; - schemaType: { + schema: JSONSchema7; + type: { defaultNamespace?: string | false | undefined; allowedKinds?: string[] | undefined; allowArbitraryValues?: boolean | undefined; @@ -333,13 +341,12 @@ export const RepoUrlPickerFieldExtension: FieldExtensionComponent< >; // @public -export type RepoUrlPickerUiOptions = - typeof RepoUrlPickerUiOptionsSchema.schemaType; +export type RepoUrlPickerUiOptions = typeof RepoUrlPickerUiOptionsSchema.type; // @public (undocumented) export const RepoUrlPickerUiOptionsSchema: { - jsonSchema: JSONSchema7; - schemaType: { + schema: JSONSchema7; + type: { allowedOwners?: string[] | undefined; allowedOrganizations?: string[] | undefined; allowedRepos?: string[] | undefined; diff --git a/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts index 83e477b6aa..3139839bf4 100644 --- a/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts @@ -19,8 +19,8 @@ import { makeJsonSchemaFromZod } from '../utils'; const EntityNamePickerReturnValueSchema = makeJsonSchemaFromZod(z.string()); export type EntityNamePickerReturnValue = - typeof EntityNamePickerReturnValueSchema.schemaType; + typeof EntityNamePickerReturnValueSchema.type; export const EntityNamePickerSchema = { - returnValue: EntityNamePickerReturnValueSchema.jsonSchema, + returnValue: EntityNamePickerReturnValueSchema.schema, }; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts index 3e05264a14..3d14a2bf3e 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts @@ -52,13 +52,11 @@ const EntityPickerReturnValueSchema = makeJsonSchemaFromZod(z.string()); * * @public */ -export type EntityPickerUiOptions = - typeof EntityPickerUiOptionsSchema.schemaType; +export type EntityPickerUiOptions = typeof EntityPickerUiOptionsSchema.type; -export type EntityPickerReturnValue = - typeof EntityPickerReturnValueSchema.schemaType; +export type EntityPickerReturnValue = typeof EntityPickerReturnValueSchema.type; export const EntityPickerSchema = { - uiOptions: EntityPickerUiOptionsSchema.jsonSchema, - returnValue: EntityPickerReturnValueSchema.jsonSchema, + uiOptions: EntityPickerUiOptionsSchema.schema, + returnValue: EntityPickerReturnValueSchema.schema, }; diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts index e08918f838..d3a5391385 100644 --- a/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts @@ -44,12 +44,12 @@ const EntityTagsPickerReturnValueSchema = makeJsonSchemaFromZod( * @public */ export type EntityTagsPickerUiOptions = - typeof EntityTagsPickerUiOptionsSchema.schemaType; + typeof EntityTagsPickerUiOptionsSchema.type; export type EntityTagsPickerReturnValue = - typeof EntityTagsPickerReturnValueSchema.schemaType; + typeof EntityTagsPickerReturnValueSchema.type; export const EntityTagsPickerSchema = { - uiOptions: EntityTagsPickerUiOptionsSchema.jsonSchema, - returnValue: EntityTagsPickerReturnValueSchema.jsonSchema, + uiOptions: EntityTagsPickerUiOptionsSchema.schema, + returnValue: EntityTagsPickerReturnValueSchema.schema, }; diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts index e5bfcd9709..c967448729 100644 --- a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts @@ -53,12 +53,12 @@ const OwnedEntityPickerReturnValueSchema = makeJsonSchemaFromZod(z.string()); * @public */ export type OwnedEntityPickerUiOptions = - typeof OwnedEntityPickerUiOptionsSchema.schemaType; + typeof OwnedEntityPickerUiOptionsSchema.type; export type OwnedEntityPickerReturnValue = - typeof OwnedEntityPickerReturnValueSchema.schemaType; + typeof OwnedEntityPickerReturnValueSchema.type; export const OwnedEntityPickerSchema = { - uiOptions: OwnedEntityPickerUiOptionsSchema.jsonSchema, - returnValue: OwnedEntityPickerReturnValueSchema.jsonSchema, + uiOptions: OwnedEntityPickerUiOptionsSchema.schema, + returnValue: OwnedEntityPickerReturnValueSchema.schema, }; diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts b/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts index ccfe453651..accc8f80fc 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts @@ -49,12 +49,11 @@ const OwnerPickerReturnValueSchema = makeJsonSchemaFromZod(z.string()); * * @public */ -export type OwnerPickerUiOptions = typeof OwnerPickerUiOptionsSchema.schemaType; +export type OwnerPickerUiOptions = typeof OwnerPickerUiOptionsSchema.type; -export type OwnerPickerReturnValue = - typeof OwnerPickerReturnValueSchema.schemaType; +export type OwnerPickerReturnValue = typeof OwnerPickerReturnValueSchema.type; export const OwnerPickerSchema = { - uiOptions: OwnerPickerUiOptionsSchema.jsonSchema, - returnValue: OwnerPickerReturnValueSchema.jsonSchema, + uiOptions: OwnerPickerUiOptionsSchema.schema, + returnValue: OwnerPickerReturnValueSchema.schema, }; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts index 98d9f76a9c..bf8fa3cf5c 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts @@ -85,16 +85,15 @@ const RepoUrlPickerReturnValueSchema = makeJsonSchemaFromZod(z.string()); * * @public */ -export type RepoUrlPickerUiOptions = - typeof RepoUrlPickerUiOptionsSchema.schemaType; +export type RepoUrlPickerUiOptions = typeof RepoUrlPickerUiOptionsSchema.type; export type RepoUrlPickerReturnValue = - typeof RepoUrlPickerReturnValueSchema.schemaType; + typeof RepoUrlPickerReturnValueSchema.type; // NOTE: There is a bug with this failing validation in the custom field explorer due // to https://github.com/rjsf-team/react-jsonschema-form/issues/675 even if // requestUserCredentials is not defined export const RepoUrlPickerSchema = { - uiOptions: RepoUrlPickerUiOptionsSchema.jsonSchema, - returnValue: RepoUrlPickerReturnValueSchema.jsonSchema, + uiOptions: RepoUrlPickerUiOptionsSchema.schema, + returnValue: RepoUrlPickerReturnValueSchema.schema, }; diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts index 6358934025..e78b92da00 100644 --- a/plugins/scaffolder/src/components/fields/index.ts +++ b/plugins/scaffolder/src/components/fields/index.ts @@ -18,3 +18,4 @@ export * from './OwnerPicker'; export * from './RepoUrlPicker'; export * from './OwnedEntityPicker'; export * from './EntityTagsPicker'; +export { makeJsonSchemaFromZod } from './utils'; diff --git a/plugins/scaffolder/src/components/fields/utils.ts b/plugins/scaffolder/src/components/fields/utils.ts index b3ebf1e1b4..2f56e31db5 100644 --- a/plugins/scaffolder/src/components/fields/utils.ts +++ b/plugins/scaffolder/src/components/fields/utils.ts @@ -18,17 +18,18 @@ import { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; /** + * @public * Utility function to convert zod schemas to JSON schemas with * type inference extraction that abstracts away zod typings */ export function makeJsonSchemaFromZod( schema: T, ): { - jsonSchema: JSONSchema7; - schemaType: T extends z.ZodType ? I : never; + schema: JSONSchema7; + type: T extends z.ZodType ? I : never; } { return { - jsonSchema: zodToJsonSchema(schema) as JSONSchema7, - schemaType: null as any, + schema: zodToJsonSchema(schema) as JSONSchema7, + type: null as any, }; }