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 231f1577aefe0c2de73860fb33aa5378001d8c13..07569465a4688bb8eb01fe29fe7293d82fdcf01e 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 @@ -3,13 +3,15 @@ import { GlAvatarLabeled, GlAvatarLink, GlBadge, + GlFormCheckbox, GlSkeletonLoader, GlTable, GlTooltipDirective, GlKeysetPagination, } from '@gitlab/ui'; -import { pick } from 'lodash'; -import { s__ } from '~/locale'; +import { pick, escape } from 'lodash'; +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 { addOnEligibleUserListTableFields } from 'ee/usage_quotas/code_suggestions/constants'; @@ -21,6 +23,7 @@ export default { name: 'AddOnEligibleUserList', directives: { GlTooltip: GlTooltipDirective, + SafeHtml, }, components: { CodeSuggestionsAddonAssignment, @@ -28,11 +31,13 @@ export default { GlAvatarLabeled, GlAvatarLink, GlBadge, + GlFormCheckbox, GlKeysetPagination, GlSkeletonLoader, GlTable, }, mixins: [glFeatureFlagMixin()], + inject: { isBulkAddOnAssignmentEnabled: { default: false } }, props: { addOnPurchaseId: { type: String, @@ -62,6 +67,7 @@ export default { data() { return { addOnAssignmentError: undefined, + selectedUsers: [], }; }, addOnErrorDictionary: ADD_ON_ERROR_DICTIONARY, @@ -87,10 +93,17 @@ export default { return s__('Billing|No users to display.'); }, tableFieldsConfiguration() { + let fieldConfig = ['user', 'codeSuggestionsAddon', 'emailWide', 'lastActivityTimeWide']; + if (this.isFilteringEnabled && this.hasMaxRoleField) { - return ['user', 'codeSuggestionsAddon', 'email', 'maxRole', 'lastActivityTime']; + fieldConfig = ['user', 'codeSuggestionsAddon', 'email', 'maxRole', 'lastActivityTime']; + } + + if (this.isBulkAddOnAssignmentEnabled) { + fieldConfig = ['checkbox', ...fieldConfig]; } - return ['user', 'codeSuggestionsAddon', 'emailWide', 'lastActivityTimeWide']; + + return fieldConfig; }, tableFields() { return Object.values(pick(addOnEligibleUserListTableFields, this.tableFieldsConfiguration)); @@ -98,16 +111,42 @@ export default { tableItems() { return this.users.map((node) => ({ ...node, - username: `@${node?.username}`, + usernameWithHandle: `@${node?.username}`, addOnAssignments: node?.addOnAssignments?.nodes, })); }, + isSelectAllUsersChecked() { + return !this.isLoading && this.users.length === this.selectedUsers.length; + }, + isSelectAllUsersIndeterminate() { + return this.isAnyUserSelected && !this.isSelectAllUsersChecked; + }, + isAnyUserSelected() { + return Boolean(this.selectedUsers.length); + }, + pluralisedSelectedUsers() { + return sprintf( + n__( + 'Billing|%{value} user selected', + 'Billing|%{value} users selected', + this.selectedUsers.length, + ), + { value: `<strong>${escape(this.selectedUsers.length)}</strong>` }, + false, + ); + }, }, methods: { nextPage() { + // Retaining user selection on page navigation will be carried out in + // https://gitlab.com/gitlab-org/gitlab/-/issues/443401 + this.unselectAllUsers(); this.$emit('next', this.pageInfo.endCursor); }, prevPage() { + // Retaining user selection on page navigation will be carried out in + // https://gitlab.com/gitlab-org/gitlab/-/issues/443401 + this.unselectAllUsers(); this.$emit('prev', this.pageInfo.startCursor); }, handleAddOnAssignmentError(errorCode) { @@ -120,6 +159,26 @@ export default { scrollToTop() { scrollToElement(this.$el); }, + isUserSelected(item) { + return this.selectedUsers.includes(item.username); + }, + handleUserSelection(user, value) { + if (value) { + this.selectedUsers.push(user.username); + } else { + this.selectedUsers = this.selectedUsers.filter((username) => username !== user.username); + } + }, + handleSelectAllUsers(value) { + if (value) { + this.selectedUsers = this.users.map((user) => user.username); + } else { + this.unselectAllUsers(); + } + }, + unselectAllUsers() { + this.selectedUsers = []; + }, }, }; </script> @@ -136,6 +195,12 @@ export default { :dismissible="true" @dismiss="clearAddOnAssignmentError" /> + <div + v-if="isAnyUserSelected" + class="gl-display-flex gl-bg-gray-10 gl-p-5 gl-mt-5 gl-align-items-center gl-justify-content-space-between" + > + <span v-safe-html="pluralisedSelectedUsers" data-testid="selected-users-summary"></span> + </div> <gl-table :items="tableItems" :fields="tableFields" @@ -155,6 +220,24 @@ export default { </gl-skeleton-loader> </div> </template> + <template #head(checkbox)> + <gl-form-checkbox + v-if="isBulkAddOnAssignmentEnabled" + class="gl-min-h-5" + :checked="isSelectAllUsersChecked" + :indeterminate="isSelectAllUsersIndeterminate" + data-testid="select-all-users" + @change="handleSelectAllUsers" + /> + </template> + <template #cell(checkbox)="{ item }"> + <gl-form-checkbox + v-if="isBulkAddOnAssignmentEnabled" + class="gl-min-h-5" + :checked="isUserSelected(item)" + @change="handleUserSelection(item, $event)" + /> + </template> <template #cell(user)="{ item }"> <slot name="user-cell" :item="item"> <div class="gl-display-flex"> @@ -163,7 +246,7 @@ export default { :src="item.avatarUrl" :size="$options.avatarSize" :label="item.name" - :sub-label="item.username" + :sub-label="item.usernameWithHandle" /> </gl-avatar-link> </div> diff --git a/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/saas_add_on_eligible_user_list.vue b/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/saas_add_on_eligible_user_list.vue index e980a3b7918bea024795116352f925e0131446d6..6c0d4fb89a2e3a0bc3661a1a3940d3fa89c9669d 100644 --- a/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/saas_add_on_eligible_user_list.vue +++ b/ee/app/assets/javascripts/usage_quotas/code_suggestions/components/saas_add_on_eligible_user_list.vue @@ -221,7 +221,7 @@ export default { :src="item.avatarUrl" :size="$options.avatarSize" :label="item.name" - :sub-label="item.username" + :sub-label="item.usernameWithHandle" > <template #meta> <gl-badge v-if="userMembershipType(item)" size="sm" variant="muted"> diff --git a/ee/app/assets/javascripts/usage_quotas/code_suggestions/constants.js b/ee/app/assets/javascripts/usage_quotas/code_suggestions/constants.js index 20b5a53ef0ac4a5ee97161f77f7dcbfe694a2836..bf1086495fa310d9085e3866230013f5dde509c5 100644 --- a/ee/app/assets/javascripts/usage_quotas/code_suggestions/constants.js +++ b/ee/app/assets/javascripts/usage_quotas/code_suggestions/constants.js @@ -46,7 +46,14 @@ export const addOnEligibleUserListTableFields = { user: { key: 'user', label: __('User'), - thClass: `gl-pl-2! ${thWidthPercent(30)}`, + thClass: `gl-pl-2! ${thWidthPercent(25)}`, + tdClass: 'gl-vertical-align-middle! gl-pl-2!', + }, + checkbox: { + key: 'checkbox', + label: '', + headerTitle: __('Checkbox'), + thClass: __(`${thWidthPercent(5)} gl-pl-2!`), tdClass: 'gl-vertical-align-middle! gl-pl-2!', }, }; diff --git a/ee/app/assets/javascripts/usage_quotas/code_suggestions/tab_metadata.js b/ee/app/assets/javascripts/usage_quotas/code_suggestions/tab_metadata.js index fcb0c55e5b406e7248ec0598b74fbbd347114b15..2cc6e90e4039e7cc1581a655dc6f9029dd150c28 100644 --- a/ee/app/assets/javascripts/usage_quotas/code_suggestions/tab_metadata.js +++ b/ee/app/assets/javascripts/usage_quotas/code_suggestions/tab_metadata.js @@ -1,4 +1,5 @@ import { s__ } from '~/locale'; +import { parseBoolean } from '~/lib/utils/common_utils'; import apolloProvider from '../shared/provider'; import { CODE_SUGGESTIONS_TAB_METADATA_EL_SELECTOR } from '../constants'; import CodeSuggestionsUsage from './components/code_suggestions_usage.vue'; @@ -19,6 +20,7 @@ export const parseProvideData = (el) => { trackLabel, userName, addDuoProHref, + duoProBulkUserAssignmentAvailable, } = el.dataset; return { @@ -27,6 +29,7 @@ export const parseProvideData = (el) => { createHandRaiseLeadPath, addDuoProHref, isSaaS: true, + isBulkAddOnAssignmentEnabled: parseBoolean(duoProBulkUserAssignmentAvailable), buttonAttributes: buttonAttributes && { ...JSON.parse(buttonAttributes), variant: 'confirm' }, user: { namespaceId, diff --git a/ee/app/helpers/ee/groups_helper.rb b/ee/app/helpers/ee/groups_helper.rb index ceee7ad4fa0d659751609cf6641b61f4b4807fbc..471a2ec7bed3280fea365833102f15819d6f34b6 100644 --- a/ee/app/helpers/ee/groups_helper.rb +++ b/ee/app/helpers/ee/groups_helper.rb @@ -94,7 +94,12 @@ def group_seats_usage_quota_app_data(group) end def code_suggestions_usage_app_data(group) - data = { full_path: group.full_path, group_id: group.id, add_duo_pro_href: duo_pro_url(group) } + data = { + full_path: group.full_path, + group_id: group.id, + add_duo_pro_href: duo_pro_url(group), + duo_pro_bulk_user_assignment_available: duo_pro_bulk_user_assignment_available?(group).to_s + } return data unless ::Feature.enabled?(:cs_connect_with_sales, group) 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 f6e6539af196ac403d298cb8e6fa7a09621bf2ab..898d3af094f3312d6ef3ae08f5116ab1f2a49230 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 @@ -4,6 +4,7 @@ import { GlSkeletonLoader, GlKeysetPagination, GlTable, + GlFormCheckbox, } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; @@ -29,6 +30,7 @@ describe('Add On Eligible User List', () => { const createComponent = ({ enableAddOnUsersFiltering = false, + isBulkAddOnAssignmentEnabled = false, mountFn = shallowMount, props = {}, slots = {}, @@ -46,6 +48,7 @@ describe('Add On Eligible User List', () => { glFeatures: { enableAddOnUsersFiltering, }, + isBulkAddOnAssignmentEnabled, }, slots, }), @@ -98,6 +101,10 @@ describe('Add On Eligible User List', () => { const findSerializedTable = (tableWrapper) => { return tableWrapper.findAll('tbody tr').wrappers.map(serializeTableRow); }; + const findSelectAllUsersCheckbox = () => wrapper.findByTestId('select-all-users'); + const findSelectedUsersSummary = () => wrapper.findByTestId('selected-users-summary'); + const findSelectUserCheckboxAt = (index) => + wrapper.find('tbody').findAllComponents(GlFormCheckbox).at(index); describe('renders table', () => { beforeEach(() => { @@ -126,6 +133,19 @@ describe('Add On Eligible User List', () => { avatarLink: { alt: 'User Two', href: 'path/to/usertwo' }, }, }, + { + email: 'Private', + lastActivityOn: '2023-03-19', + tooltip: 'An email address is only visible for users with public emails.', + user: { + avatarLabeled: { + size: '32', + src: 'path/to/img_userthree', + text: 'User Three @userthree', + }, + avatarLink: { alt: 'User Three', href: 'path/to/userthree' }, + }, + }, ]; const actualUserListData = findSerializedTable(findTable()); @@ -204,6 +224,20 @@ describe('Add On Eligible User List', () => { avatarLink: { alt: 'User Two', href: 'path/to/usertwo' }, }, }, + { + email: 'Private', + lastActivityOn: '2023-03-19', + maxRole: 'developer', + tooltip: 'An email address is only visible for users with public emails.', + user: { + avatarLabeled: { + size: '32', + src: 'path/to/img_userthree', + text: 'User Three @userthree', + }, + avatarLink: { alt: 'User Three', href: 'path/to/userthree' }, + }, + }, ]; const actualUserListData = findSerializedTable(findTable()); @@ -212,6 +246,63 @@ describe('Add On Eligible User List', () => { }); }); + describe('with isBulkAddOnAssignmentEnabled enabled', () => { + beforeEach(() => { + return createComponent({ isBulkAddOnAssignmentEnabled: true }); + }); + + it('passes the correct fields configuration', () => { + expect(findTableKeys()).toEqual([ + 'checkbox', + 'user', + 'codeSuggestionsAddon', + 'email', + 'lastActivityTime', + ]); + }); + }); + + describe('with enableAddOnUsersFiltering and isBulkAddOnAssignmentEnabled enabled', () => { + beforeEach(() => { + return createComponent({ + enableAddOnUsersFiltering: true, + isBulkAddOnAssignmentEnabled: true, + }); + }); + + it('passes the correct fields configuration', () => { + expect(findTableKeys()).toEqual([ + 'checkbox', + 'user', + 'codeSuggestionsAddon', + 'email', + 'lastActivityTime', + ]); + }); + + describe('when eligible users have maxRole field', () => { + beforeEach(() => { + return createComponent({ + mountFn: mount, + enableAddOnUsersFiltering: true, + isBulkAddOnAssignmentEnabled: true, + props: { users: eligibleUsersWithMaxRole }, + }); + }); + + it('passes the correct fields configuration', () => { + expect(findTableKeys()).toEqual([ + 'checkbox', + 'user', + 'codeSuggestionsAddon', + 'email', + 'maxRole', + 'lastActivityTime', + ]); + }); + }); + }); + describe('code suggestions addon', () => { describe('renders', () => { it('shows code suggestions addon field', () => { @@ -226,6 +317,11 @@ describe('Add On Eligible User List', () => { addOnAssignments: [], addOnPurchaseId, }, + { + userId: 'gid://gitlab/User/3', + addOnAssignments: [], + addOnPurchaseId, + }, ]; const actualProps = findAllCodeSuggestionsAddonComponents().wrappers.map((item) => ({ userId: item.props('userId'), @@ -417,4 +513,76 @@ describe('Add On Eligible User List', () => { expect(wrapper.find('.user-cell').text()).toBe('A user cell content'); }); }); + + describe('bulk assignment', () => { + describe('when using select all option', () => { + beforeEach(async () => { + await createComponent({ mountFn: mount, isBulkAddOnAssignmentEnabled: true }); + + findSelectAllUsersCheckbox().find('input').setChecked(true); + await nextTick(); + }); + + it('shows a summary of all users selected when select all users checkbox is clicked', () => { + expect(findSelectedUsersSummary().text()).toMatchInterpolatedText( + `${eligibleUsers.length} users selected`, + ); + }); + + it('does not show a summary of users when unselect all users checkbox is clicked', async () => { + findSelectAllUsersCheckbox().find('input').setChecked(false); + await nextTick(); + + expect(findSelectedUsersSummary().exists()).toBe(false); + }); + }); + + describe('when using individual checkboxes', () => { + it('shows a summary of only the selected users', async () => { + await createComponent({ mountFn: mount, isBulkAddOnAssignmentEnabled: true }); + + findSelectUserCheckboxAt(1).find('input').setChecked(true); + findSelectUserCheckboxAt(2).find('input').setChecked(true); + await nextTick(); + + expect(findSelectedUsersSummary().text()).toMatchInterpolatedText('2 users selected'); + }); + + it('pluralises user count appropriately', async () => { + await createComponent({ mountFn: mount, isBulkAddOnAssignmentEnabled: true }); + + findSelectUserCheckboxAt(1).find('input').setChecked(true); + await nextTick(); + + expect(findSelectedUsersSummary().text()).toMatchInterpolatedText('1 user selected'); + }); + }); + + describe('when paginating', () => { + beforeEach(async () => { + createComponent({ + mountFn: mount, + isBulkAddOnAssignmentEnabled: true, + props: { pageInfo: pageInfoWithMorePages }, + }); + + findSelectAllUsersCheckbox().find('input').setChecked(true); + await nextTick(); + }); + + it('resets user selection on navigating to next page', async () => { + findPagination().vm.$emit('next'); + await waitForPromises(); + + expect(findSelectedUsersSummary().exists()).toBe(false); + }); + + it('resets user selection on navigating to previous page', async () => { + findPagination().vm.$emit('prev'); + await waitForPromises(); + + expect(findSelectedUsersSummary().exists()).toBe(false); + }); + }); + }); }); diff --git a/ee/spec/frontend/usage_quotas/code_suggestions/mock_data.js b/ee/spec/frontend/usage_quotas/code_suggestions/mock_data.js index 6fa10bdf526a2a836b4d71748521b6316daaf2b1..06147f33bbc1cc4163f967a676824c98f7f7302c 100644 --- a/ee/spec/frontend/usage_quotas/code_suggestions/mock_data.js +++ b/ee/spec/frontend/usage_quotas/code_suggestions/mock_data.js @@ -59,6 +59,19 @@ export const mockSMUserWithNoAddOnAssignment = { __typename: 'AddOnUser', }; +export const mockAnotherSMUserWithNoAddOnAssignment = { + id: 'gid://gitlab/User/3', + username: 'userthree', + name: 'User Three', + publicEmail: null, + avatarUrl: 'path/to/img_userthree', + webUrl: 'path/to/userthree', + lastActivityOn: '2023-03-19', + maxRole: null, + addOnAssignments: { nodes: [], __typename: 'UserAddOnAssignmentConnection' }, + __typename: 'AddOnUser', +}; + export const mockUserWithAddOnAssignment = { ...mockSMUserWithAddOnAssignment, membershipType: null, @@ -69,7 +82,16 @@ export const mockUserWithNoAddOnAssignment = { membershipType: null, }; -export const eligibleUsers = [mockUserWithAddOnAssignment, mockUserWithNoAddOnAssignment]; +export const mockAnotherUserWithNoAddOnAssignment = { + ...mockAnotherSMUserWithNoAddOnAssignment, + membershipType: null, +}; + +export const eligibleUsers = [ + mockUserWithAddOnAssignment, + mockUserWithNoAddOnAssignment, + mockAnotherUserWithNoAddOnAssignment, +]; export const eligibleSMUsers = [mockSMUserWithAddOnAssignment, mockSMUserWithNoAddOnAssignment]; export const eligibleUsersWithMaxRole = eligibleUsers.map((user) => ({ ...user, diff --git a/ee/spec/helpers/ee/groups_helper_spec.rb b/ee/spec/helpers/ee/groups_helper_spec.rb index 6e3a8563959e2750d53d7b1e609f74aec70c602d..0a74b3990d08a9fdfe75a82c37dca402f85c9842 100644 --- a/ee/spec/helpers/ee/groups_helper_spec.rb +++ b/ee/spec/helpers/ee/groups_helper_spec.rb @@ -368,14 +368,41 @@ stub_feature_flags(cs_connect_with_sales: false) end - it { is_expected.to eql(data) } + context 'when duo pro bulk assignment is available' do + before do + allow(helper).to receive(:duo_pro_bulk_user_assignment_available?).and_return(true) + end + + it { is_expected.to eql(data.merge(duo_pro_bulk_user_assignment_available: 'true')) } + end + + context 'when duo pro bulk assignment is not available' do + before do + allow(helper).to receive(:duo_pro_bulk_user_assignment_available?).and_return(false) + end + + it { is_expected.to eql(data.merge(duo_pro_bulk_user_assignment_available: 'false')) } + end end context 'when cs_connect_with_sales ff is enabled' do - it 'contains data for hand raise lead button' do - hand_raise_lead_button_data = helper.code_suggestions_hand_raise_props(group) + let(:code_suggestions_hand_raise_props) { helper.code_suggestions_hand_raise_props(group) } + let(:expected_data) { data.merge(code_suggestions_hand_raise_props) } + + context 'when duo pro bulk assignment is available' do + before do + allow(helper).to receive(:duo_pro_bulk_user_assignment_available?).and_return(true) + end + + it { is_expected.to eql(expected_data.merge(duo_pro_bulk_user_assignment_available: 'true')) } + end + + context 'when duo pro bulk assignment is not available' do + before do + allow(helper).to receive(:duo_pro_bulk_user_assignment_available?).and_return(false) + end - expect(subject).to eq(data.merge(hand_raise_lead_button_data)) + it { is_expected.to eql(expected_data.merge(duo_pro_bulk_user_assignment_available: 'false')) } end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2a23e8696fab7208d086b01a41513b558c678c6f..032e5fd7f3026f7f4ec7eb5f257e407d531fa1cf 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8161,6 +8161,11 @@ msgstr "" msgid "Billing|%{user} was successfully approved" msgstr "" +msgid "Billing|%{value} user selected" +msgid_plural "Billing|%{value} users selected" +msgstr[0] "" +msgstr[1] "" + msgid "Billing|Add seats" msgstr "" @@ -10154,6 +10159,9 @@ msgstr "" msgid "Check your sign-up restrictions" msgstr "" +msgid "Checkbox" +msgstr "" + msgid "Checkin reminder has been enabled." msgstr ""