diff --git a/ee/app/assets/javascripts/usage_quotas/add_on/graphql/user_add_on_assignment_bulk_create.mutation.graphql b/ee/app/assets/javascripts/usage_quotas/add_on/graphql/user_add_on_assignment_bulk_create.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..b2e563c296b1434bc56654c6824f7619a2450532 --- /dev/null +++ b/ee/app/assets/javascripts/usage_quotas/add_on/graphql/user_add_on_assignment_bulk_create.mutation.graphql @@ -0,0 +1,27 @@ +mutation userAddOnAssignmentBulkCreate( + $userIds: [UserID!]! + $addOnPurchaseId: GitlabSubscriptionsAddOnPurchaseID! +) { + userAddOnAssignmentBulkCreate(input: { userIds: $userIds, addOnPurchaseId: $addOnPurchaseId }) { + errors + users { + nodes { + id + addOnAssignments(addOnPurchaseIds: [$addOnPurchaseId]) { + nodes { + # eslint-disable-next-line @graphql-eslint/require-id-when-available + addOnPurchase { + name + } + } + } + } + } + addOnPurchase { + id + name + purchasedQuantity + assignedQuantity + } + } +} diff --git a/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal.vue b/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal.vue index 8bd7d90a08d5499f10abb1c68ea1e2c7a8aea188..bd77cfc81e6c9c5e68e9636da305fcbc2f8458f5 100644 --- a/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal.vue +++ b/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal.vue @@ -18,6 +18,11 @@ export default { type: String, required: true, }, + isBulkActionInProgress: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isBulkActionToAssignSeats() { @@ -56,6 +61,9 @@ export default { hide() { this.$emit('cancel'); }, + confirmSeatAssignment() { + this.$emit('confirm-seat-assignment'); + }, }, }; </script> @@ -72,13 +80,19 @@ export default { <template #modal-footer> <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"> - <gl-button data-testid="bulk-action-cancel-button" @click="hide"> + <gl-button + data-testid="bulk-action-cancel-button" + :disabled="isBulkActionInProgress" + @click="hide" + > {{ __('Cancel') }} </gl-button> <gl-button v-if="isBulkActionToAssignSeats" variant="confirm" data-testid="assign-confirmation-button" + :loading="isBulkActionInProgress" + @click="confirmSeatAssignment" > {{ s__('Billing|Assign seats') }} </gl-button> diff --git a/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/add_on_eligible_user_list.vue b/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/add_on_eligible_user_list.vue index 07d44637ac238eda131665ce5d2784b804cdeed0..62bb66ac63bbe54d884e1044e9bd11da8c87643d 100644 --- a/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/add_on_eligible_user_list.vue +++ b/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/add_on_eligible_user_list.vue @@ -15,6 +15,7 @@ import { s__, n__, sprintf } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { ADD_ON_ERROR_DICTIONARY } from 'ee/usage_quotas/error_constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { addOnEligibleUserListTableFields, ASSIGN_SEATS_BULK_ACTION, @@ -24,6 +25,7 @@ import ErrorAlert from 'ee/vue_shared/components/error_alert/error_alert.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; import CodeSuggestionsAddonAssignment from 'ee/usage_quotas/code_suggestions/components/code_suggestions_addon_assignment.vue'; import AddOnBulkActionConfirmationModal from 'ee/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal.vue'; +import userAddOnAssignmentBulkCreateMutation from 'ee/usage_quotas/add_on/graphql/user_add_on_assignment_bulk_create.mutation.graphql'; export default { name: 'AddOnEligibleUserList', @@ -77,6 +79,7 @@ export default { addOnAssignmentError: undefined, selectedUsers: [], bulkAction: undefined, + isBulkActionInProgress: false, isConfirmationModalVisible: false, }; }, @@ -172,18 +175,18 @@ export default { scrollToElement(this.$el); }, isUserSelected(item) { - return this.selectedUsers.includes(item.username); + return this.selectedUsers.includes(item.id); }, handleUserSelection(user, value) { if (value) { - this.selectedUsers.push(user.username); + this.selectedUsers.push(user.id); } else { - this.selectedUsers = this.selectedUsers.filter((username) => username !== user.username); + this.selectedUsers = this.selectedUsers.filter((id) => id !== user.id); } }, handleSelectAllUsers(value) { if (value) { - this.selectedUsers = this.users.map((user) => user.username); + this.selectedUsers = this.users.map((user) => user.id); } else { this.unselectAllUsers(); } @@ -199,6 +202,35 @@ export default { this.isConfirmationModalVisible = false; this.bulkAction = undefined; }, + async assignSeats() { + this.isBulkActionInProgress = true; + + try { + const { + data: { userAddOnAssignmentBulkCreate }, + } = await this.$apollo.mutate({ + mutation: userAddOnAssignmentBulkCreateMutation, + variables: { + userIds: this.selectedUsers, + addOnPurchaseId: this.addOnPurchaseId, + }, + }); + + const errors = userAddOnAssignmentBulkCreate?.errors || []; + + if (!errors.length) { + this.unselectAllUsers(); + } + } catch (e) { + this.handleBulkActionError(e); + } finally { + this.isBulkActionInProgress = false; + this.isConfirmationModalVisible = false; + } + }, + handleBulkActionError(e) { + Sentry.captureException(e); + }, }, }; </script> @@ -327,6 +359,8 @@ export default { v-if="isConfirmationModalVisible" :bulk-action="bulkAction" :user-count="selectedUsers.length" + :is-bulk-action-in-progress="isBulkActionInProgress" + @confirm-seat-assignment="assignSeats" @cancel="handleCancelBulkAction" /> </section> diff --git a/ee/spec/frontend/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal_spec.js b/ee/spec/frontend/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal_spec.js index 28aaeb2a4feed4610f3340203d54d27461587a9f..3b84790a6e9a55a06e7ebe9892e19274164f11cb 100644 --- a/ee/spec/frontend/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal_spec.js +++ b/ee/spec/frontend/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal_spec.js @@ -53,6 +53,14 @@ describe('Add On Bulk Action Confirmation Modal', () => { 'This action will assign a GitLab Duo Pro seat to 2 users. Are you sure you want to continue?', ); }); + + it('emits appropriate event on assignment confirmation', () => { + createComponent(); + + findAssignSeatsButton().vm.$emit('click'); + + expect(wrapper.emitted('confirm-seat-assignment')).toHaveLength(1); + }); }); describe('when bulk action is for seat unassignment', () => { @@ -80,6 +88,35 @@ describe('Add On Bulk Action Confirmation Modal', () => { }); }); + describe('loading state', () => { + describe('when loading', () => { + beforeEach(() => { + createComponent({ isBulkActionInProgress: true }); + }); + + it('shows loading state for confirm assignment button', () => { + expect(findAssignSeatsButton().props().loading).toBe(true); + }); + + it('disables cancel button', () => { + expect(findCancelButton().props().disabled).toBe(true); + }); + }); + describe('when not loading', () => { + beforeEach(() => { + createComponent({ isBulkActionInProgress: false }); + }); + + it('does not show loading state', () => { + expect(findAssignSeatsButton().props().loading).toBe(false); + }); + + it('does not disable cancel button', () => { + expect(findCancelButton().props().disabled).toBe(false); + }); + }); + }); + it('should emit cancel when cancel button is clicked', () => { createComponent(); diff --git a/ee/spec/frontend/usage_quotas/code_suggestions/components/add_on_eligible_user_list_spec.js b/ee/spec/frontend/usage_quotas/code_suggestions/components/add_on_eligible_user_list_spec.js index 4255ea22e01f03aa997e903f0da9ab05e3a2022d..54d91c9401984a6221e0dcd50d0ad803d5272e09 100644 --- a/ee/spec/frontend/usage_quotas/code_suggestions/components/add_on_eligible_user_list_spec.js +++ b/ee/spec/frontend/usage_quotas/code_suggestions/components/add_on_eligible_user_list_spec.js @@ -7,7 +7,9 @@ import { GlFormCheckbox, } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import CodeSuggestionsAddOnAssignment from 'ee/usage_quotas/code_suggestions/components/code_suggestions_addon_assignment.vue'; import AddOnEligibleUserList from 'ee/usage_quotas/code_suggestions/components/add_on_eligible_user_list.vue'; import waitForPromises from 'helpers/wait_for_promises'; @@ -16,28 +18,103 @@ import { pageInfoWithNoPages, pageInfoWithMorePages, eligibleUsersWithMaxRole, + mockAddOnEligibleUsers, } from 'ee_jest/usage_quotas/code_suggestions/mock_data'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { ADD_ON_ERROR_DICTIONARY } from 'ee/usage_quotas/error_constants'; import { scrollToElement } from '~/lib/utils/common_utils'; import AddOnBulkActionConfirmationModal from 'ee/usage_quotas/code_suggestions/components/add_on_bulk_action_confirmation_modal.vue'; +import { ADD_ON_CODE_SUGGESTIONS } from 'ee/usage_quotas/code_suggestions/constants'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import getAddOnEligibleUsers from 'ee/usage_quotas/add_on/graphql/saas_add_on_eligible_users.query.graphql'; +import userAddOnAssignmentBulkCreateMutation from 'ee/usage_quotas/add_on/graphql/user_add_on_assignment_bulk_create.mutation.graphql'; + +Vue.use(VueApollo); jest.mock('~/lib/utils/common_utils'); +jest.mock('~/sentry/sentry_browser_wrapper'); describe('Add On Eligible User List', () => { let wrapper; const addOnPurchaseId = 'gid://gitlab/GitlabSubscriptions::AddOnPurchase/1'; + const codeSuggestionsAddOn = { addOnPurchase: { name: ADD_ON_CODE_SUGGESTIONS } }; + + const addOnPurchase = { + id: addOnPurchaseId, + name: ADD_ON_CODE_SUGGESTIONS, + purchasedQuantity: 3, + assignedQuantity: 2, + __typename: 'AddOnPurchase', + }; + + const addOnEligibleUsersQueryVariables = { + fullPath: 'namespace/full-path', + addOnType: 'CODE_SUGGESTIONS', + addOnPurchaseIds: [addOnPurchaseId], + }; + + const bulkAddOnAssignmentSuccess = { + clientMutationId: '1', + errors: [], + addOnPurchase, + users: { + nodes: [ + { + id: eligibleUsers[1].id, + addOnAssignments: { + nodes: codeSuggestionsAddOn, + __typename: 'UserAddOnAssignmentConnection', + }, + __typename: 'AddOnUser', + }, + { + id: eligibleUsers[2].id, + addOnAssignments: { + nodes: codeSuggestionsAddOn, + __typename: 'UserAddOnAssignmentConnection', + }, + __typename: 'AddOnUser', + }, + ], + }, + }; + + const bulkAssignAddOnHandler = jest.fn().mockResolvedValue({ + data: { userAddOnAssignmentBulkCreate: bulkAddOnAssignmentSuccess }, + }); + + const createMockApolloProvider = (addonAssignmentCreateHandler) => { + const mockApollo = createMockApollo([ + [userAddOnAssignmentBulkCreateMutation, addonAssignmentCreateHandler], + ]); + + // Needed to check if cache update is successful on successful mutation + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getAddOnEligibleUsers, + variables: addOnEligibleUsersQueryVariables, + data: mockAddOnEligibleUsers.data, + }); + + return mockApollo; + }; + + let mockApolloClient; + const createComponent = ({ enableAddOnUsersFiltering = false, isBulkAddOnAssignmentEnabled = false, + addonAssignmentBulkCreateHandler = bulkAssignAddOnHandler, mountFn = shallowMount, props = {}, slots = {}, } = {}) => { + mockApolloClient = createMockApolloProvider(addonAssignmentBulkCreateHandler); + wrapper = extendedWrapper( mountFn(AddOnEligibleUserList, { + apolloProvider: mockApolloClient, propsData: { addOnPurchaseId, users: eligibleUsers, @@ -58,6 +135,12 @@ describe('Add On Eligible User List', () => { return waitForPromises(); }; + const getAddOnAssignmentStatusForUserFromCache = (userId) => { + return mockApolloClient.clients.defaultClient.cache + .readQuery({ query: getAddOnEligibleUsers, variables: addOnEligibleUsersQueryVariables }) + .namespace.addOnEligibleUsers.nodes.find((node) => node.id === userId).addOnAssignments.nodes; + }; + const findTable = () => wrapper.findComponent(GlTable); const findTableKeys = () => findTable() @@ -546,6 +629,7 @@ describe('Add On Eligible User List', () => { expect(findConfirmationModal().props()).toEqual({ bulkAction: 'ASSIGN_BULK_ACTION', + isBulkActionInProgress: false, userCount: eligibleUsers.length, }); }); @@ -556,6 +640,7 @@ describe('Add On Eligible User List', () => { expect(findConfirmationModal().props()).toEqual({ bulkAction: 'UNASSIGN_BULK_ACTION', + isBulkActionInProgress: false, userCount: eligibleUsers.length, }); }); @@ -589,6 +674,7 @@ describe('Add On Eligible User List', () => { expect(findConfirmationModal().props()).toEqual({ bulkAction: 'ASSIGN_BULK_ACTION', + isBulkActionInProgress: false, userCount: 2, }); }); @@ -599,6 +685,7 @@ describe('Add On Eligible User List', () => { expect(findConfirmationModal().props()).toEqual({ bulkAction: 'UNASSIGN_BULK_ACTION', + isBulkActionInProgress: false, userCount: 2, }); }); @@ -627,6 +714,100 @@ describe('Add On Eligible User List', () => { }); }); + describe('bulk assignment confirmation', () => { + describe('successful assignment', () => { + beforeEach(async () => { + await createComponent({ mountFn: mount, isBulkAddOnAssignmentEnabled: true }); + + findSelectUserCheckboxAt(1).find('input').setChecked(true); + findSelectUserCheckboxAt(2).find('input').setChecked(true); + await nextTick(); + + findAssignSeatsButton().vm.$emit('click'); + await nextTick(); + + findConfirmationModal().vm.$emit('confirm-seat-assignment'); + await nextTick(); + }); + + it('calls bulk addon assigment mutation with appropriate params', () => { + expect(bulkAssignAddOnHandler).toHaveBeenCalledWith({ + addOnPurchaseId, + userIds: [eligibleUsers[1].id, eligibleUsers[2].id], + }); + }); + + it('shows a loading state', () => { + expect(findConfirmationModal().props().isBulkActionInProgress).toBe(true); + }); + + it('updates the cache with latest add-on assignment status', async () => { + await waitForPromises(); + + expect(getAddOnAssignmentStatusForUserFromCache(eligibleUsers[1].id)).toEqual( + codeSuggestionsAddOn, + ); + expect(getAddOnAssignmentStatusForUserFromCache(eligibleUsers[2].id)).toEqual( + codeSuggestionsAddOn, + ); + }); + + it('does not show the confirmation modal on successful API call', async () => { + await waitForPromises(); + + expect(findConfirmationModal().exists()).toBe(false); + }); + + it('unselects users on successful API call', async () => { + expect(findSelectedUsersSummary().exists()).toBe(true); + + await waitForPromises(); + + expect(findSelectedUsersSummary().exists()).toBe(false); + }); + }); + + describe('unsuccessful assignment', () => { + const error = new Error('An error'); + + beforeEach(async () => { + await createComponent({ + mountFn: mount, + isBulkAddOnAssignmentEnabled: true, + addonAssignmentBulkCreateHandler: jest.fn().mockRejectedValue(error), + }); + + findSelectUserCheckboxAt(1).find('input').setChecked(true); + findSelectUserCheckboxAt(2).find('input').setChecked(true); + await nextTick(); + + findAssignSeatsButton().vm.$emit('click'); + await nextTick(); + + findConfirmationModal().vm.$emit('confirm-seat-assignment'); + await nextTick(); + }); + + it('captures error on Sentry for generic errors', async () => { + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(error); + }); + + it('does not show the confirmation modal on unsuccessful API call', async () => { + await waitForPromises(); + + expect(findConfirmationModal().exists()).toBe(false); + }); + + it('retains user selection on unsuccessful API call', async () => { + await waitForPromises(); + + expect(findSelectedUsersSummary().text()).toMatchInterpolatedText('2 users selected'); + }); + }); + }); + describe('when paginating', () => { beforeEach(async () => { createComponent({