Merge pull request #14008 from kuangp/feat/fieldExplorer

[PRFC] Custom Field Extension Explorer
This commit is contained in:
Johan Haals
2022-11-17 09:58:39 +01:00
committed by GitHub
33 changed files with 1004 additions and 175 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Implement Custom Field Explorer to view and play around with available installed custom field extensions
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

@@ -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<string, { focused?: boolean }>) => {
return (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 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 (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !formData}
onChange={onChange}
focused={focused}
/>
);
};
```
+151 -62
View File
@@ -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<TInputProps = unknown>(
options: LayoutOptions,
): Extension<LayoutComponent<TInputProps>>;
// @public
export type CustomFieldExtensionSchema = {
returnValue: JSONSchema7;
uiOptions?: JSONSchema7;
};
// @public
export type CustomFieldValidator<TFieldReturnValue> = (
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<TFieldReturnValue, TInputProps>,
) => JSX.Element | null;
validation?: CustomFieldValidator<TFieldReturnValue>;
schema?: CustomFieldExtensionSchema;
};
// @public
export interface FieldSchema<TReturn, TUiOptions> {
// (undocumented)
readonly schema: CustomFieldExtensionSchema;
// (undocumented)
readonly type: FieldExtensionComponentProps<TReturn, TUiOptions>;
// (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<any, any, {}>,
>(
returnSchema: TReturnSchema,
uiOptionsSchema?: TUiOptionsSchema,
): FieldSchema<
TReturnSchema extends z.ZodType<any, any, infer IReturn> ? IReturn : never,
TUiOptionsSchema extends z.ZodType<any, any, infer IUiOptions>
? IUiOptions
: never
>;
// @alpha
export type NextCustomFieldValidator<TFieldReturnValue> = (
data: TFieldReturnValue,
@@ -199,6 +247,7 @@ export type NextFieldExtensionOptions<
props: NextFieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
) => JSX.Element | null;
validation?: NextCustomFieldValidator<TFieldReturnValue>;
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<undefined>;
+3 -1
View File
@@ -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",
@@ -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<any, any>[];
onClose?: () => void;
}) => {
const classes = useStyles();
const fieldOptions = customFieldExtensions.filter(field => !!field.schema);
const [selectedField, setSelectedField] = useState(fieldOptions[0]);
const [fieldFormState, setFieldFormState] = useState({});
const [formState, setFormState] = useState({});
const [refreshKey, setRefreshKey] = useState(Date.now());
const sampleFieldTemplate = useMemo(
() =>
yaml.stringify({
parameters: [
{
title: `${selectedField.name} Example`,
properties: {
[selectedField.name]: {
type: selectedField.schema?.returnValue?.type,
'ui:field': selectedField.name,
'ui:options': fieldFormState,
},
},
},
],
}),
[fieldFormState, selectedField],
);
const fieldComponents = useMemo(() => {
return Object.fromEntries(
customFieldExtensions.map(({ name, component }) => [name, component]),
);
}, [customFieldExtensions]);
const handleSelectionChange = useCallback(
selection => {
setSelectedField(selection);
setFieldFormState({});
setFormState({});
},
[setFieldFormState, setFormState, setSelectedField],
);
const handleFieldConfigChange = useCallback(
state => {
setFieldFormState(state);
setFormState({});
// Force TemplateEditorForm to re-render since some fields
// may not be responsive to ui:option changes
setRefreshKey(Date.now());
},
[setFieldFormState, setRefreshKey],
);
return (
<main className={classes.root}>
<div className={classes.controls}>
<FormControl variant="outlined" size="small" fullWidth>
<InputLabel id="select-field-label">
Choose Custom Field Extension
</InputLabel>
<Select
value={selectedField}
label="Choose Custom Field Extension"
labelId="select-field-label"
onChange={e => handleSelectionChange(e.target.value)}
>
{fieldOptions.map((option, idx) => (
<MenuItem key={idx} value={option as any}>
{option.name}
</MenuItem>
))}
</Select>
</FormControl>
<IconButton size="medium" onClick={onClose}>
<CloseIcon />
</IconButton>
</div>
<div className={classes.fieldForm}>
<Card>
<CardHeader title="Field Options" />
<CardContent>
<Form
showErrorList={false}
fields={{ ...fieldOverrides, ...fieldComponents }}
noHtml5Validate
formData={fieldFormState}
formContext={{ fieldFormState }}
onSubmit={e => handleFieldConfigChange(e.formData)}
schema={selectedField.schema?.uiOptions || {}}
>
<Button
variant="contained"
color="primary"
type="submit"
disabled={!selectedField.schema?.uiOptions}
>
Apply
</Button>
</Form>
</CardContent>
</Card>
</div>
<div className={classes.preview}>
<Card>
<CardHeader title="Example Template Spec" />
<CardContent>
<CodeMirror
readOnly
theme="dark"
height="100%"
extensions={[StreamLanguage.define(yamlSupport)]}
value={sampleFieldTemplate}
/>
</CardContent>
</Card>
<TemplateEditorForm
key={refreshKey}
content={sampleFieldTemplate}
contentIsSpec
fieldExtensions={customFieldExtensions}
data={formState}
onUpdate={setFormState}
setErrorText={() => null}
/>
</div>
</main>
);
};
@@ -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) {
</Card>
);
const cardFieldExplorer = (
<Card className={classes.card} elevation={4}>
<CardActionArea onClick={() => props.onSelect?.('field-explorer')}>
<CardContent>
<Typography variant="h5" gutterBottom>
Custom Field Explorer
</Typography>
<Typography variant="body1">
View and play around with available installed custom field
extensions.
</Typography>
</CardContent>
</CardActionArea>
</Card>
);
return (
<div style={props.style}>
<Typography variant="h6" className={classes.introText}>
@@ -121,6 +137,7 @@ export function TemplateEditorIntro(props: EditorIntroProps) {
{supportsLoad && cardLoadLocal}
{cardFormEditor}
{!supportsLoad && cardLoadLocal}
{cardFieldExplorer}
</div>
</div>
);
@@ -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 = (
<CustomFieldExplorer
customFieldExtensions={props.customFieldExtensions}
onClose={() => setSelection(undefined)}
/>
);
} else {
content = (
<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' });
}
}}
/>
@@ -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<string>,
) => {
export const EntityNamePicker = (props: EntityNamePickerProps) => {
const {
onChange,
required,
@@ -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;
@@ -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<string, EntityPickerUiOptions>,
) => {
export const EntityPicker = (props: EntityPickerProps) => {
const {
onChange,
schema: { title = 'Entity', description = 'An entity from the catalog' },
@@ -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';
@@ -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;
@@ -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<string[], EntityTagsPickerUiOptions>,
) => {
export const EntityTagsPicker = (props: EntityTagsPickerProps) => {
const { formData, onChange, uiSchema } = props;
const catalogApi = useApi(catalogApiRef);
const [tagOptions, setTagOptions] = useState<string[]>([]);
@@ -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';
@@ -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;
@@ -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<string, OwnedEntityPickerUiOptions>,
) => {
export const OwnedEntityPicker = (props: OwnedEntityPickerProps) => {
const {
onChange,
schema: { title = 'Entity', description = 'An entity from the catalog' },
@@ -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';
@@ -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;
@@ -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<string, OwnerPickerUiOptions>,
) => {
export const OwnerPicker = (props: OwnerPickerProps) => {
const {
schema: { title = 'Owner', description = 'The owner of the component' },
uiSchema,
@@ -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';
@@ -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;
@@ -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<string, RepoUrlPickerUiOptions>,
) => {
export const RepoUrlPicker = (props: RepoUrlPickerProps) => {
const { uiSchema, onChange, rawErrors, formData } = props;
const [state, setState] = useState<RepoUrlPickerState>(
parseRepoPickerUrl(formData),
@@ -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';
@@ -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;
@@ -18,3 +18,4 @@ export * from './OwnerPicker';
export * from './RepoUrlPicker';
export * from './OwnedEntityPicker';
export * from './EntityTagsPicker';
export { type FieldSchema, makeFieldSchemaFromZod } from './utils';
@@ -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<TReturn, TUiOptions> {
readonly schema: CustomFieldExtensionSchema;
readonly type: FieldExtensionComponentProps<TReturn, TUiOptions>;
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<any, any, {}>,
>(
returnSchema: TReturnSchema,
uiOptionsSchema?: TUiOptionsSchema,
): FieldSchema<
TReturnSchema extends z.ZodType<any, any, infer IReturn> ? IReturn : never,
TUiOptionsSchema extends z.ZodType<any, any, infer IUiOptions>
? IUiOptions
: never
> {
return {
schema: {
returnValue: zodToJsonSchema(returnSchema) as JSONSchema7,
uiOptions: uiOptionsSchema
? (zodToJsonSchema(uiOptionsSchema) as JSONSchema7)
: undefined,
},
type: null as any,
uiOptionsType: null as any,
};
}
+30 -6
View File
@@ -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,
},
];
@@ -16,6 +16,7 @@
import React from 'react';
import {
CustomFieldExtensionSchema,
CustomFieldValidator,
FieldExtensionOptions,
FieldExtensionComponentProps,
@@ -103,6 +104,7 @@ attachComponentData(
);
export type {
CustomFieldExtensionSchema,
CustomFieldValidator,
FieldExtensionOptions,
FieldExtensionComponentProps,
+14 -2
View File
@@ -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<TFieldReturnValue> = (
context: { apiHolder: ApiHolder },
) => void | Promise<void>;
/**
* 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<TFieldReturnValue, TInputProps>,
) => JSX.Element | null;
validation?: CustomFieldValidator<TFieldReturnValue>;
schema?: CustomFieldExtensionSchema;
};
/**
@@ -107,4 +118,5 @@ export type NextFieldExtensionOptions<
props: NextFieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
) => JSX.Element | null;
validation?: NextCustomFieldValidator<TFieldReturnValue>;
schema?: CustomFieldExtensionSchema;
};
+1
View File
@@ -43,6 +43,7 @@ export {
ScaffolderFieldExtensions,
} from './extensions';
export type {
CustomFieldExtensionSchema,
CustomFieldValidator,
FieldExtensionOptions,
FieldExtensionComponentProps,
+30 -6
View File
@@ -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,
}),
);
+2
View File
@@ -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