Merge pull request #21875 from mrooding/add-multi-entity-picker-field
feat: introduced a new MultiEntityPicker field
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+694
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
OwnedEntityPickerFieldExtension,
|
||||
MyGroupsPickerFieldExtension,
|
||||
RepoUrlPickerFieldExtension,
|
||||
MultiEntityPickerFieldExtension,
|
||||
ScaffolderPage,
|
||||
scaffolderPlugin,
|
||||
} from './plugin';
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user