Merge pull request #21875 from mrooding/add-multi-entity-picker-field

feat: introduced a new MultiEntityPicker field
This commit is contained in:
Ben Lambert
2024-01-23 13:55:19 +01:00
committed by GitHub
10 changed files with 1217 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.
+28
View File
@@ -210,6 +210,34 @@ export function makeFieldSchemaFromZod<
: never
>;
// @public
export const MultiEntityPickerFieldExtension: FieldExtensionComponent_2<
string[],
{
defaultKind?: string | undefined;
allowArbitraryValues?: boolean | undefined;
defaultNamespace?: string | false | undefined;
catalogFilter?:
| Record<
string,
| string
| string[]
| {
exists?: boolean | undefined;
}
>
| Record<
string,
| string
| string[]
| {
exists?: boolean | undefined;
}
>[]
| undefined;
}
>;
// @public
export const MyGroupsPickerFieldExtension: FieldExtensionComponent_2<
string,
@@ -0,0 +1,694 @@
/*
* Copyright 2020 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 { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { fireEvent, screen } from '@testing-library/react';
import React from 'react';
import { MultiEntityPicker } from './MultiEntityPicker';
import { MultiEntityPickerProps } from './schema';
import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react';
const makeEntity = (kind: string, namespace: string, name: string): Entity => ({
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind,
metadata: { namespace, name },
});
describe('<MultiEntityPicker />', () => {
let entities: Entity[];
const onChange = jest.fn();
const schema = {};
const required = false;
let uiSchema: MultiEntityPickerProps['uiSchema'];
const rawErrors: string[] = [];
const formData: string[] = [];
let props: FieldProps<string[]>;
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(async () => ({ items: entities })),
addLocation: jest.fn(),
getLocationByRef: jest.fn(),
removeEntityByUid: jest.fn(),
} as any;
let Wrapper: React.ComponentType<React.PropsWithChildren<{}>>;
beforeEach(() => {
entities = [
makeEntity('Group', 'default', 'team-a'),
makeEntity('Group', 'default', 'squad-b'),
];
Wrapper = ({ children }: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
{children}
</TestApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
describe('without allowedKinds and catalogFilter', () => {
beforeEach(() => {
uiSchema = { 'ui:options': {} };
props = {
onChange,
schema,
required,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('searches for all entities', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
expect(catalogApi.getEntities).toHaveBeenCalledWith(undefined);
});
it('updates even if there is not an exact match', async () => {
const { getByRole } = await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = getByRole('textbox');
fireEvent.change(input, { target: { value: 'squ' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(['squ']);
});
});
describe('with catalogFilter', () => {
beforeEach(() => {
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
},
};
props = {
onChange,
schema,
required,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('searches for a specific group entity', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
expect(catalogApi.getEntities).toHaveBeenCalledWith({
filter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
});
});
it('allow single top level filter', async () => {
uiSchema = {
'ui:options': {
catalogFilter: {
kind: ['Group'],
'metadata.name': 'test-entity',
},
},
};
catalogApi.getEntities.mockResolvedValue({ items: entities });
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} uiSchema={uiSchema} />
</Wrapper>,
);
expect(catalogApi.getEntities).toHaveBeenCalledWith({
filter: {
kind: ['Group'],
'metadata.name': 'test-entity',
},
});
});
it('search for entitities containing an specific key', async () => {
const uiSchemaWithBoolean = {
'ui:options': {
catalogFilter: [
{
kind: ['User'],
'metadata.annotation.some/anotation': { exists: true },
},
],
},
};
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} uiSchema={uiSchemaWithBoolean} />
</Wrapper>,
);
expect(catalogApi.getEntities).toHaveBeenCalledWith({
filter: [
{
kind: ['User'],
'metadata.annotation.some/anotation': CATALOG_FILTER_EXISTS,
},
],
});
});
});
describe('catalogFilter should take precedence over allowedKinds', () => {
beforeEach(() => {
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-group',
},
],
allowedKinds: ['User'],
},
};
props = {
onChange,
schema,
required,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('searches for a Group entity', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
expect(catalogApi.getEntities).toHaveBeenCalledWith({
filter: [
{
kind: ['Group'],
'metadata.name': 'test-group',
},
],
});
});
});
describe('uses full entity ref', () => {
beforeEach(() => {
uiSchema = {
'ui:options': {
defaultKind: 'Group',
},
};
props = {
onChange,
schema,
required,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('returns the full entityRef when entity exists in the list', async () => {
const { getByRole } = await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = getByRole('textbox');
fireEvent.change(input, { target: { value: 'team-a' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(['group:default/team-a']);
});
it('returns the full entityRef when entity does not exist in the list', async () => {
const { getByRole } = await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = getByRole('textbox');
fireEvent.change(input, { target: { value: 'team-b' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(['group:default/team-b']);
});
});
describe('Required MultiEntityPicker', () => {
beforeEach(() => {
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
},
};
props = {
onChange,
schema,
required: true,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('User enters clear input', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
<div data-testid="outside">Outside</div>
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
expect(input).toHaveValue('');
});
it('User selects item', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'team-a' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(['team-a']);
});
it('User selects item and enters clear input', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
<div data-testid="outside">Outside</div>
</Wrapper>,
);
// Open the Autocomplete dropdown
const input = screen.getByRole('textbox');
fireEvent.click(input);
// Select an option from the dropdown
fireEvent.change(input, { target: { value: 'team-a' } });
// Close the dropdown by clicking outside the Autocomplete component
const outside = screen.getByTestId('outside');
fireEvent.mouseDown(outside);
// Click back into the Autocomplete component
fireEvent.click(input);
// Verify that the selected option is displayed in the input
expect(input).toHaveValue('team-a');
// Click the Clear button to clear the input
const clearButton = screen.getByLabelText('Clear');
fireEvent.click(clearButton);
// Verify that the input is empty
expect(input).toHaveValue('');
// Verify that the handleChange function was called with an empty array
expect(onChange).toHaveBeenCalledWith([]);
});
});
describe('Optional MultiEntityPicker', () => {
beforeEach(() => {
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
},
};
props = {
onChange,
schema,
required: false,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('User enters clear input', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
<div data-testid="outside">Outside</div>
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
expect(input).toHaveValue('');
});
it('User selects item', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'team-a' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(['team-a']);
});
it('User selects item and enters clear input', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
<div data-testid="outside">Outside</div>
</Wrapper>,
);
// Open the Autocomplete dropdown
const input = screen.getByRole('textbox');
fireEvent.click(input);
// Select an option from the dropdown
fireEvent.change(input, { target: { value: 'team-a' } });
// Close the dropdown by clicking outside the Autocomplete component
const outside = screen.getByTestId('outside');
fireEvent.mouseDown(outside);
// Click back into the Autocomplete component
fireEvent.click(input);
// Verify that the selected option is displayed in the input
expect(input).toHaveValue('team-a');
// Click the Clear button to clear the input
const clearButton = screen.getByLabelText('Clear');
fireEvent.click(clearButton);
// Verify that the input is empty
expect(input).toHaveValue('');
// Verify that the handleChange function was called with an empty array
expect(onChange).toHaveBeenCalledWith([]);
});
});
describe('Required Free Solo', () => {
beforeEach(() => {
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
},
allowArbitraryValues: true,
};
props = {
onChange,
schema,
required: true,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('User enters clear input', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
<div data-testid="outside">Outside</div>
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
expect(input).toHaveValue('');
});
it('User selects item', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'team-a' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(['team-a']);
});
it('User selects item and enters clear input', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
<div data-testid="outside">Outside</div>
</Wrapper>,
);
// Open the Autocomplete dropdown
const input = screen.getByRole('textbox');
fireEvent.click(input);
// Select an option from the dropdown
fireEvent.change(input, { target: { value: 'team-a' } });
// Close the dropdown by clicking outside the Autocomplete component
const outside = screen.getByTestId('outside');
fireEvent.mouseDown(outside);
// Click back into the Autocomplete component
fireEvent.click(input);
// Verify that the selected option is displayed in the input
expect(input).toHaveValue('team-a');
// Click the Clear button to clear the input
const clearButton = screen.getByLabelText('Clear');
fireEvent.click(clearButton);
// Verify that the input is empty
expect(input).toHaveValue('');
// Verify that the handleChange function was called with an empty array
expect(onChange).toHaveBeenCalledWith([]);
});
});
describe('Optional Free Solo', () => {
beforeEach(() => {
uiSchema = {
'ui:options': {
catalogFilter: [
{
kind: ['Group'],
'metadata.name': 'test-entity',
},
{
kind: ['User'],
'metadata.name': 'test-entity',
},
],
},
allowArbitraryValues: true,
};
props = {
onChange,
schema,
required: false,
uiSchema,
rawErrors,
formData,
} as unknown as FieldProps<any>;
catalogApi.getEntities.mockResolvedValue({ items: entities });
});
it('User enters clear input', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
<div data-testid="outside">Outside</div>
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
expect(input).toHaveValue('');
});
it('User selects item', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'team-a' } });
fireEvent.blur(input);
expect(onChange).toHaveBeenCalledWith(['team-a']);
});
it('User selects item and enters clear input', async () => {
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
<div data-testid="outside">Outside</div>
</Wrapper>,
);
// Open the Autocomplete dropdown
const input = screen.getByRole('textbox');
fireEvent.click(input);
// Select an option from the dropdown
fireEvent.change(input, { target: { value: 'team-a' } });
// Close the dropdown by clicking outside the Autocomplete component
const outside = screen.getByTestId('outside');
fireEvent.mouseDown(outside);
// Click back into the Autocomplete component
fireEvent.click(input);
// Verify that the selected option is displayed in the input
expect(input).toHaveValue('team-a');
// Click the Clear button to clear the input
const clearButton = screen.getByLabelText('Clear');
fireEvent.click(clearButton);
// Verify that the input is empty
expect(input).toHaveValue('');
// Verify that the handleChange function was called with an empty array
expect(onChange).toHaveBeenCalledWith([]);
});
});
});
@@ -0,0 +1,259 @@
/*
* 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/utils';
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.
*/
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,20 @@
/*
* 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 {
MultiEntityPickerFieldSchema,
type MultiEntityPickerUiOptions,
} from './schema';
@@ -0,0 +1,69 @@
/*
* 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';
export const entityQueryFilterExpressionSchema = z.record(
z
.string()
.or(z.object({ exists: z.boolean().optional() }))
.or(z.array(z.string())),
);
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.
*/
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];
@@ -45,6 +45,11 @@ import {
} from '../components/fields/MyGroupsPicker/MyGroupsPicker';
import { SecretInput } from '../components/fields/SecretInput';
import {
MultiEntityPicker,
MultiEntityPickerSchema,
validateMultiEntityPickerValidation,
} from '../components/fields/MultiEntityPicker/MultiEntityPicker';
export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
{
@@ -88,4 +93,10 @@ export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS = [
component: SecretInput,
name: 'Secret',
},
{
component: MultiEntityPicker,
name: 'MultiEntityPicker',
schema: MultiEntityPickerSchema,
validation: validateMultiEntityPickerValidation,
},
];
+1
View File
@@ -29,6 +29,7 @@ export {
OwnedEntityPickerFieldExtension,
MyGroupsPickerFieldExtension,
RepoUrlPickerFieldExtension,
MultiEntityPickerFieldExtension,
ScaffolderPage,
scaffolderPlugin,
} from './plugin';
+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`.