diff --git a/.changeset/soft-nails-arrive.md b/.changeset/soft-nails-arrive.md new file mode 100644 index 0000000000..e9c0a40a05 --- /dev/null +++ b/.changeset/soft-nails-arrive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Implement Custom Field Explorer to view and play around with available installed custom field extensions 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..1d28bd5db9 100644 --- a/docs/features/software-templates/writing-custom-field-extensions.md +++ b/docs/features/software-templates/writing-custom-field-extensions.md @@ -181,3 +181,102 @@ 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, +}: FieldExtensionComponentProps) => { + 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 `makeFieldSchemaFromZod` helper utility function to generate both the JSON schema +and type for your field props to preventing having to duplicate the definitions: + +```tsx +//packages/app/src/scaffolder/MyCustomExtensionWithOptions/MyCustomExtensionWithOptions.tsx +... +import { z } from 'zod'; +import { makeFieldSchemaFromZod } from '@backstage/plugin-scaffolder'; + +const MyCustomExtensionWithOptionsFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + focused: z + .boolean() + .optional() + .describe('Whether to focus this field'), + }), +); + +export const MyCustomExtensionWithOptionsSchema = MyCustomExtensionWithOptionsFieldSchema.schema; + +type MyCustomExtensionWithOptionsProps = typeof MyCustomExtensionWithOptionsFieldSchema.type; + +export const MyCustomExtensionWithOptions = ({ + onChange, + rawErrors, + required, + formData, +}: MyCustomExtensionWithOptionsProps) => { + return ( + 0 && !formData} + onChange={onChange} + focused={focused} + /> + ); +}; +``` diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 27eb863a16..bc6643cffe 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< @@ -57,6 +58,12 @@ export function createScaffolderLayout( options: LayoutOptions, ): Extension>; +// @public +export type CustomFieldExtensionSchema = { + returnValue: JSONSchema7; + uiOptions?: JSONSchema7; +}; + // @public export type CustomFieldValidator = ( data: TFieldReturnValue, @@ -75,36 +82,52 @@ export const EntityNamePickerFieldExtension: FieldExtensionComponent< // @public export const EntityPickerFieldExtension: FieldExtensionComponent< string, - EntityPickerUiOptions + { + defaultKind?: string | undefined; + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } +>; + +// @public (undocumented) +export const EntityPickerFieldSchema: FieldSchema< + string, + { + defaultKind?: string | undefined; + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } >; // @public -export interface EntityPickerUiOptions { - // (undocumented) - allowArbitraryValues?: boolean; - // (undocumented) - allowedKinds?: string[]; - // (undocumented) - defaultKind?: string; - // (undocumented) - defaultNamespace?: string | false; -} +export type EntityPickerUiOptions = + typeof EntityPickerFieldSchema.uiOptionsType; // @public export const EntityTagsPickerFieldExtension: FieldExtensionComponent< string[], - EntityTagsPickerUiOptions + { + showCounts?: boolean | undefined; + kinds?: string[] | undefined; + helperText?: string | undefined; + } +>; + +// @public (undocumented) +export const EntityTagsPickerFieldSchema: FieldSchema< + string[], + { + showCounts?: boolean | undefined; + kinds?: string[] | undefined; + helperText?: string | undefined; + } >; // @public -export interface EntityTagsPickerUiOptions { - // (undocumented) - helperText?: string; - // (undocumented) - kinds?: string[]; - // (undocumented) - showCounts?: boolean; -} +export type EntityTagsPickerUiOptions = + typeof EntityTagsPickerFieldSchema.uiOptionsType; // @public export type FieldExtensionComponent<_TReturnValue, _TInputProps> = () => null; @@ -130,8 +153,19 @@ export type FieldExtensionOptions< props: FieldExtensionComponentProps, ) => JSX.Element | null; validation?: CustomFieldValidator; + schema?: CustomFieldExtensionSchema; }; +// @public +export interface FieldSchema { + // (undocumented) + readonly schema: CustomFieldExtensionSchema; + // (undocumented) + readonly type: FieldExtensionComponentProps; + // (undocumented) + readonly uiOptionsType: TUiOptions; +} + // @public export type LayoutComponent<_TInputProps> = () => null; @@ -169,6 +203,20 @@ export type LogEvent = { taskId: string; }; +// @public +export function makeFieldSchemaFromZod< + TReturnSchema extends z.ZodType, + TUiOptionsSchema extends z.ZodType = z.ZodType, +>( + returnSchema: TReturnSchema, + uiOptionsSchema?: TUiOptionsSchema, +): FieldSchema< + TReturnSchema extends z.ZodType ? IReturn : never, + TUiOptionsSchema extends z.ZodType + ? IUiOptions + : never +>; + // @alpha export type NextCustomFieldValidator = ( data: TFieldReturnValue, @@ -199,6 +247,7 @@ export type NextFieldExtensionOptions< props: NextFieldExtensionComponentProps, ) => JSX.Element | null; validation?: NextCustomFieldValidator; + schema?: CustomFieldExtensionSchema; }; // @alpha (undocumented) @@ -228,36 +277,51 @@ export const nextSelectedTemplateRouteRef: SubRouteRef< // @public export const OwnedEntityPickerFieldExtension: FieldExtensionComponent< string, - OwnedEntityPickerUiOptions + { + defaultKind?: string | undefined; + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } +>; + +// @public (undocumented) +export const OwnedEntityPickerFieldSchema: FieldSchema< + string, + { + defaultKind?: string | undefined; + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } >; // @public -export interface OwnedEntityPickerUiOptions { - // (undocumented) - allowArbitraryValues?: boolean; - // (undocumented) - allowedKinds?: string[]; - // (undocumented) - defaultKind?: string; - // (undocumented) - defaultNamespace?: string | false; -} +export type OwnedEntityPickerUiOptions = + typeof OwnedEntityPickerFieldSchema.uiOptionsType; // @public export const OwnerPickerFieldExtension: FieldExtensionComponent< string, - OwnerPickerUiOptions + { + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } +>; + +// @public (undocumented) +export const OwnerPickerFieldSchema: FieldSchema< + string, + { + defaultNamespace?: string | false | undefined; + allowedKinds?: string[] | undefined; + allowArbitraryValues?: boolean | undefined; + } >; // @public -export interface OwnerPickerUiOptions { - // (undocumented) - allowArbitraryValues?: boolean; - // (undocumented) - allowedKinds?: string[]; - // (undocumented) - defaultNamespace?: string | false; -} +export type OwnerPickerUiOptions = typeof OwnerPickerFieldSchema.uiOptionsType; // @public export const repoPickerValidation: ( @@ -271,31 +335,56 @@ export const repoPickerValidation: ( // @public export const RepoUrlPickerFieldExtension: FieldExtensionComponent< string, - RepoUrlPickerUiOptions + { + allowedOwners?: string[] | undefined; + allowedOrganizations?: string[] | undefined; + allowedRepos?: string[] | undefined; + allowedHosts?: string[] | undefined; + requestUserCredentials?: + | { + additionalScopes?: + | { + azure?: string[] | undefined; + github?: string[] | undefined; + gitlab?: string[] | undefined; + bitbucket?: string[] | undefined; + gerrit?: string[] | undefined; + } + | undefined; + secretsKey: string; + } + | undefined; + } +>; + +// @public (undocumented) +export const RepoUrlPickerFieldSchema: FieldSchema< + string, + { + allowedOwners?: string[] | undefined; + allowedOrganizations?: string[] | undefined; + allowedRepos?: string[] | undefined; + allowedHosts?: string[] | undefined; + requestUserCredentials?: + | { + additionalScopes?: + | { + azure?: string[] | undefined; + github?: string[] | undefined; + gitlab?: string[] | undefined; + bitbucket?: string[] | undefined; + gerrit?: string[] | undefined; + } + | undefined; + secretsKey: string; + } + | undefined; + } >; // @public -export interface RepoUrlPickerUiOptions { - // (undocumented) - allowedHosts?: string[]; - // (undocumented) - allowedOrganizations?: string[]; - // (undocumented) - allowedOwners?: string[]; - // (undocumented) - allowedRepos?: string[]; - // (undocumented) - requestUserCredentials?: { - secretsKey: string; - additionalScopes?: { - gerrit?: string[]; - github?: string[]; - gitlab?: string[]; - bitbucket?: string[]; - azure?: string[]; - }; - }; -} +export type RepoUrlPickerUiOptions = + typeof RepoUrlPickerFieldSchema.uiOptionsType; // @public (undocumented) export const rootRouteRef: RouteRef; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 7f3ad0479b..18c522b91e 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -74,7 +74,9 @@ "react-use": "^17.2.4", "use-immer": "^0.7.0", "yaml": "^2.0.0", - "zen-observable": "^0.8.15" + "zen-observable": "^0.8.15", + "zod": "^3.11.6", + "zod-to-json-schema": "^3.18.1" }, "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx new file mode 100644 index 0000000000..3510968acd --- /dev/null +++ b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx @@ -0,0 +1,201 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { StreamLanguage } from '@codemirror/language'; +import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml'; +import { + Button, + Card, + CardContent, + CardHeader, + FormControl, + IconButton, + InputLabel, + makeStyles, + MenuItem, + Select, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import { 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 '../../extensions'; +import * as fieldOverrides from '../MultistepJsonForm/FieldOverrides'; +import { TemplateEditorForm } from './TemplateEditorForm'; + +const Form = withTheme(MuiTheme); + +const useStyles = makeStyles(theme => ({ + root: { + gridArea: 'pageContent', + display: 'grid', + gridTemplateAreas: ` + "controls controls" + "fieldForm preview" + `, + gridTemplateRows: 'auto 1fr', + gridTemplateColumns: '1fr 1fr', + }, + controls: { + gridArea: 'controls', + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + margin: theme.spacing(1), + }, + fieldForm: { + gridArea: 'fieldForm', + }, + preview: { + gridArea: 'preview', + }, +})); + +export const CustomFieldExplorer = ({ + customFieldExtensions = [], + onClose, +}: { + customFieldExtensions?: FieldExtensionOptions[]; + onClose?: () => void; +}) => { + const classes = useStyles(); + const fieldOptions = customFieldExtensions.filter(field => !!field.schema); + const [selectedField, setSelectedField] = useState(fieldOptions[0]); + const [fieldFormState, setFieldFormState] = useState({}); + const [formState, setFormState] = useState({}); + const [refreshKey, setRefreshKey] = useState(Date.now()); + const sampleFieldTemplate = useMemo( + () => + yaml.stringify({ + parameters: [ + { + title: `${selectedField.name} Example`, + properties: { + [selectedField.name]: { + type: selectedField.schema?.returnValue?.type, + 'ui:field': selectedField.name, + 'ui:options': fieldFormState, + }, + }, + }, + ], + }), + [fieldFormState, selectedField], + ); + + const fieldComponents = useMemo(() => { + return Object.fromEntries( + customFieldExtensions.map(({ name, component }) => [name, component]), + ); + }, [customFieldExtensions]); + + const handleSelectionChange = useCallback( + selection => { + setSelectedField(selection); + setFieldFormState({}); + setFormState({}); + }, + [setFieldFormState, setFormState, setSelectedField], + ); + + const handleFieldConfigChange = useCallback( + state => { + setFieldFormState(state); + setFormState({}); + // Force TemplateEditorForm to re-render since some fields + // may not be responsive to ui:option changes + setRefreshKey(Date.now()); + }, + [setFieldFormState, setRefreshKey], + ); + + return ( +
+
+ + + Choose Custom Field Extension + + + + + + + +
+
+ + + +
handleFieldConfigChange(e.formData)} + schema={selectedField.schema?.uiOptions || {}} + > + +
+
+
+
+
+ + + + + + + null} + /> +
+
+ ); +}; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx index dc411ee965..61e6c46964 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorIntro.tsx @@ -44,7 +44,7 @@ const useStyles = makeStyles(theme => ({ interface EditorIntroProps { style?: JSX.IntrinsicElements['div']['style']; - onSelect?: (option: 'local' | 'form') => void; + onSelect?: (option: 'local' | 'form' | 'field-explorer') => void; } export function TemplateEditorIntro(props: EditorIntroProps) { @@ -104,6 +104,22 @@ export function TemplateEditorIntro(props: EditorIntroProps) { ); + const cardFieldExplorer = ( + + props.onSelect?.('field-explorer')}> + + + Custom Field Explorer + + + View and play around with available installed custom field + extensions. + + + + + ); + return (
@@ -121,6 +137,7 @@ export function TemplateEditorIntro(props: EditorIntroProps) { {supportsLoad && cardLoadLocal} {cardFormEditor} {!supportsLoad && cardLoadLocal} + {cardFieldExplorer}
); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index b539b6403b..8847b85373 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -19,6 +19,7 @@ import { TemplateDirectoryAccess, WebFileSystemAccess, } from '../../lib/filesystem'; +import { CustomFieldExplorer } from './CustomFieldExplorer'; import { TemplateEditorIntro } from './TemplateEditorIntro'; import { TemplateEditor } from './TemplateEditor'; import { TemplateFormPreviewer } from './TemplateFormPreviewer'; @@ -32,6 +33,9 @@ type Selection = } | { type: 'form'; + } + | { + type: 'field-explorer'; }; interface TemplateEditorPageProps { @@ -62,6 +66,13 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { layouts={props.layouts} /> ); + } else if (selection?.type === 'field-explorer') { + content = ( + setSelection(undefined)} + /> + ); } else { content = ( @@ -73,6 +84,8 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { .catch(() => {}); } else if (option === 'form') { setSelection({ type: 'form' }); + } else if (option === 'field-explorer') { + setSelection({ type: 'field-explorer' }); } }} /> diff --git a/plugins/scaffolder/src/components/fields/EntityNamePicker/EntityNamePicker.tsx b/plugins/scaffolder/src/components/fields/EntityNamePicker/EntityNamePicker.tsx index 802135a863..555b726917 100644 --- a/plugins/scaffolder/src/components/fields/EntityNamePicker/EntityNamePicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityNamePicker/EntityNamePicker.tsx @@ -14,15 +14,15 @@ * limitations under the License. */ import React from 'react'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { EntityNamePickerProps } from './schema'; import { TextField } from '@material-ui/core'; +export { EntityNamePickerSchema } from './schema'; + /** * EntityName Picker */ -export const EntityNamePicker = ( - props: FieldExtensionComponentProps, -) => { +export const EntityNamePicker = (props: EntityNamePickerProps) => { const { onChange, required, diff --git a/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts new file mode 100644 index 0000000000..27e33befb5 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityNamePicker/schema.ts @@ -0,0 +1,23 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +const EntityNamePickerFieldSchema = makeFieldSchemaFromZod(z.string()); + +export const EntityNamePickerSchema = EntityNamePickerFieldSchema.schema; + +export type EntityNamePickerProps = typeof EntityNamePickerFieldSchema.type; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index e7883a4721..bc78e00576 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -23,20 +23,9 @@ import FormControl from '@material-ui/core/FormControl'; import Autocomplete from '@material-ui/lab/Autocomplete'; import React, { useCallback, useEffect } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { EntityPickerProps } from './schema'; -/** - * The input props that can be specified under `ui:options` for the - * `EntityPicker` field extension. - * - * @public - */ -export interface EntityPickerUiOptions { - allowedKinds?: string[]; - defaultKind?: string; - allowArbitraryValues?: boolean; - defaultNamespace?: string | false; -} +export { EntityPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `EntityPicker` @@ -44,9 +33,7 @@ export interface EntityPickerUiOptions { * * @public */ -export const EntityPicker = ( - props: FieldExtensionComponentProps, -) => { +export const EntityPicker = (props: EntityPickerProps) => { const { onChange, schema: { title = 'Entity', description = 'An entity from the catalog' }, diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/index.ts b/plugins/scaffolder/src/components/fields/EntityPicker/index.ts index 891b5bef16..b8df596c98 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/EntityPicker/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { EntityPickerUiOptions } from './EntityPicker'; +export { EntityPickerFieldSchema, type EntityPickerUiOptions } from './schema'; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts new file mode 100644 index 0000000000..9236457fc0 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityPicker/schema.ts @@ -0,0 +1,59 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const EntityPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + allowedKinds: z + .array(z.string()) + .optional() + .describe('List of kinds of entities to derive options from'), + defaultKind: z + .string() + .optional() + .describe( + 'The default entity kind. Options of this kind will not be prefixed.', + ), + allowArbitraryValues: z + .boolean() + .optional() + .describe('Whether to allow arbitrary user input. Defaults to true'), + defaultNamespace: z + .union([z.string(), z.literal(false)]) + .optional() + .describe( + 'The default namespace. Options with this namespace will not be prefixed.', + ), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `EntityPicker` field extension. + * + * @public + */ +export type EntityPickerUiOptions = + typeof EntityPickerFieldSchema.uiOptionsType; + +export type EntityPickerProps = typeof EntityPickerFieldSchema.type; + +export const EntityPickerSchema = EntityPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx b/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx index c0e3aab436..549ccdb8aa 100644 --- a/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/EntityTagsPicker.tsx @@ -22,19 +22,9 @@ import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { FormControl, TextField } from '@material-ui/core'; import { Autocomplete } from '@material-ui/lab'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { EntityTagsPickerProps } from './schema'; -/** - * The input props that can be specified under `ui:options` for the - * `EntityTagsPicker` field extension. - * - * @public - */ -export interface EntityTagsPickerUiOptions { - kinds?: string[]; - showCounts?: boolean; - helperText?: string; -} +export { EntityTagsPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `EntityTagsPicker` @@ -42,9 +32,7 @@ export interface EntityTagsPickerUiOptions { * * @public */ -export const EntityTagsPicker = ( - props: FieldExtensionComponentProps, -) => { +export const EntityTagsPicker = (props: EntityTagsPickerProps) => { const { formData, onChange, uiSchema } = props; const catalogApi = useApi(catalogApiRef); const [tagOptions, setTagOptions] = useState([]); diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/index.ts b/plugins/scaffolder/src/components/fields/EntityTagsPicker/index.ts index 9ff6e553a6..52bad9a80d 100644 --- a/plugins/scaffolder/src/components/fields/EntityTagsPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/index.ts @@ -13,4 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { EntityTagsPickerUiOptions } from './EntityTagsPicker'; +export { + EntityTagsPickerFieldSchema, + type EntityTagsPickerUiOptions, +} from './schema'; diff --git a/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts b/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts new file mode 100644 index 0000000000..6677d16e98 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/EntityTagsPicker/schema.ts @@ -0,0 +1,48 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const EntityTagsPickerFieldSchema = makeFieldSchemaFromZod( + z.array(z.string()), + z.object({ + kinds: z + .array(z.string()) + .optional() + .describe('List of kinds of entities to derive tags from'), + showCounts: z + .boolean() + .optional() + .describe('Whether to show usage counts per tag'), + helperText: z.string().optional().describe('Helper text to display'), + }), +); + +export const EntityTagsPickerSchema = EntityTagsPickerFieldSchema.schema; + +export type EntityTagsPickerProps = typeof EntityTagsPickerFieldSchema.type; + +/** + * The input props that can be specified under `ui:options` for the + * `EntityTagsPicker` field extension. + * + * @public + */ +export type EntityTagsPickerUiOptions = + typeof EntityTagsPickerFieldSchema.uiOptionsType; diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx index 2446883a86..01a174a6a3 100644 --- a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx @@ -26,20 +26,9 @@ import Autocomplete from '@material-ui/lab/Autocomplete'; import React, { useMemo } from 'react'; import useAsync from 'react-use/lib/useAsync'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { OwnedEntityPickerProps } from './schema'; -/** - * The input props that can be specified under `ui:options` for the - * `OwnedEntityPicker` field extension. - * - * @public - */ -export interface OwnedEntityPickerUiOptions { - allowedKinds?: string[]; - defaultKind?: string; - allowArbitraryValues?: boolean; - defaultNamespace?: string | false; -} +export { OwnedEntityPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `OwnedEntityPicker` @@ -47,9 +36,7 @@ export interface OwnedEntityPickerUiOptions { * * @public */ -export const OwnedEntityPicker = ( - props: FieldExtensionComponentProps, -) => { +export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => { const { onChange, schema: { title = 'Entity', description = 'An entity from the catalog' }, diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts index 2988ba8cdc..985dae1d3c 100644 --- a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/index.ts @@ -13,4 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { OwnedEntityPickerUiOptions } from './OwnedEntityPicker'; +export { + OwnedEntityPickerFieldSchema, + type OwnedEntityPickerUiOptions, +} from './schema'; diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts new file mode 100644 index 0000000000..4190b89f95 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/schema.ts @@ -0,0 +1,59 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const OwnedEntityPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + allowedKinds: z + .array(z.string()) + .optional() + .describe('List of kinds of entities to derive options from'), + defaultKind: z + .string() + .optional() + .describe( + 'The default entity kind. Options of this kind will not be prefixed.', + ), + allowArbitraryValues: z + .boolean() + .optional() + .describe('Whether to allow arbitrary user input. Defaults to true'), + defaultNamespace: z + .union([z.string(), z.literal(false)]) + .optional() + .describe( + 'The default namespace. Options with this namespace will not be prefixed.', + ), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `OwnedEntityPicker` field extension. + * + * @public + */ +export type OwnedEntityPickerUiOptions = + typeof OwnedEntityPickerFieldSchema.uiOptionsType; + +export type OwnedEntityPickerProps = typeof OwnedEntityPickerFieldSchema.type; + +export const OwnedEntityPickerSchema = OwnedEntityPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx index 3de2b12bd0..1c4c356906 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx @@ -15,19 +15,9 @@ */ import React from 'react'; import { EntityPicker } from '../EntityPicker/EntityPicker'; -import { FieldExtensionComponentProps } from '../../../extensions'; +import { OwnerPickerProps } from './schema'; -/** - * The input props that can be specified under `ui:options` for the - * `OwnerPicker` field extension. - * - * @public - */ -export interface OwnerPickerUiOptions { - allowedKinds?: string[]; - allowArbitraryValues?: boolean; - defaultNamespace?: string | false; -} +export { OwnerPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `OwnerPicker` @@ -35,9 +25,7 @@ export interface OwnerPickerUiOptions { * * @public */ -export const OwnerPicker = ( - props: FieldExtensionComponentProps, -) => { +export const OwnerPicker = (props: OwnerPickerProps) => { const { schema: { title = 'Owner', description = 'The owner of the component' }, uiSchema, diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts b/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts index aa26024b5b..9d94650e04 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { OwnerPickerUiOptions } from './OwnerPicker'; +export { OwnerPickerFieldSchema, type OwnerPickerUiOptions } from './schema'; diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts b/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts new file mode 100644 index 0000000000..56edef1343 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/schema.ts @@ -0,0 +1,55 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const OwnerPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + allowedKinds: z + .array(z.string()) + .default(['Group', 'User']) + .optional() + .describe( + 'List of kinds of entities to derive options from. Defaults to Group and User', + ), + allowArbitraryValues: z + .boolean() + .optional() + .describe('Whether to allow arbitrary user input. Defaults to true'), + defaultNamespace: z + .union([z.string(), z.literal(false)]) + .optional() + .describe( + 'The default namespace. Options with this namespace will not be prefixed.', + ), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `OwnerPicker` field extension. + * + * @public + */ +export type OwnerPickerUiOptions = typeof OwnerPickerFieldSchema.uiOptionsType; + +export type OwnerPickerProps = typeof OwnerPickerFieldSchema.type; + +export const OwnerPickerSchema = OwnerPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 451df857e5..827eff6ea2 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -24,36 +24,15 @@ import { GitlabRepoPicker } from './GitlabRepoPicker'; import { AzureRepoPicker } from './AzureRepoPicker'; import { BitbucketRepoPicker } from './BitbucketRepoPicker'; import { GerritRepoPicker } from './GerritRepoPicker'; -import { FieldExtensionComponentProps } from '../../../extensions'; import { RepoUrlPickerHost } from './RepoUrlPickerHost'; import { RepoUrlPickerRepoName } from './RepoUrlPickerRepoName'; import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils'; +import { RepoUrlPickerProps } from './schema'; import { RepoUrlPickerState } from './types'; import useDebounce from 'react-use/lib/useDebounce'; import { useTemplateSecrets } from '../../secrets'; -/** - * The input props that can be specified under `ui:options` for the - * `RepoUrlPicker` field extension. - * - * @public - */ -export interface RepoUrlPickerUiOptions { - allowedHosts?: string[]; - allowedOrganizations?: string[]; - allowedOwners?: string[]; - allowedRepos?: string[]; - requestUserCredentials?: { - secretsKey: string; - additionalScopes?: { - gerrit?: string[]; - github?: string[]; - gitlab?: string[]; - bitbucket?: string[]; - azure?: string[]; - }; - }; -} +export { RepoUrlPickerSchema } from './schema'; /** * The underlying component that is rendered in the form for the `RepoUrlPicker` @@ -61,9 +40,7 @@ export interface RepoUrlPickerUiOptions { * * @public */ -export const RepoUrlPicker = ( - props: FieldExtensionComponentProps, -) => { +export const RepoUrlPicker = (props: RepoUrlPickerProps) => { const { uiSchema, onChange, rawErrors, formData } = props; const [state, setState] = useState( parseRepoPickerUrl(formData), diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts index c5f596a786..33e5ef1715 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts @@ -13,5 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export type { RepoUrlPickerUiOptions } from './RepoUrlPicker'; +export { + RepoUrlPickerFieldSchema, + type RepoUrlPickerUiOptions, +} from './schema'; export { repoPickerValidation } from './validation'; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts new file mode 100644 index 0000000000..8f8674a0a7 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/schema.ts @@ -0,0 +1,95 @@ +/* + * 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 { z } from 'zod'; +import { makeFieldSchemaFromZod } from '../utils'; + +/** + * @public + */ +export const RepoUrlPickerFieldSchema = makeFieldSchemaFromZod( + z.string(), + z.object({ + allowedHosts: z + .array(z.string()) + .optional() + .describe('List of allowed SCM platform hosts'), + allowedOrganizations: z + .array(z.string()) + .optional() + .describe('List of allowed organizations in the given SCM platform'), + allowedOwners: z + .array(z.string()) + .optional() + .describe('List of allowed owners in the given SCM platform'), + allowedRepos: z + .array(z.string()) + .optional() + .describe('List of allowed repos in the given SCM platform'), + requestUserCredentials: z + .object({ + secretsKey: z + .string() + .describe( + 'Key used within the template secrets context to store the credential', + ), + additionalScopes: z + .object({ + gerrit: z + .array(z.string()) + .optional() + .describe('Additional Gerrit scopes to request'), + github: z + .array(z.string()) + .optional() + .describe('Additional GitHub scopes to request'), + gitlab: z + .array(z.string()) + .optional() + .describe('Additional GitLab scopes to request'), + bitbucket: z + .array(z.string()) + .optional() + .describe('Additional BitBucket scopes to request'), + azure: z + .array(z.string()) + .optional() + .describe('Additional Azure scopes to request'), + }) + .optional() + .describe('Additional permission scopes to request'), + }) + .optional() + .describe( + 'If defined will request user credentials to auth against the given SCM platform', + ), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `RepoUrlPicker` field extension. + * + * @public + */ +export type RepoUrlPickerUiOptions = + typeof RepoUrlPickerFieldSchema.uiOptionsType; + +export type RepoUrlPickerProps = typeof RepoUrlPickerFieldSchema.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 = RepoUrlPickerFieldSchema.schema; diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts index 6358934025..8d86f601ce 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 { type FieldSchema, makeFieldSchemaFromZod } from './utils'; diff --git a/plugins/scaffolder/src/components/fields/utils.ts b/plugins/scaffolder/src/components/fields/utils.ts new file mode 100644 index 0000000000..2d3e2aab1e --- /dev/null +++ b/plugins/scaffolder/src/components/fields/utils.ts @@ -0,0 +1,62 @@ +/* + * 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 { JSONSchema7 } from 'json-schema'; +import { z } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; +import { + CustomFieldExtensionSchema, + FieldExtensionComponentProps, +} from '../../extensions'; + +/** + * @public + * FieldSchema encapsulates a JSONSchema7 along with the + * matching FieldExtensionComponentProps type for a field extension. + */ +export interface FieldSchema { + readonly schema: CustomFieldExtensionSchema; + readonly type: FieldExtensionComponentProps; + readonly uiOptionsType: TUiOptions; +} + +/** + * @public + * Utility function to convert zod return and UI options schemas to a + * CustomFieldExtensionSchema with FieldExtensionComponentProps type inference + */ +export function makeFieldSchemaFromZod< + TReturnSchema extends z.ZodType, + TUiOptionsSchema extends z.ZodType = z.ZodType, +>( + returnSchema: TReturnSchema, + uiOptionsSchema?: TUiOptionsSchema, +): FieldSchema< + TReturnSchema extends z.ZodType ? IReturn : never, + TUiOptionsSchema extends z.ZodType + ? IUiOptions + : never +> { + return { + schema: { + returnValue: zodToJsonSchema(returnSchema) as JSONSchema7, + uiOptions: uiOptionsSchema + ? (zodToJsonSchema(uiOptionsSchema) as JSONSchema7) + : undefined, + }, + type: null as any, + uiOptionsType: null as any, + }; +} diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts index 8081dcb0ca..c6ab5276d0 100644 --- a/plugins/scaffolder/src/extensions/default.ts +++ b/plugins/scaffolder/src/extensions/default.ts @@ -13,40 +13,64 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EntityPicker } from '../components/fields/EntityPicker/EntityPicker'; -import { EntityNamePicker } from '../components/fields/EntityNamePicker/EntityNamePicker'; +import { + EntityPicker, + EntityPickerSchema, +} from '../components/fields/EntityPicker/EntityPicker'; +import { + EntityNamePicker, + EntityNamePickerSchema, +} from '../components/fields/EntityNamePicker/EntityNamePicker'; import { entityNamePickerValidation } from '../components/fields/EntityNamePicker/validation'; -import { EntityTagsPicker } from '../components/fields/EntityTagsPicker/EntityTagsPicker'; -import { OwnerPicker } from '../components/fields/OwnerPicker/OwnerPicker'; -import { RepoUrlPicker } from '../components/fields/RepoUrlPicker/RepoUrlPicker'; +import { + EntityTagsPicker, + EntityTagsPickerSchema, +} from '../components/fields/EntityTagsPicker/EntityTagsPicker'; +import { + OwnerPicker, + OwnerPickerSchema, +} from '../components/fields/OwnerPicker/OwnerPicker'; +import { + RepoUrlPicker, + RepoUrlPickerSchema, +} from '../components/fields/RepoUrlPicker/RepoUrlPicker'; import { repoPickerValidation } from '../components/fields/RepoUrlPicker/validation'; -import { OwnedEntityPicker } from '../components/fields/OwnedEntityPicker/OwnedEntityPicker'; +import { + OwnedEntityPicker, + OwnedEntityPickerSchema, +} from '../components/fields/OwnedEntityPicker/OwnedEntityPicker'; export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [ { component: EntityPicker, name: 'EntityPicker', + schema: EntityPickerSchema, }, { component: EntityNamePicker, name: 'EntityNamePicker', validation: entityNamePickerValidation, + schema: EntityNamePickerSchema, }, { component: EntityTagsPicker, name: 'EntityTagsPicker', + schema: EntityTagsPickerSchema, }, { component: RepoUrlPicker, name: 'RepoUrlPicker', validation: repoPickerValidation, + schema: RepoUrlPickerSchema, }, { component: OwnerPicker, name: 'OwnerPicker', + schema: OwnerPickerSchema, }, { component: OwnedEntityPicker, name: 'OwnedEntityPicker', + schema: OwnedEntityPickerSchema, }, ]; diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index 08ab3e603c..a3a4fdf57b 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -16,6 +16,7 @@ import React from 'react'; import { + CustomFieldExtensionSchema, CustomFieldValidator, FieldExtensionOptions, FieldExtensionComponentProps, @@ -103,6 +104,7 @@ attachComponentData( ); export type { + CustomFieldExtensionSchema, CustomFieldValidator, FieldExtensionOptions, FieldExtensionComponentProps, diff --git a/plugins/scaffolder/src/extensions/types.ts b/plugins/scaffolder/src/extensions/types.ts index 62f17dff15..3834b39c0a 100644 --- a/plugins/scaffolder/src/extensions/types.ts +++ b/plugins/scaffolder/src/extensions/types.ts @@ -15,14 +15,14 @@ */ import { ApiHolder } from '@backstage/core-plugin-api'; import { FieldValidation, FieldProps } from '@rjsf/core'; -import { PropsWithChildren } from 'react'; - import { UIOptionsType, FieldProps as FieldPropsV5, UiSchema as UiSchemaV5, FieldValidation as FieldValidationV5, } from '@rjsf/utils'; +import { PropsWithChildren } from 'react'; +import { JSONSchema7 } from 'json-schema'; /** * Field validation type for Custom Field Extensions. @@ -35,6 +35,16 @@ export type CustomFieldValidator = ( context: { apiHolder: ApiHolder }, ) => void | Promise; +/** + * Type for the Custom Field Extension schema. + * + * @public + */ +export type CustomFieldExtensionSchema = { + returnValue: JSONSchema7; + uiOptions?: JSONSchema7; +}; + /** * Type for the Custom Field Extension with the * name and components and validation function. @@ -50,6 +60,7 @@ export type FieldExtensionOptions< props: FieldExtensionComponentProps, ) => JSX.Element | null; validation?: CustomFieldValidator; + schema?: CustomFieldExtensionSchema; }; /** @@ -107,4 +118,5 @@ export type NextFieldExtensionOptions< props: NextFieldExtensionComponentProps, ) => JSX.Element | null; validation?: NextCustomFieldValidator; + schema?: CustomFieldExtensionSchema; }; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 7853667101..0e5e2630ac 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -43,6 +43,7 @@ export { ScaffolderFieldExtensions, } from './extensions'; export type { + CustomFieldExtensionSchema, CustomFieldValidator, FieldExtensionOptions, FieldExtensionComponentProps, diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index f9b359d93a..a34105745f 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -16,12 +16,24 @@ import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; -import { EntityPicker } from './components/fields/EntityPicker/EntityPicker'; +import { + EntityPicker, + EntityPickerSchema, +} from './components/fields/EntityPicker/EntityPicker'; import { entityNamePickerValidation } from './components/fields/EntityNamePicker'; -import { EntityNamePicker } from './components/fields/EntityNamePicker/EntityNamePicker'; -import { OwnerPicker } from './components/fields/OwnerPicker/OwnerPicker'; +import { + EntityNamePicker, + EntityNamePickerSchema, +} from './components/fields/EntityNamePicker/EntityNamePicker'; +import { + OwnerPicker, + OwnerPickerSchema, +} from './components/fields/OwnerPicker/OwnerPicker'; import { repoPickerValidation } from './components/fields/RepoUrlPicker'; -import { RepoUrlPicker } from './components/fields/RepoUrlPicker/RepoUrlPicker'; +import { + RepoUrlPicker, + RepoUrlPickerSchema, +} from './components/fields/RepoUrlPicker/RepoUrlPicker'; import { createScaffolderFieldExtension } from './extensions'; import { nextRouteRef, @@ -37,8 +49,14 @@ import { fetchApiRef, identityApiRef, } from '@backstage/core-plugin-api'; -import { OwnedEntityPicker } from './components/fields/OwnedEntityPicker/OwnedEntityPicker'; -import { EntityTagsPicker } from './components/fields/EntityTagsPicker/EntityTagsPicker'; +import { + OwnedEntityPicker, + OwnedEntityPickerSchema, +} from './components/fields/OwnedEntityPicker/OwnedEntityPicker'; +import { + EntityTagsPicker, + EntityTagsPickerSchema, +} from './components/fields/EntityTagsPicker/EntityTagsPicker'; /** * The main plugin export for the scaffolder. @@ -82,6 +100,7 @@ export const EntityPickerFieldExtension = scaffolderPlugin.provide( createScaffolderFieldExtension({ component: EntityPicker, name: 'EntityPicker', + schema: EntityPickerSchema, }), ); @@ -95,6 +114,7 @@ export const EntityNamePickerFieldExtension = scaffolderPlugin.provide( component: EntityNamePicker, name: 'EntityNamePicker', validation: entityNamePickerValidation, + schema: EntityNamePickerSchema, }), ); @@ -109,6 +129,7 @@ export const RepoUrlPickerFieldExtension = scaffolderPlugin.provide( component: RepoUrlPicker, name: 'RepoUrlPicker', validation: repoPickerValidation, + schema: RepoUrlPickerSchema, }), ); @@ -121,6 +142,7 @@ export const OwnerPickerFieldExtension = scaffolderPlugin.provide( createScaffolderFieldExtension({ component: OwnerPicker, name: 'OwnerPicker', + schema: OwnerPickerSchema, }), ); @@ -146,6 +168,7 @@ export const OwnedEntityPickerFieldExtension = scaffolderPlugin.provide( createScaffolderFieldExtension({ component: OwnedEntityPicker, name: 'OwnedEntityPicker', + schema: OwnedEntityPickerSchema, }), ); @@ -157,6 +180,7 @@ export const EntityTagsPickerFieldExtension = scaffolderPlugin.provide( createScaffolderFieldExtension({ component: EntityTagsPicker, name: 'EntityTagsPicker', + schema: EntityTagsPickerSchema, }), ); diff --git a/yarn.lock b/yarn.lock index 23a40a0b6c..f64ac93992 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6646,6 +6646,8 @@ __metadata: use-immer: ^0.7.0 yaml: ^2.0.0 zen-observable: ^0.8.15 + zod: ^3.11.6 + zod-to-json-schema: ^3.18.1 peerDependencies: "@types/react": ^16.13.1 || ^17.0.0 react: ^16.13.1 || ^17.0.0