diff --git a/.changeset/metal-elephants-sit.md b/.changeset/metal-elephants-sit.md new file mode 100644 index 0000000000..17182ff1ea --- /dev/null +++ b/.changeset/metal-elephants-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Introduced a new MultiEntityPicker field that supports selecting multiple Entities diff --git a/docs/features/software-templates/ui-options-examples.md b/docs/features/software-templates/ui-options-examples.md index b402f68aee..7f0e5b225f 100644 --- a/docs/features/software-templates/ui-options-examples.md +++ b/docs/features/software-templates/ui-options-examples.md @@ -119,6 +119,117 @@ entity: defaultNamespace: payment ``` +## MultiEntityPicker + +The input props that can be specified under `ui:options` for the `MultiEntityPicker` field extension. + +### `allowArbitraryValues` + +Whether to allow arbitrary user input. Defaults to true. + +`allowArbitraryValues` provides input validation when selecting an entity as the values you enter will correspond to a valid entity. + +- Adding a valid entity with `allowArbitraryValues` as `false` + +```yaml +entity: + title: Entities + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + allowArbitraryValues: false +``` + +- Adding an arbitrary entity with `allowArbitraryValues` as `true` (default value) + +```yaml +entity: + title: Entities + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + allowArbitraryValues: true +``` + +### `catalogFilter` + +`catalogFilter` supports filtering options by any field(s) of an entity. + +- Get all entities of kind `Group` + +```yaml +entity: + title: Entities + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + catalogFilter: + - kind: Group +``` + +- Get entities of kind `Group` and spec.type `team` + +```yaml +entity: + title: Entities + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + catalogFilter: + - kind: Group + spec.type: team +``` + +For the full details on the spec.\* values see [here](../software-catalog/descriptor-format.md#kind-group). + +### `defaultKind` + +The default entity kind. + +```yaml +system: + title: System + type: array + description: Systems of the component + ui:field: MultiEntityPicker + ui:options: + catalogFilter: + kind: System + defaultKind: System +``` + +### `defaultNamespace` + +The ID of a namespace that the entity belongs to. The default value is `default`. + +- Listing all entities in the `default` namespace (default value) + +```yaml +entity: + title: Entity + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + defaultNamespace: default +``` + +- Listing all entities in the `payment` namespace + +```yaml +entity: + title: Entity + type: array + description: Entities of the component + ui:field: MultiEntityPicker + ui:options: + defaultNamespace: payment +``` + ## `OwnerPicker` The input props that can be specified under `ui:options` for the `OwnerPicker` field extension. diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx new file mode 100644 index 0000000000..0f27acca9a --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx @@ -0,0 +1,261 @@ +/* + * 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 { + type EntityFilterQuery, + CATALOG_FILTER_EXISTS, +} from '@backstage/catalog-client'; +import { + Entity, + parseEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { + catalogApiRef, + humanizeEntityRef, +} from '@backstage/plugin-catalog-react'; +import { TextField } from '@material-ui/core'; +import FormControl from '@material-ui/core/FormControl'; +import Autocomplete, { + AutocompleteChangeReason, +} from '@material-ui/lab/Autocomplete'; +import React, { useCallback, useEffect } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { FieldValidation } from '@rjsf/core'; +import { + MultiEntityPickerFilterQueryValue, + MultiEntityPickerProps, + MultiEntityPickerUiOptions, + MultiEntityPickerFilterQuery, +} from './schema'; + +export { MultiEntityPickerSchema } from './schema'; + +/** + * The underlying component that is rendered in the form for the `MultiEntityPicker` + * field extension. + * + * @public + */ +export const MultiEntityPicker = (props: MultiEntityPickerProps) => { + const { + onChange, + schema: { title = 'Entity', description = 'An entity from the catalog' }, + required, + uiSchema, + rawErrors, + formData, + idSchema, + } = props; + const catalogFilter = buildCatalogFilter(uiSchema); + const defaultKind = uiSchema['ui:options']?.defaultKind; + const defaultNamespace = + uiSchema['ui:options']?.defaultNamespace || undefined; + + const catalogApi = useApi(catalogApiRef); + + const { value: entities, loading } = useAsync(async () => { + const { items } = await catalogApi.getEntities( + catalogFilter ? { filter: catalogFilter } : undefined, + ); + return items; + }); + const allowArbitraryValues = + uiSchema['ui:options']?.allowArbitraryValues ?? true; + + const getLabel = useCallback( + (ref: string) => { + try { + return humanizeEntityRef( + parseEntityRef(ref, { defaultKind, defaultNamespace }), + { + defaultKind, + defaultNamespace, + }, + ); + } catch (err) { + return ref; + } + }, + [defaultKind, defaultNamespace], + ); + + const onSelect = useCallback( + (_: any, refs: (string | Entity)[], reason: AutocompleteChangeReason) => { + const values = refs + .map(ref => { + if (typeof ref !== 'string') { + // if ref does not exist: pass 'undefined' to trigger validation for required value + return ref ? stringifyEntityRef(ref as Entity) : undefined; + } + if (reason === 'blur' || reason === 'create-option') { + // Add in default namespace, etc. + let entityRef = ref; + try { + // Attempt to parse the entity ref into it's full form. + entityRef = stringifyEntityRef( + parseEntityRef(ref as string, { + defaultKind, + defaultNamespace, + }), + ); + } catch (err) { + // If the passed in value isn't an entity ref, do nothing. + } + + // We need to check against formData here as that's the previous value for this field. + if (formData.includes(ref) || allowArbitraryValues) { + return entityRef; + } + } + + return undefined; + }) + .filter(ref => ref !== undefined) as string[]; + + onChange(values); + }, + [onChange, formData, defaultKind, defaultNamespace, allowArbitraryValues], + ); + + useEffect(() => { + if (entities?.length === 1) { + onChange(stringifyEntityRef(entities[0])); + } + }, [entities, onChange]); + + return ( + 0 && !formData} + > + formData && formData.includes(stringifyEntityRef(e)), + ) ?? (allowArbitraryValues && formData ? formData.map(getLabel) : []) + } + loading={loading} + onChange={onSelect} + options={entities || []} + getOptionLabel={option => + // option can be a string due to freeSolo. + typeof option === 'string' + ? option + : humanizeEntityRef(option, { defaultKind, defaultNamespace })! + } + autoSelect + freeSolo={allowArbitraryValues} + renderInput={params => ( + + )} + /> + + ); +}; + +export const validateMultiEntityPickerValidation = ( + values: string[], + validation: FieldValidation, +) => { + values.forEach(value => { + try { + parseEntityRef(value); + } catch { + validation.addError(`${value} is not a valid entity ref`); + } + }); +}; + +/** + * Converts a special `{exists: true}` value to the `CATALOG_FILTER_EXISTS` symbol. + * + * @param value - The value to convert. + * @returns The converted value. + */ +function convertOpsValues( + value: Exclude>, +): string | symbol { + if (typeof value === 'object' && value.exists) { + return CATALOG_FILTER_EXISTS; + } + return value?.toString(); +} + +/** + * Converts schema filters to entity filter query, replacing `{exists:true}` values + * with the constant `CATALOG_FILTER_EXISTS`. + * + * @param schemaFilters - An object containing schema filters with keys as filter names + * and values as filter values. + * @returns An object with the same keys as the input object, but with `{exists:true}` values + * transformed to `CATALOG_FILTER_EXISTS` symbol. + */ +function convertSchemaFiltersToQuery( + schemaFilters: MultiEntityPickerFilterQuery, +): Exclude> { + const query: EntityFilterQuery = {}; + + for (const [key, value] of Object.entries(schemaFilters)) { + if (Array.isArray(value)) { + query[key] = value; + } else { + query[key] = convertOpsValues(value); + } + } + + return query; +} + +/** + * Builds an `EntityFilterQuery` based on the `uiSchema` passed in. + * If `catalogFilter` is specified in the `uiSchema`, it is converted to a `EntityFilterQuery`. + * + * @param uiSchema The `uiSchema` of an `EntityPicker` component. + * @returns An `EntityFilterQuery` based on the `uiSchema`, or `undefined` if `catalogFilter` is not specified in the `uiSchema`. + */ +function buildCatalogFilter( + uiSchema: MultiEntityPickerProps['uiSchema'], +): EntityFilterQuery | undefined { + const catalogFilter: MultiEntityPickerUiOptions['catalogFilter'] | undefined = + uiSchema['ui:options']?.catalogFilter; + + if (!catalogFilter) { + return undefined; + } + + if (Array.isArray(catalogFilter)) { + return catalogFilter.map(convertSchemaFiltersToQuery); + } + + return convertSchemaFiltersToQuery(catalogFilter); +} diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts b/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts new file mode 100644 index 0000000000..3b82568152 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/index.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +export { MultiEntityPickerExtension } from './extensions'; + +export { + MultiEntityPickerFieldSchema, + type MultiEntityPickerUiOptions, +} from './schema'; diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts new file mode 100644 index 0000000000..b6292a46b7 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/schema.ts @@ -0,0 +1,77 @@ +/* + * 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 { makeFieldSchemaFromZod } from '@backstage/plugin-scaffolder'; +import { z } from 'zod'; + +/** + * @public + */ +export const entityQueryFilterExpressionSchema = z.record( + z + .string() + .or(z.object({ exists: z.boolean().optional() })) + .or(z.array(z.string())), +); + +/** + * @public + */ +export const MultiEntityPickerFieldSchema = makeFieldSchemaFromZod( + z.array(z.string()), + z.object({ + 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.', + ), + catalogFilter: z + .array(entityQueryFilterExpressionSchema) + .or(entityQueryFilterExpressionSchema) + .optional() + .describe('List of key-value filter expression for entities'), + }), +); + +/** + * The input props that can be specified under `ui:options` for the + * `EntityPicker` field extension. + * + * @public + */ +export type MultiEntityPickerUiOptions = + typeof MultiEntityPickerFieldSchema.uiOptionsType; + +export type MultiEntityPickerProps = typeof MultiEntityPickerFieldSchema.type; + +export const MultiEntityPickerSchema = MultiEntityPickerFieldSchema.schema; + +export type MultiEntityPickerFilterQuery = z.TypeOf< + typeof entityQueryFilterExpressionSchema +>; + +export type MultiEntityPickerFilterQueryValue = + MultiEntityPickerFilterQuery[keyof MultiEntityPickerFilterQuery]; diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index 7fa7dc1521..c053bfacd9 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -33,6 +33,11 @@ import { OwnerPicker, OwnerPickerSchema, } from './components/fields/OwnerPicker/OwnerPicker'; +import { + MultiEntityPicker, + MultiEntityPickerSchema, + validateMultiEntityPickerValidation, +} from './components/fields/MultiEntityPicker/MultiEntityPicker'; import { repoPickerValidation } from './components/fields/RepoUrlPicker'; import { RepoUrlPicker, @@ -134,6 +139,20 @@ export const EntityNamePickerFieldExtension = scaffolderPlugin.provide( }), ); +/** + * A field extension for selecting multiple entities that exists in the Catalog. + * + * @public + */ +export const MultiEntityPickerFieldExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + component: MultiEntityPicker, + name: 'MultiEntityPicker', + schema: MultiEntityPickerSchema, + validation: validateMultiEntityPickerValidation, + }), +); + /** * The field extension which provides the ability to select a RepositoryUrl. * Currently, this is an encoded URL that looks something like the following `github.com?repo=myRepoName&owner=backstage`.