Merge pull request #26688 from backstage/gh-envs-support-more-params

[Scaffolder] Github env create action to support more params from the Github env api
This commit is contained in:
Ben Lambert
2024-09-17 08:59:10 +02:00
committed by GitHub
13 changed files with 632 additions and 3 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
'@backstage/plugin-scaffolder-backend': patch
'@backstage/plugin-scaffolder': patch
---
Added support for all request parameters in the Github create/update environment API in the Github environment create scaffolder action.
Disable MultiEntityPicker when `maxItems` limit is reached defined in `JSONSchema`
+3
View File
@@ -263,4 +263,7 @@ module.exports = {
},
},
],
settings: {
'testing-library/custom-queries': 'off',
},
};
@@ -4,6 +4,7 @@
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { createPullRequest } from 'octokit-plugin-create-pull-request';
import { GithubCredentialsProvider } from '@backstage/integration';
@@ -66,6 +67,7 @@ export function createGithubDeployKeyAction(options: {
// @public
export function createGithubEnvironmentAction(options: {
integrations: ScmIntegrationRegistry;
catalogClient?: CatalogApi;
}): TemplateAction<
{
repoUrl: string;
@@ -89,6 +91,9 @@ export function createGithubEnvironmentAction(options: {
}
| undefined;
token?: string | undefined;
waitTimer?: number | undefined;
preventSelfReview?: boolean | undefined;
reviewers?: string[] | undefined;
},
JsonObject
>;
@@ -44,6 +44,8 @@
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
@@ -302,4 +302,52 @@ export const examples: TemplateExample[] = [
],
}),
},
{
description: 'Create a GitHub Environment with Wait Timer',
example: yaml.stringify({
steps: [
{
action: 'github:environment:create',
name: 'Create Environment',
input: {
repoUrl: 'github.com?repo=repository&owner=owner',
name: 'envname',
waitTimer: 1000,
},
},
],
}),
},
{
description: 'Create a GitHub Environment with Prevent Self Review',
example: yaml.stringify({
steps: [
{
action: 'github:environment:create',
name: 'Create Environment',
input: {
repoUrl: 'github.com?repo=repository&owner=owner',
name: 'envname',
preventSelfReview: true,
},
},
],
}),
},
{
description: 'Create a GitHub Environment with Reviewers',
example: yaml.stringify({
steps: [
{
action: 'github:environment:create',
name: 'Create Environment',
input: {
repoUrl: 'github.com?repo=repository&owner=owner',
name: 'envname',
reviewers: ['group:default/team-a', 'user:default/johndoe'],
},
},
],
}),
},
];
@@ -20,6 +20,7 @@ import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import yaml from 'yaml';
import { examples } from './gitHubEnvironment.examples';
import { CatalogApi } from '@backstage/catalog-client';
const mockOctokit = {
rest: {
@@ -33,8 +34,17 @@ const mockOctokit = {
createOrUpdateEnvironment: jest.fn(),
get: jest.fn(),
},
teams: {
getByName: jest.fn(),
},
users: {
getByUsername: jest.fn(),
},
},
};
const mockCatalogClient: Partial<CatalogApi> = {
getEntitiesByRefs: jest.fn(),
};
jest.mock('octokit', () => ({
Octokit: class {
constructor() {
@@ -42,6 +52,9 @@ jest.mock('octokit', () => ({
}
},
}));
jest.mock('@backstage/catalog-client', () => ({
CatalogClient: mockCatalogClient,
}));
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
@@ -72,9 +85,36 @@ describe('github:environment:create examples', () => {
id: 'repoid',
},
});
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: {
id: 1,
},
});
mockOctokit.rest.teams.getByName.mockResolvedValue({
data: {
id: 2,
},
});
(mockCatalogClient.getEntitiesByRefs as jest.Mock).mockResolvedValue({
items: [
{
kind: 'User',
metadata: {
name: 'johndoe',
},
},
{
kind: 'Group',
metadata: {
name: 'team-a',
},
},
],
});
action = createGithubEnvironmentAction({
integrations,
catalogClient: mockCatalogClient as CatalogApi,
});
});
@@ -95,6 +135,9 @@ describe('github:environment:create examples', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
mockOctokit.rest.repos.createDeploymentBranchPolicy,
@@ -128,6 +171,9 @@ describe('github:environment:create examples', () => {
protected_branches: true,
custom_branch_policies: false,
},
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -162,6 +208,9 @@ describe('github:environment:create examples', () => {
protected_branches: false,
custom_branch_policies: true,
},
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -208,6 +257,9 @@ describe('github:environment:create examples', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -280,6 +332,9 @@ describe('github:environment:create examples', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -331,6 +386,9 @@ describe('github:environment:create examples', () => {
custom_branch_policies: true,
protected_branches: false,
},
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -382,6 +440,9 @@ describe('github:environment:create examples', () => {
custom_branch_policies: true,
protected_branches: false,
},
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -473,6 +534,9 @@ describe('github:environment:create examples', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -503,6 +567,9 @@ describe('github:environment:create examples', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -536,6 +603,9 @@ describe('github:environment:create examples', () => {
custom_branch_policies: true,
protected_branches: false,
},
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -593,6 +663,9 @@ describe('github:environment:create examples', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -643,6 +716,9 @@ describe('github:environment:create examples', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -697,6 +773,9 @@ describe('github:environment:create examples', () => {
custom_branch_policies: false,
protected_branches: true,
},
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -726,6 +805,9 @@ describe('github:environment:create examples', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: null,
prevent_self_review: false,
});
expect(
@@ -782,4 +864,112 @@ describe('github:environment:create examples', () => {
secret_name: 'SECRET2',
});
});
it(`should ${examples[14].description}`, async () => {
const input = yaml.parse(examples[14].example).steps[0].input;
await action.handler({
...mockContext,
input,
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 1000,
reviewers: null,
prevent_self_review: false,
});
expect(
mockOctokit.rest.repos.createDeploymentBranchPolicy,
).not.toHaveBeenCalled();
expect(
mockOctokit.rest.actions.createEnvironmentVariable,
).not.toHaveBeenCalled();
expect(
mockOctokit.rest.actions.getEnvironmentPublicKey,
).not.toHaveBeenCalled();
expect(
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
).not.toHaveBeenCalled();
});
it(`should ${examples[15].description}`, async () => {
const input = yaml.parse(examples[15].example).steps[0].input;
await action.handler({
...mockContext,
input,
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: null,
prevent_self_review: true,
});
expect(
mockOctokit.rest.repos.createDeploymentBranchPolicy,
).not.toHaveBeenCalled();
expect(
mockOctokit.rest.actions.createEnvironmentVariable,
).not.toHaveBeenCalled();
expect(
mockOctokit.rest.actions.getEnvironmentPublicKey,
).not.toHaveBeenCalled();
expect(
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
).not.toHaveBeenCalled();
});
it(`should ${examples[16].description}`, async () => {
const input = yaml.parse(examples[16].example).steps[0].input;
await action.handler({
...mockContext,
input,
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
reviewers: [
{
type: 'User',
id: 1,
},
{
type: 'Team',
id: 2,
},
],
prevent_self_review: false,
});
expect(
mockOctokit.rest.repos.createDeploymentBranchPolicy,
).not.toHaveBeenCalled();
expect(
mockOctokit.rest.actions.createEnvironmentVariable,
).not.toHaveBeenCalled();
expect(
mockOctokit.rest.actions.getEnvironmentPublicKey,
).not.toHaveBeenCalled();
expect(
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
).not.toHaveBeenCalled();
});
});
@@ -19,6 +19,7 @@ import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { CatalogApi } from '@backstage/catalog-client';
const mockOctokit = {
rest: {
@@ -32,8 +33,19 @@ const mockOctokit = {
createOrUpdateEnvironment: jest.fn(),
get: jest.fn(),
},
teams: {
getByName: jest.fn(),
},
users: {
getByUsername: jest.fn(),
},
},
};
const mockCatalogClient: Partial<CatalogApi> = {
getEntitiesByRefs: jest.fn(),
};
jest.mock('octokit', () => ({
Octokit: class {
constructor() {
@@ -42,6 +54,10 @@ jest.mock('octokit', () => ({
},
}));
jest.mock('@backstage/catalog-client', () => ({
CatalogClient: mockCatalogClient,
}));
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
describe('github:environment:create', () => {
@@ -76,9 +92,36 @@ describe('github:environment:create', () => {
id: 'repoid',
},
});
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: {
id: 1,
},
});
mockOctokit.rest.teams.getByName.mockResolvedValue({
data: {
id: 2,
},
});
(mockCatalogClient.getEntitiesByRefs as jest.Mock).mockResolvedValue({
items: [
{
kind: 'User',
metadata: {
name: 'johndoe',
},
},
{
kind: 'Group',
metadata: {
name: 'team-a',
},
},
],
});
action = createGithubEnvironmentAction({
integrations,
catalogClient: mockCatalogClient as CatalogApi,
});
});
@@ -94,6 +137,9 @@ describe('github:environment:create', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
reviewers: null,
wait_timer: 0,
prevent_self_review: false,
});
});
@@ -119,6 +165,9 @@ describe('github:environment:create', () => {
protected_branches: true,
custom_branch_policies: false,
},
reviewers: null,
wait_timer: 0,
prevent_self_review: false,
});
});
@@ -145,6 +194,9 @@ describe('github:environment:create', () => {
protected_branches: false,
custom_branch_policies: true,
},
reviewers: null,
wait_timer: 0,
prevent_self_review: false,
});
expect(
@@ -190,6 +242,9 @@ describe('github:environment:create', () => {
protected_branches: false,
custom_branch_policies: true,
},
reviewers: null,
wait_timer: 0,
prevent_self_review: false,
});
expect(
@@ -231,6 +286,9 @@ describe('github:environment:create', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
reviewers: null,
wait_timer: 0,
prevent_self_review: false,
});
expect(
@@ -277,6 +335,9 @@ describe('github:environment:create', () => {
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
reviewers: null,
wait_timer: 0,
prevent_self_review: false,
});
expect(
@@ -316,4 +377,101 @@ describe('github:environment:create', () => {
environment_name: 'envname',
});
});
it('should work with wait_timer', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
waitTimer: 1000,
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
reviewers: null,
wait_timer: 1000,
prevent_self_review: false,
});
});
it('should work with preventSelfReview set to true', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
preventSelfReview: true,
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
reviewers: null,
wait_timer: 0,
prevent_self_review: true,
});
});
it('should work with preventSelfReview set to false', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
preventSelfReview: false,
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
reviewers: null,
wait_timer: 0,
prevent_self_review: false,
});
});
it('should work with reviewers', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
reviewers: ['group:default/team-a', 'user:default/johndoe'],
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
wait_timer: 0,
prevent_self_review: false,
reviewers: [
{
id: 1,
type: 'User',
},
{
id: 2,
type: 'Team',
},
],
});
});
});
@@ -24,6 +24,8 @@ import { getOctokitOptions } from './helpers';
import { Octokit } from 'octokit';
import Sodium from 'libsodium-wrappers';
import { examples } from './gitHubEnvironment.examples';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
/**
* Creates an `github:environment:create` Scaffolder action that creates a Github Environment.
@@ -32,8 +34,9 @@ import { examples } from './gitHubEnvironment.examples';
*/
export function createGithubEnvironmentAction(options: {
integrations: ScmIntegrationRegistry;
catalogClient?: CatalogApi;
}) {
const { integrations } = options;
const { integrations, catalogClient } = options;
// For more information on how to define custom actions, see
// https://backstage.io/docs/features/software-templates/writing-custom-actions
return createTemplateAction<{
@@ -48,6 +51,9 @@ export function createGithubEnvironmentAction(options: {
environmentVariables?: { [key: string]: string };
secrets?: { [key: string]: string };
token?: string;
waitTimer?: number;
preventSelfReview?: boolean;
reviewers?: string[];
}>({
id: 'github:environment:create',
description: 'Creates Deployment Environments',
@@ -120,6 +126,25 @@ export function createGithubEnvironmentAction(options: {
type: 'string',
description: 'The token to use for authorization to GitHub',
},
waitTimer: {
title: 'Wait Timer',
type: 'integer',
description:
'The time to wait before creating or updating the environment (in milliseconds)',
},
preventSelfReview: {
title: 'Prevent Self Review',
type: 'boolean',
description: 'Whether to prevent self-review for this environment',
},
reviewers: {
title: 'Reviewers',
type: 'array',
description: 'Reviewers for this environment',
items: {
type: 'string',
},
},
},
},
},
@@ -133,8 +158,15 @@ export function createGithubEnvironmentAction(options: {
environmentVariables,
secrets,
token: providedToken,
waitTimer,
preventSelfReview,
reviewers,
} = ctx.input;
// When environment creation step is executed right after a repo publish step, the repository might not be available immediately.
// Add a 2-second delay before initiating the steps in this action.
await new Promise(resolve => setTimeout(resolve, 2000));
const octokitOptions = await getOctokitOptions({
integrations,
token: providedToken,
@@ -153,11 +185,56 @@ export function createGithubEnvironmentAction(options: {
repo: repo,
});
// convert reviewers from catalog entity to Github user or team
const githubReviewers: { type: 'User' | 'Team'; id: number }[] = [];
if (reviewers) {
let reviewersEntityRefs: Array<Entity | undefined> = [];
// Fetch reviewers from Catalog
const catalogResponse = await catalogClient?.getEntitiesByRefs({
entityRefs: reviewers,
});
if (catalogResponse?.items?.length) {
reviewersEntityRefs = catalogResponse.items;
}
for (const reviewerEntityRef of reviewersEntityRefs) {
if (reviewerEntityRef?.kind === 'User') {
try {
const user = await client.rest.users.getByUsername({
username: reviewerEntityRef.metadata.name,
});
githubReviewers.push({
type: 'User',
id: user.data.id,
});
} catch (error) {
ctx.logger.error('User not found:', error);
}
} else if (reviewerEntityRef?.kind === 'Group') {
try {
const team = await client.rest.teams.getByName({
org: owner,
team_slug: reviewerEntityRef.metadata.name,
});
githubReviewers.push({
type: 'Team',
id: team.data.id,
});
} catch (error) {
ctx.logger.error('Team not found:', error);
}
}
}
}
await client.rest.repos.createOrUpdateEnvironment({
owner: owner,
repo: repo,
environment_name: name,
deployment_branch_policy: deploymentBranchPolicy ?? null,
wait_timer: waitTimer ?? 0,
prevent_self_review: preventSelfReview ?? false,
reviewers: githubReviewers.length ? githubReviewers : null,
});
if (customBranchPolicyNames) {
@@ -35,6 +35,7 @@ import {
DefaultGithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { CatalogClient } from '@backstage/catalog-client';
/**
* @public
@@ -48,11 +49,15 @@ export const githubModule = createBackendModule({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
config: coreServices.rootConfig,
discovery: coreServices.discovery,
},
async init({ scaffolder, config }) {
async init({ scaffolder, config, discovery }) {
const integrations = ScmIntegrations.fromConfig(config);
const githubCredentialsProvider =
DefaultGithubCredentialsProvider.fromIntegrations(integrations);
const catalogClient = new CatalogClient({
discoveryApi: discovery,
});
scaffolder.addActions(
createGithubActionsDispatchAction({
@@ -68,6 +73,7 @@ export const githubModule = createBackendModule({
}),
createGithubEnvironmentAction({
integrations,
catalogClient,
}),
createGithubIssuesLabelAction({
integrations,
@@ -241,6 +241,7 @@ export const createBuiltinActions = (
}),
createGithubEnvironmentAction({
integrations,
catalogClient,
}),
createGithubDeployKeyAction({
integrations,
@@ -24,6 +24,7 @@ import {
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { fireEvent, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import React from 'react';
import { MultiEntityPicker } from './MultiEntityPicker';
import { MultiEntityPickerProps } from './schema';
@@ -704,4 +705,123 @@ describe('<MultiEntityPicker />', () => {
expect(onChange).toHaveBeenCalledWith([]);
});
});
describe('Multiselect maxNoOfEntities option', () => {
beforeEach(() => {
const testEntities = [
makeEntity('Group', 'default', 'team-a'),
makeEntity('Group', 'default', 'squad-b'),
makeEntity('User', 'default', 'user-a'),
makeEntity('User', 'default', 'user-b'),
];
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: testEntities });
});
it('limit the number of selected entities when maxNoOfEntities is specified', async () => {
props.schema.maxItems = 2;
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.mouseDown(input);
const optionA = screen.getByText('team-a');
await userEvent.click(optionA as HTMLElement);
fireEvent.mouseDown(input);
const optionB = screen.getByText('user-b');
await userEvent.click(optionB as HTMLElement);
fireEvent.mouseDown(input);
const optionC = screen.getByText('user-a');
await expect(() =>
userEvent.click(optionC as HTMLElement),
).rejects.toThrow(/pointer-events: none/);
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenNthCalledWith(1, ['group:default/team-a']);
expect(onChange).toHaveBeenNthCalledWith(2, [
'group:default/team-a',
'user:default/user-b',
]);
expect(onChange).not.toHaveBeenNthCalledWith(3, [
'group:default/team-a',
'user:default/user-b',
'user:default/user-a',
]);
});
it('does not limit the number of selected entities when maxItems is not specified', async () => {
props.schema.maxItems = undefined;
await renderInTestApp(
<Wrapper>
<MultiEntityPicker {...props} />
</Wrapper>,
);
const input = screen.getByRole('textbox');
fireEvent.mouseDown(input);
const optionA = screen.getByText('team-a');
await userEvent.click(optionA as HTMLElement);
fireEvent.mouseDown(input);
const optionB = screen.getByText('user-b');
await userEvent.click(optionB as HTMLElement);
fireEvent.mouseDown(input);
const optionC = screen.getByText('user-a');
await userEvent.click(optionC as HTMLElement);
fireEvent.mouseDown(input);
const optionD = screen.getByText('squad-b');
await userEvent.click(optionD as HTMLElement);
expect(onChange).toHaveBeenCalledTimes(4);
expect(onChange).toHaveBeenNthCalledWith(1, ['group:default/team-a']);
expect(onChange).toHaveBeenNthCalledWith(2, [
'group:default/team-a',
'user:default/user-b',
]);
expect(onChange).toHaveBeenNthCalledWith(3, [
'group:default/team-a',
'user:default/user-b',
'user:default/user-a',
]);
expect(onChange).toHaveBeenNthCalledWith(4, [
'group:default/team-a',
'user:default/user-b',
'user:default/user-a',
'group:default/squad-b',
]);
});
});
});
@@ -34,7 +34,7 @@ import FormControl from '@material-ui/core/FormControl';
import Autocomplete, {
AutocompleteChangeReason,
} from '@material-ui/lab/Autocomplete';
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import useAsync from 'react-use/esm/useAsync';
import { FieldValidation } from '@rjsf/utils';
import {
@@ -65,6 +65,7 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
const defaultKind = uiSchema['ui:options']?.defaultKind;
const defaultNamespace =
uiSchema['ui:options']?.defaultNamespace || undefined;
const [noOfItemsSelected, setNoOfItemsSelected] = useState(0);
const catalogApi = useApi(catalogApiRef);
const entityPresentationApi = useApi(entityPresentationApiRef);
@@ -92,6 +93,9 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
const allowArbitraryValues =
uiSchema['ui:options']?.allowArbitraryValues ?? true;
// if not specified, maxItems defaults to undefined
const maxItems = props.schema.maxItems;
const onSelect = useCallback(
(_: any, refs: (string | Entity)[], reason: AutocompleteChangeReason) => {
const values = refs
@@ -125,6 +129,7 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
})
.filter(ref => ref !== undefined) as string[];
setNoOfItemsSelected(values.length);
onChange(values);
},
[onChange, formData, defaultKind, defaultNamespace, allowArbitraryValues],
@@ -158,6 +163,9 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
: entities?.entityRefToPresentation.get(stringifyEntityRef(option))
?.entityRef!
}
getOptionDisabled={_options =>
maxItems ? noOfItemsSelected >= maxItems : false
}
autoSelect
freeSolo={allowArbitraryValues}
renderInput={params => (
+2
View File
@@ -7109,6 +7109,8 @@ __metadata:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/catalog-client": "workspace:^"
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"