feat: introduced a new MultiEntityPicker field that supports selecting multiple Entities

Signed-off-by: Marc Rooding <marc@mrooding.me>
This commit is contained in:
Marc Rooding
2023-09-06 10:38:37 +02:00
committed by blam
parent ab1682e85d
commit a86920b287
6 changed files with 495 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Introduced a new MultiEntityPicker field that supports selecting multiple Entities
@@ -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.
@@ -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 (
<FormControl
margin="normal"
required={required}
error={rawErrors?.length > 0 && !formData}
>
<Autocomplete
multiple
filterSelectedOptions
disabled={entities?.length === 1}
id={idSchema?.$id}
value={
// Since free solo can be enabled, attempt to parse as a full entity ref first, then fall
// back to the given value.
entities?.filter(
e => 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 => (
<TextField
{...params}
label={title}
margin="dense"
helperText={description}
FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }}
variant="outlined"
required={required}
InputProps={params.InputProps}
/>
)}
/>
</FormControl>
);
};
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<MultiEntityPickerFilterQueryValue, Array<any>>,
): 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<EntityFilterQuery, Array<any>> {
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);
}
@@ -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';
@@ -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];
+19
View File
@@ -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`.