Commit 0d26cab7 authored by Diana Zubova's avatar Diana Zubova Committed by Natalia Tepluhina

Add overage confirmation modal

Show a modal window to confirm invite user actions
if users addition will cause overage

EE: true
Changelog: added
parent a8a61c9d
......@@ -8,6 +8,7 @@ import {
GlFormCheckboxGroup,
} from '@gitlab/ui';
import { partition, isString, uniqueId } from 'lodash';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
......@@ -21,7 +22,6 @@ import {
import eventHub from '../event_hub';
import { responseMessageFromSuccess } from '../utils/response_message_parser';
import ModalConfetti from './confetti.vue';
import InviteModalBase from './invite_modal_base.vue';
import MembersTokenSelect from './members_token_select.vue';
export default {
......
......@@ -86,3 +86,5 @@ module InviteMembersHelper
projects.map { |project| { id: project.id, title: project.title } }
end
end
InviteMembersHelper.prepend_mod_with('InviteMembersHelper')
---
name: overage_members_modal
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79644/
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/350265
milestone: '14.8'
type: development
group: group::purchase
default_enabled: false
<script>
import {
GlFormGroup,
GlModal,
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlLink,
GlSprintf,
GlButton,
GlFormInput,
} from '@gitlab/ui';
import { unescape } from 'lodash';
import { sanitize } from '~/lib/dompurify';
import { sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
ACCESS_LEVEL,
ACCESS_EXPIRE_DATE,
INVALID_FEEDBACK_MESSAGE_DEFAULT,
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
HEADER_CLOSE_LABEL,
} from '~/invite_members/constants';
import { responseMessageFromError } from '~/invite_members/utils/response_message_parser';
import {
OVERAGE_MODAL_LINK,
OVERAGE_MODAL_TITLE,
OVERAGE_MODAL_BACK_BUTTON,
OVERAGE_MODAL_CONTINUE_BUTTON,
OVERAGE_MODAL_LINK_TEXT,
overageModalInfoText,
overageModalInfoWarning,
} from '../constants';
export default {
components: {
GlFormGroup,
GlDatepicker,
GlLink,
GlModal,
GlDropdown,
GlDropdownItem,
GlSprintf,
GlButton,
GlFormInput,
},
mixins: [glFeatureFlagsMixin()],
inheritAttrs: false,
props: {
modalTitle: {
type: String,
required: true,
},
modalId: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
accessLevels: {
type: Object,
required: true,
},
defaultAccessLevel: {
type: Number,
required: true,
},
helpLink: {
type: String,
required: true,
},
labelIntroText: {
type: String,
required: true,
},
labelSearchField: {
type: String,
required: true,
},
formGroupDescription: {
type: String,
required: false,
default: '',
},
submitDisabled: {
type: Boolean,
required: false,
default: false,
},
subscriptionSeats: {
type: Number,
required: false,
default: 10, // TODO: pass data from backend https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78287
},
},
data() {
// Be sure to check out reset!
return {
invalidFeedbackMessage: '',
selectedAccessLevel: this.defaultAccessLevel,
selectedDate: undefined,
isLoading: false,
minDate: new Date(),
hasOverage: false,
totalUserCount: null,
};
},
computed: {
introText() {
return sprintf(this.labelIntroText, { name: this.name });
},
validationState() {
return this.invalidFeedbackMessage ? false : null;
},
selectLabelId() {
return `${this.modalId}_select`;
},
selectedRoleName() {
return Object.keys(this.accessLevels).find(
(key) => this.accessLevels[key] === Number(this.selectedAccessLevel),
);
},
showOverageModal() {
return this.hasOverage && this.enabledOverageCheck;
},
enabledOverageCheck() {
return this.glFeatures.overageMembersModal;
},
modalInfo() {
const infoText = this.$options.i18n.infoText(this.subscriptionSeats);
const infoWarning = this.$options.i18n.infoWarning(this.totalUserCount, this.name);
return `${infoText} ${infoWarning}`;
},
modalTitleLabel() {
return this.showOverageModal ? this.$options.i18n.OVERAGE_MODAL_TITLE : this.modalTitle;
},
},
watch: {
selectedAccessLevel: {
immediate: true,
handler(val) {
this.$emit('access-level', val);
},
},
},
methods: {
showInvalidFeedbackMessage(response) {
const message = this.unescapeMsg(responseMessageFromError(response));
this.invalidFeedbackMessage = message || INVALID_FEEDBACK_MESSAGE_DEFAULT;
},
reset() {
// This component isn't necessarily disposed,
// so we might need to reset it's state.
this.isLoading = false;
this.invalidFeedbackMessage = '';
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
// don't reopen the overage modal
this.hasOverage = false;
this.$emit('reset');
},
closeModal() {
this.reset();
this.$refs.modal.hide();
},
clearValidation() {
this.invalidFeedbackMessage = '';
},
changeSelectedItem(item) {
this.selectedAccessLevel = item;
},
submit() {
this.isLoading = true;
this.invalidFeedbackMessage = '';
this.$emit('submit', {
onSuccess: () => {
this.isLoading = false;
},
onError: (...args) => {
this.isLoading = false;
this.showInvalidFeedbackMessage(...args);
},
data: {
accessLevel: this.selectedAccessLevel,
expiresAt: this.selectedDate,
},
});
},
unescapeMsg(message) {
return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
},
checkOverage() {
// add a more complex check in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78287
// totalUserCount should be calculated there
if (this.enabledOverageCheck) {
this.totalUserCount = 1;
this.hasOverage = true;
} else {
this.submit();
}
},
handleBack() {
this.hasOverage = false;
},
},
i18n: {
HEADER_CLOSE_LABEL,
ACCESS_EXPIRE_DATE,
ACCESS_LEVEL,
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
OVERAGE_MODAL_TITLE,
OVERAGE_MODAL_LINK,
OVERAGE_MODAL_BACK_BUTTON,
OVERAGE_MODAL_CONTINUE_BUTTON,
OVERAGE_MODAL_LINK_TEXT,
infoText: overageModalInfoText,
infoWarning: overageModalInfoWarning,
},
};
</script>
<template>
<gl-modal
ref="modal"
:modal-id="modalId"
data-qa-selector="invite_members_modal_content"
data-testid="invite-modal"
size="sm"
:title="modalTitleLabel"
:header-close-label="$options.i18n.HEADER_CLOSE_LABEL"
@hidden="reset"
@close="reset"
@hide="reset"
>
<div v-show="!showOverageModal">
<div class="gl-display-flex" data-testid="modal-base-intro-text">
<slot name="intro-text-before"></slot>
<p>
<gl-sprintf :message="introText">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<slot name="intro-text-after"></slot>
</div>
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
:description="formGroupDescription"
data-testid="members-form-group"
>
<label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label>
<slot
name="select"
v-bind="{ clearValidation, validationState, labelId: selectLabelId }"
></slot>
</gl-form-group>
<label class="gl-font-weight-bold">{{ $options.i18n.ACCESS_LEVEL }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown
class="gl-shadow-none gl-w-full"
data-qa-selector="access_level_dropdown"
v-bind="$attrs"
:text="selectedRoleName"
>
<template v-for="(key, item) in accessLevels">
<gl-dropdown-item
:key="key"
active-class="is-active"
is-check-item
:is-checked="key === selectedAccessLevel"
@click="changeSelectedItem(key)"
>
<div>{{ item }}</div>
</gl-dropdown-item>
</template>
</gl-dropdown>
</div>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-sprintf :message="$options.i18n.READ_MORE_TEXT">
<template #link="{ content }">
<gl-link :href="helpLink" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
<label class="gl-mt-5 gl-display-block" for="expires_at">{{
$options.i18n.ACCESS_EXPIRE_DATE
}}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block">
<gl-datepicker
v-model="selectedDate"
class="gl-display-inline!"
:min-date="minDate"
:target="null"
>
<template #default="{ formattedDate }">
<gl-form-input
class="gl-w-full"
:value="formattedDate"
:placeholder="__(`YYYY-MM-DD`)"
/>
</template>
</gl-datepicker>
</div>
<slot name="form-after"></slot>
</div>
<div v-if="showOverageModal">
{{ modalInfo }}
<gl-link :href="$options.i18n.OVERAGE_MODAL_LINK" target="_blank">{{
$options.i18n.OVERAGE_MODAL_LINK_TEXT
}}</gl-link>
</div>
<template #modal-footer>
<template v-if="!showOverageModal">
<gl-button data-testid="cancel-button" @click="closeModal">
{{ $options.i18n.CANCEL_BUTTON_TEXT }}
</gl-button>
<gl-button
:disabled="submitDisabled"
:loading="isLoading"
variant="success"
data-qa-selector="invite_button"
data-testid="invite-button"
@click="checkOverage"
>
{{ $options.i18n.INVITE_BUTTON_TEXT }}
</gl-button>
</template>
<template v-else>
<gl-button data-testid="overage-back-button" @click="handleBack">
{{ $options.i18n.OVERAGE_MODAL_BACK_BUTTON }}
</gl-button>
<gl-button
:disabled="submitDisabled"
:loading="isLoading"
variant="success"
data-qa-selector="invite_with_overage_button"
data-testid="invite-with-overage-button"
@click="submit"
>
{{ $options.i18n.OVERAGE_MODAL_CONTINUE_BUTTON }}
</gl-button>
</template>
</template>
</gl-modal>
</template>
import { __, s__, n__, sprintf } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
export const OVERAGE_MODAL_LINK = helpPagePath('subscriptions/quarterly_reconciliation');
export const OVERAGE_MODAL_TITLE = s__('MembersOverage|You are about to incur additional charges');
export const OVERAGE_MODAL_BACK_BUTTON = __('Back');
export const OVERAGE_MODAL_CONTINUE_BUTTON = __('Continue');
export const OVERAGE_MODAL_LINK_TEXT = __('Learn more.');
export const overageModalInfoText = (quantity) =>
n__(
'MembersOverage|Your subscription includes %d seat.',
'MembersOverage|Your subscription includes %d seats.',
quantity,
);
export const overageModalInfoWarning = (quantity, groupName) =>
sprintf(
n__(
'MembersOverage|If you continue, the %{groupName} group will have %{quantity} seat in use and will be billed for the overage.',
'MembersOverage|If you continue, the %{groupName} group will have %{quantity} seats in use and will be billed for the overage.',
quantity,
),
{
groupName,
quantity,
},
);
......@@ -19,8 +19,11 @@ module EE
# This before_action needs to be redefined so we can use the new values
# from `admin_not_required_endpoints`.
before_action :authorize_admin_group_member!, except: admin_not_required_endpoints
before_action :authorize_update_group_member!, only: [:update, :override]
before_action do
push_frontend_feature_flag(:overage_members_modal, @group, default_enabled: :yaml) if ::Gitlab::CurrentSettings.should_check_namespace_plan?
end
end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
......
......@@ -2,6 +2,9 @@
module EE
module InviteMembersHelper
extend ::Gitlab::Utils::Override
override :users_filter_data
def users_filter_data(group)
root_group = group&.root_ancestor
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Groups > Members > Manage members' do
include Spec::Support::Helpers::Features::MembersHelpers
include Spec::Support::Helpers::Features::InviteMembersModalHelper
include Spec::Support::Helpers::ModalHelpers
let_it_be(:user1) { create(:user, name: 'John Doe') }
let_it_be(:user2) { create(:user, name: 'Mary Jane') }
let_it_be(:group) { create(:group) }
before do
sign_in(user1)
end
context 'when adding a member to a group triggers an overage', :js, :aggregate_failures do
before do
allow(Gitlab).to receive(:com?) { true }
create(:gitlab_subscription, namespace: group)
stub_application_setting(check_namespace_plan: true)
end
it 'show an overage modal and invites a member to a group if confirmed' do
group.add_owner(user1)
visit group_group_members_path(group)
click_on 'Invite members'
page.within '[data-testid="invite-modal"]' do
find('[data-testid="members-token-select-input"]').set(user2.name)
wait_for_requests
click_button user2.name
choose_options('Developer', nil)
click_button 'Invite'
expect(page).to have_content("Your subscription includes 10 seats. If you continue, the #{group.name} group will have 1 seat in use and will be billed for the overage. Learn more.")
click_button 'Continue'
page.refresh
end
page.within(second_row) do
expect(page).to have_content(user2.name)
expect(page).to have_button('Developer')
end
end
it 'show an overage modal and get back to initial modal if not confirmed' do
group.add_owner(user1)
visit group_group_members_path(group)
click_on 'Invite members'
page.within '[data-testid="invite-modal"]' do
find('[data-testid="members-token-select-input"]').set(user2.name)
wait_for_requests
click_button user2.name
choose_options('Developer', nil)
click_button 'Invite'
expect(page).to have_content("Your subscription includes 10 seats. If you continue, the #{group.name} group will have 1 seat in use and will be billed for the overage. Learn more.")
click_button 'Back'
end
expect(page).to have_content("You're inviting members to the #{group.name} group.")
click_button 'Cancel'
expect(page).not_to have_content(user2.name)
expect(page).not_to have_button('Developer')
end
end
end
import {
GlDropdown,
GlDropdownItem,
GlDatepicker,
GlFormGroup,
GlSprintf,
GlLink,
GlModal,
} from '@gitlab/ui';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import { CANCEL_BUTTON_TEXT, INVITE_BUTTON_TEXT } from '~/invite_members/constants';
import {
OVERAGE_MODAL_TITLE,
OVERAGE_MODAL_CONTINUE_BUTTON,
OVERAGE_MODAL_BACK_BUTTON,
} from 'ee/invite_members/constants';
import waitForPromises from 'helpers/wait_for_promises';
import { propsData } from 'jest/invite_members/mock_data/modal_base';
describe('InviteModalBase', () => {
let wrapper;
const createComponent = (data = {}, props = {}, glFeatures = {}) => {
wrapper = shallowMountExtended(InviteModalBase, {
propsData: {
...propsData,
...props,
},
provide: {
...glFeatures,
},
data() {
return data;
},
stubs: {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
}),
GlDropdown: true,
GlDropdownItem: true,
GlSprintf,
GlFormGroup: stubComponent(GlFormGroup, {
props: ['state', 'invalidFeedback', 'description'],
}),
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findLink = () => wrapper.findComponent(GlLink);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findByTestId('invite-button');
const findBackButton = () => wrapper.findByTestId('overage-back-button');
const findOverageInviteButton = () => wrapper.findByTestId('invite-with-overage-button');
const clickInviteButton = () => findInviteButton().vm.$emit('click');
const clickBackButton = () => findBackButton().vm.$emit('click');
describe('rendering the modal', () => {
beforeEach(() => {
createComponent();
});
it('renders the modal with the correct title', () => {
expect(wrapper.findComponent(GlModal).props('title')).toBe(propsData.modalTitle);
});
it('displays the introText', () => {
expect(findIntroText()).toBe(propsData.labelIntroText);
});
it('renders the Cancel button text correctly', () => {
expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT);
});
it('renders the Invite button text correctly', () => {
expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT);
});
it('renders the Invite button modal without isLoading', () => {
expect(findInviteButton().props('loading')).toBe(false);
});
describe('rendering the access levels dropdown', () => {
it('sets the default dropdown text to the default access level name', () => {
expect(findDropdown().attributes('text')).toBe('Guest');
});
it('renders dropdown items for each accessLevel', () => {
expect(findDropdownItems()).toHaveLength(5);
});
});
describe('rendering the help link', () => {
it('renders the correct link', () => {
expect(findLink().attributes('href')).toBe(propsData.helpLink);
});
});
describe('rendering the access expiration date field', () => {
it('renders the datepicker', () => {
expect(findDatepicker().exists()).toBe(true);
});
});
});
describe('displays overage modal', () => {
beforeEach(async () => {
createComponent({}, {}, { glFeatures: { overageMembersModal: true } });
clickInviteButton();
await waitForPromises();
});
it('renders the modal with the correct title', () => {
expect(wrapper.findComponent(GlModal).props('title')).toBe(OVERAGE_MODAL_TITLE);
});
it('renders the Back button text correctly', () => {
expect(findBackButton().text()).toBe(OVERAGE_MODAL_BACK_BUTTON);
});
it('renders the Continue button text correctly', () => {
expect(findOverageInviteButton().text()).toBe(OVERAGE_MODAL_CONTINUE_BUTTON);
});
it('shows the info text', () => {
expect(wrapper.findComponent(GlModal).text()).toContain(
'If you continue, the _name_ group will have 1 seat in use and will be billed for the overage.',
);
});
it('switches back to the intial modal', async () => {
clickBackButton();
await waitForPromises();
expect(wrapper.findComponent(GlModal).props('title')).toBe('_modal_title_');
});
});
});
......@@ -22613,6 +22613,19 @@ msgstr ""
msgid "Members of a group may only view projects they have permission to access"
msgstr ""
msgid "MembersOverage|If you continue, the %{groupName} group will have %{quantity} seat in use and will be billed for the overage."
msgid_plural "MembersOverage|If you continue, the %{groupName} group will have %{quantity} seats in use and will be billed for the overage."
msgstr[0] ""
msgstr[1] ""
msgid "MembersOverage|You are about to incur additional charges"
msgstr ""
msgid "MembersOverage|Your subscription includes %d seat."
msgid_plural "MembersOverage|Your subscription includes %d seats."
msgstr[0] ""
msgstr[1] ""
msgid "Membership"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment