diff --git a/.changeset/tall-lamps-design.md b/.changeset/tall-lamps-design.md new file mode 100644 index 0000000000..5b1a07e5de --- /dev/null +++ b/.changeset/tall-lamps-design.md @@ -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` diff --git a/.eslintrc.js b/.eslintrc.js index 01cad187be..eb2a07b3b0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -263,4 +263,7 @@ module.exports = { }, }, ], + settings: { + 'testing-library/custom-queries': 'off', + }, }; diff --git a/plugins/scaffolder-backend-module-github/api-report.md b/plugins/scaffolder-backend-module-github/api-report.md index ec9cb2e31a..86964b7623 100644 --- a/plugins/scaffolder-backend-module-github/api-report.md +++ b/plugins/scaffolder-backend-module-github/api-report.md @@ -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 >; diff --git a/plugins/scaffolder-backend-module-github/package.json b/plugins/scaffolder-backend-module-github/package.json index 9ebd61f809..fad69f590c 100644 --- a/plugins/scaffolder-backend-module-github/package.json +++ b/plugins/scaffolder-backend-module-github/package.json @@ -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:^", diff --git a/plugins/scaffolder-backend-module-github/src/actions/gitHubEnvironment.examples.ts b/plugins/scaffolder-backend-module-github/src/actions/gitHubEnvironment.examples.ts index 7c5f7efa3d..850fd24847 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/gitHubEnvironment.examples.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/gitHubEnvironment.examples.ts @@ -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'], + }, + }, + ], + }), + }, ]; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts index c8b133859b..e89aa466bc 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.examples.test.ts @@ -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 = { + 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(); + }); }); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts index 139d8028eb..1397942a7c 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.test.ts @@ -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 = { + 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', + }, + ], + }); + }); }); diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.ts b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.ts index d3e8d0e5d2..29c0a3ce38 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubEnvironment.ts @@ -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 = []; + // 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) { diff --git a/plugins/scaffolder-backend-module-github/src/module.ts b/plugins/scaffolder-backend-module-github/src/module.ts index a5676ab8c9..03835cf411 100644 --- a/plugins/scaffolder-backend-module-github/src/module.ts +++ b/plugins/scaffolder-backend-module-github/src/module.ts @@ -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, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index e2392150cc..3c09d91c2d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -241,6 +241,7 @@ export const createBuiltinActions = ( }), createGithubEnvironmentAction({ integrations, + catalogClient, }), createGithubDeployKeyAction({ integrations, diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx index 4be07b139d..edd777652b 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx @@ -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('', () => { 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; + + catalogApi.getEntities.mockResolvedValue({ items: testEntities }); + }); + + it('limit the number of selected entities when maxNoOfEntities is specified', async () => { + props.schema.maxItems = 2; + await renderInTestApp( + + + , + ); + + 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( + + + , + ); + + 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', + ]); + }); + }); }); diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx index d800c9b372..f514bfc2bb 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.tsx @@ -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 => ( diff --git a/yarn.lock b/yarn.lock index 7965744b33..3af63ade73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"