Commit a72d6d97 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '350800-split-invite-member-and-group' into 'master'

Split invite groups into own modal

See merge request gitlab-org/gitlab!79247
parents b1ccb7f4 0c037bb7
...@@ -21,7 +21,7 @@ export default { ...@@ -21,7 +21,7 @@ export default {
}, },
methods: { methods: {
openModal() { openModal() {
eventHub.$emit('openModal', { inviteeType: 'group' }); eventHub.$emit('openGroupModal');
}, },
}, },
}; };
......
<script>
import { uniqueId } from 'lodash';
import Api from '~/api';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants';
import eventHub from '../event_hub';
import GroupSelect from './group_select.vue';
import InviteModalBase from './invite_modal_base.vue';
export default {
name: 'InviteMembersModal',
components: {
GroupSelect,
InviteModalBase,
},
props: {
id: {
type: String,
required: true,
},
isProject: {
type: Boolean,
required: true,
},
name: {
type: String,
required: true,
},
accessLevels: {
type: Object,
required: true,
},
defaultAccessLevel: {
type: Number,
required: true,
},
helpLink: {
type: String,
required: true,
},
groupSelectFilter: {
type: String,
required: false,
default: GROUP_FILTERS.ALL,
},
groupSelectParentId: {
type: Number,
required: false,
default: null,
},
invalidGroups: {
type: Array,
required: true,
},
},
data() {
return {
modalId: uniqueId('invite-groups-modal-'),
groupToBeSharedWith: {},
};
},
computed: {
labelIntroText() {
return this.$options.labels[this.inviteTo].introText;
},
inviteTo() {
return this.isProject ? 'toProject' : 'toGroup';
},
toastOptions() {
return {
onComplete: () => {
this.groupToBeSharedWith = {};
},
};
},
inviteDisabled() {
return Object.keys(this.groupToBeSharedWith).length === 0;
},
},
mounted() {
eventHub.$on('openGroupModal', () => {
this.openModal();
});
},
methods: {
openModal() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
sendInvite({ onError, onSuccess, data: { accessLevel, expiresAt } }) {
const apiShareWithGroup = this.isProject
? Api.projectShareWithGroup.bind(Api)
: Api.groupShareWithGroup.bind(Api);
apiShareWithGroup(this.id, {
format: 'json',
group_id: this.groupToBeSharedWith.id,
group_access: accessLevel,
expires_at: expiresAt,
})
.then(() => {
onSuccess();
this.showSuccessMessage();
})
.catch(onError);
},
resetFields() {
this.groupToBeSharedWith = {};
},
showSuccessMessage() {
this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions);
this.closeModal();
},
},
labels: GROUP_MODAL_LABELS,
};
</script>
<template>
<invite-modal-base
:modal-id="modalId"
:modal-title="$options.labels.title"
:name="name"
:access-levels="accessLevels"
:default-access-level="defaultAccessLevel"
:help-link="helpLink"
v-bind="$attrs"
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
:submit-disabled="inviteDisabled"
@reset="resetFields"
@submit="sendInvite"
>
<template #select="{ clearValidation }">
<group-select
v-model="groupToBeSharedWith"
:access-levels="accessLevels"
:groups-filter="groupSelectFilter"
:parent-group-id="groupSelectParentId"
:invalid-groups="invalidGroups"
@input="clearValidation"
/>
</template>
</invite-modal-base>
</template>
<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 {
ACCESS_LEVEL,
ACCESS_EXPIRE_DATE,
INVALID_FEEDBACK_MESSAGE_DEFAULT,
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
HEADER_CLOSE_LABEL,
} from '../constants';
import { responseMessageFromError } from '../utils/response_message_parser';
export default {
components: {
GlFormGroup,
GlDatepicker,
GlLink,
GlModal,
GlDropdown,
GlDropdownItem,
GlSprintf,
GlButton,
GlFormInput,
},
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,
},
},
data() {
// Be sure to check out reset!
return {
invalidFeedbackMessage: '',
selectedAccessLevel: this.defaultAccessLevel,
selectedDate: undefined,
isLoading: false,
minDate: new Date(),
};
},
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),
);
},
},
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;
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: [] }));
},
},
HEADER_CLOSE_LABEL,
ACCESS_EXPIRE_DATE,
ACCESS_LEVEL,
READ_MORE_TEXT,
INVITE_BUTTON_TEXT,
CANCEL_BUTTON_TEXT,
};
</script>
<template>
<gl-modal
ref="modal"
:modal-id="modalId"
data-qa-selector="invite_members_modal_content"
data-testid="invite-modal"
size="sm"
:title="modalTitle"
:header-close-label="$options.HEADER_CLOSE_LABEL"
@hidden="reset"
@close="reset"
@hide="reset"
>
<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.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.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.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>
<template #modal-footer>
<gl-button data-testid="cancel-button" @click="closeModal">
{{ $options.CANCEL_BUTTON_TEXT }}
</gl-button>
<gl-button
:disabled="submitDisabled"
:loading="isLoading"
variant="success"
data-qa-selector="invite_button"
data-testid="invite-button"
@click="submit"
>
{{ $options.INVITE_BUTTON_TEXT }}
</gl-button>
</template>
</gl-modal>
</template>
...@@ -72,67 +72,52 @@ export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite'); ...@@ -72,67 +72,52 @@ export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite');
export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel');
export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members');
export const MODAL_LABELS = { export const MEMBER_MODAL_LABELS = {
members: { modal: {
modal: { default: {
default: { title: MEMBERS_MODAL_DEFAULT_TITLE,
title: MEMBERS_MODAL_DEFAULT_TITLE,
},
celebrate: {
title: MEMBERS_MODAL_CELEBRATE_TITLE,
intro: MEMBERS_MODAL_CELEBRATE_INTRO,
},
}, },
toGroup: { celebrate: {
default: { title: MEMBERS_MODAL_CELEBRATE_TITLE,
introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT, intro: MEMBERS_MODAL_CELEBRATE_INTRO,
},
},
toProject: {
default: {
introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT,
},
celebrate: {
introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
},
},
searchField: MEMBERS_SEARCH_FIELD,
placeHolder: MEMBERS_PLACEHOLDER,
tasksToBeDone: {
title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
},
tasksProject: {
title: MEMBERS_TASKS_PROJECTS_TITLE,
}, },
}, },
group: { toGroup: {
modal: { default: {
default: { introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT,
title: GROUP_MODAL_DEFAULT_TITLE,
},
}, },
toGroup: { },
default: { toProject: {
introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT, default: {
}, introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT,
}, },
toProject: { celebrate: {
default: { introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
},
}, },
searchField: GROUP_SEARCH_FIELD,
placeHolder: GROUP_PLACEHOLDER,
}, },
accessLevel: ACCESS_LEVEL, searchField: MEMBERS_SEARCH_FIELD,
accessExpireDate: ACCESS_EXPIRE_DATE, placeHolder: MEMBERS_PLACEHOLDER,
tasksToBeDone: {
title: MEMBERS_TASKS_TO_BE_DONE_TITLE,
noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS,
},
tasksProject: {
title: MEMBERS_TASKS_PROJECTS_TITLE,
},
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
};
export const GROUP_MODAL_LABELS = {
title: GROUP_MODAL_DEFAULT_TITLE,
toGroup: {
introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT,
},
toProject: {
introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT,
},
searchField: GROUP_SEARCH_FIELD,
placeHolder: GROUP_PLACEHOLDER,
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
invalidFeedbackMessageDefault: INVALID_FEEDBACK_MESSAGE_DEFAULT,
readMoreText: READ_MORE_TEXT,
inviteButtonText: INVITE_BUTTON_TEXT,
cancelButtonText: CANCEL_BUTTON_TEXT,
headerCloseLabel: HEADER_CLOSE_LABEL,
}; };
export const LEARN_GITLAB = 'learn_gitlab'; export const LEARN_GITLAB = 'learn_gitlab';
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(GlToast);
let initedInviteGroupsModal;
export default function initInviteGroupsModal() {
if (initedInviteGroupsModal) {
// if we already loaded this in another part of the dom, we don't want to do it again
// else we will stack the modals
return false;
}
// https://gitlab.com/gitlab-org/gitlab/-/issues/344955
// bug lying in wait here for someone to put group and project invite in same screen
// once that happens we'll need to mount these differently, perhaps split
// group/project to each mount one, with many ways to open it.
const el = document.querySelector('.js-invite-groups-modal');
if (!el) {
return false;
}
initedInviteGroupsModal = true;
return new Vue({
el,
render: (createElement) =>
createElement(InviteGroupsModal, {
props: {
...el.dataset,
isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels),
defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10),
invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'),
},
}),
});
}
...@@ -38,9 +38,6 @@ export default function initInviteMembersModal() { ...@@ -38,9 +38,6 @@ export default function initInviteMembersModal() {
isProject: parseBoolean(el.dataset.isProject), isProject: parseBoolean(el.dataset.isProject),
accessLevels: JSON.parse(el.dataset.accessLevels), accessLevels: JSON.parse(el.dataset.accessLevels),
defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10),
groupSelectFilter: el.dataset.groupsFilter,
groupSelectParentId: parseInt(el.dataset.parentId, 10),
invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'),
tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'),
projects: JSON.parse(el.dataset.projects || '[]'), projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter, usersFilter: el.dataset.usersFilter,
......
import { groupMemberRequestFormatter } from '~/groups/members/utils'; import { groupMemberRequestFormatter } from '~/groups/members/utils';
import groupsSelect from '~/groups_select'; import groupsSelect from '~/groups_select';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
import initInviteMembersForm from '~/invite_members/init_invite_members_form'; import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
...@@ -56,6 +57,7 @@ groupsSelect(); ...@@ -56,6 +57,7 @@ groupsSelect();
memberExpirationDate(); memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups'); memberExpirationDate('.js-access-expiration-date-groups');
initInviteMembersModal(); initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger(); initInviteMembersTrigger();
initInviteGroupTrigger(); initInviteGroupTrigger();
......
...@@ -3,6 +3,7 @@ import initImportAProjectModal from '~/invite_members/init_import_a_project_moda ...@@ -3,6 +3,7 @@ import initImportAProjectModal from '~/invite_members/init_import_a_project_moda
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersForm from '~/invite_members/init_invite_members_form'; import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date'; import memberExpirationDate from '~/member_expiration_date';
...@@ -17,6 +18,7 @@ memberExpirationDate(); ...@@ -17,6 +18,7 @@ memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups'); memberExpirationDate('.js-access-expiration-date-groups');
initImportAProjectModal(); initImportAProjectModal();
initInviteMembersModal(); initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger(); initInviteMembersTrigger();
initInviteGroupTrigger(); initInviteGroupTrigger();
......
...@@ -33,12 +33,23 @@ module InviteMembersHelper ...@@ -33,12 +33,23 @@ module InviteMembersHelper
end end
end end
def common_invite_group_modal_data(source, member_class, is_project)
{
id: source.id,
name: source.name,
default_access_level: Gitlab::Access::GUEST,
invalid_groups: source.related_group_ids,
help_link: help_page_url('user/permissions'),
is_project: is_project,
access_levels: member_class.access_level_roles.to_json
}
end
def common_invite_modal_dataset(source) def common_invite_modal_dataset(source)
dataset = { dataset = {
id: source.id, id: source.id,
name: source.name, name: source.name,
default_access_level: Gitlab::Access::GUEST, default_access_level: Gitlab::Access::GUEST
invalid_groups: source.related_group_ids
} }
if show_invite_members_for_task?(source) if show_invite_members_for_task?(source)
......
- return unless can_admin_group_member?(group)
.js-invite-groups-modal{ data: common_invite_group_modal_data(group, GroupMember, 'false').merge(group_select_data(group)) }
...@@ -2,5 +2,4 @@ ...@@ -2,5 +2,4 @@
.js-invite-members-modal{ data: { is_project: 'false', .js-invite-members-modal{ data: { is_project: 'false',
access_levels: GroupMember.access_level_roles.to_json, access_levels: GroupMember.access_level_roles.to_json,
default_access_level: Gitlab::Access::GUEST, help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) }
help_link: help_page_url('user/permissions') }.merge(group_select_data(group)).merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) }
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
trigger_source: 'group-members-page', trigger_source: 'group-members-page',
display_text: _('Invite members') } } display_text: _('Invite members') } }
= render 'groups/invite_groups_modal', group: @group
= render 'groups/invite_members_modal', group: @group = render 'groups/invite_members_modal', group: @group
- if can_admin_group_member?(@group) && Feature.disabled?(:invite_members_group_modal, @group, default_enabled: :yaml) - if can_admin_group_member?(@group) && Feature.disabled?(:invite_members_group_modal, @group, default_enabled: :yaml)
%hr.gl-mt-4 %hr.gl-mt-4
......
- return unless can_admin_project_member?(project)
.js-invite-groups-modal{ data: common_invite_group_modal_data(project, ProjectMember, 'true') }
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
.js-import-a-project-modal{ data: { project_id: @project.id, project_name: @project.name } } .js-import-a-project-modal{ data: { project_id: @project.id, project_name: @project.name } }
- if @project.allowed_to_share_with_group? - if @project.allowed_to_share_with_group?
.js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } } .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } }
= render 'projects/invite_groups_modal', project: @project
- if can_admin_project_member?(@project) - if can_admin_project_member?(@project)
.js-invite-members-trigger{ data: { variant: 'success', .js-invite-members-trigger{ data: { variant: 'success',
classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3',
......
...@@ -66,7 +66,7 @@ RSpec.describe 'Groups > Members > List members' do ...@@ -66,7 +66,7 @@ RSpec.describe 'Groups > Members > List members' do
click_on 'Invite members' click_on 'Invite members'
page.within '[data-testid="invite-members-modal"]' do page.within '[data-testid="invite-modal"]' do
field = find('[data-testid="members-token-select-input"]') field = find('[data-testid="members-token-select-input"]')
field.native.send_keys :tab field.native.send_keys :tab
field.click field.click
......
...@@ -9,7 +9,7 @@ module QA ...@@ -9,7 +9,7 @@ module QA
def self.included(base) def self.included(base)
super super
base.view 'app/assets/javascripts/invite_members/components/invite_members_modal.vue' do base.view 'app/assets/javascripts/invite_members/components/invite_modal_base.vue' do
element :invite_button element :invite_button
element :access_level_dropdown element :access_level_dropdown
element :invite_members_modal_content element :invite_members_modal_content
......
...@@ -44,7 +44,7 @@ describe('InviteGroupTrigger', () => { ...@@ -44,7 +44,7 @@ describe('InviteGroupTrigger', () => {
}); });
it('emits event that triggers opening the modal', () => { it('emits event that triggers opening the modal', () => {
expect(eventHub.$emit).toHaveBeenLastCalledWith('openModal', { inviteeType: 'group' }); expect(eventHub.$emit).toHaveBeenLastCalledWith('openGroupModal');
}); });
}); });
}); });
import { GlModal, GlSprintf } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Api from '~/api';
import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.vue';
import InviteModalBase from '~/invite_members/components/invite_modal_base.vue';
import GroupSelect from '~/invite_members/components/group_select.vue';
import { stubComponent } from 'helpers/stub_component';
import { propsData, sharedGroup } from '../mock_data/group_modal';
describe('InviteGroupsModal', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(InviteGroupsModal, {
propsData: {
...propsData,
...props,
},
stubs: {
InviteModalBase,
GlSprintf,
GlModal: stubComponent(GlModal, {
template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
}),
},
});
};
const createInviteGroupToProjectWrapper = () => {
createComponent({ isProject: true });
};
const createInviteGroupToGroupWrapper = () => {
createComponent({ isProject: false });
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGroupSelect = () => wrapper.findComponent(GroupSelect);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findInviteButton = () => wrapper.findByTestId('invite-button');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const membersFormGroupInvalidFeedback = () =>
findMembersFormGroup().attributes('invalid-feedback');
const clickInviteButton = () => findInviteButton().vm.$emit('click');
const clickCancelButton = () => findCancelButton().vm.$emit('click');
const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val);
describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => {
it('includes the correct type, and formatted intro text', () => {
createInviteGroupToProjectWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name project.");
});
});
describe('when inviting to a group', () => {
it('includes the correct type, and formatted intro text', () => {
createInviteGroupToGroupWrapper();
expect(findIntroText()).toBe("You're inviting a group to the test name group.");
});
});
});
describe('submitting the invite form', () => {
describe('when sharing the group is successful', () => {
const groupPostData = {
group_id: sharedGroup.id,
group_access: propsData.defaultAccessLevel,
expires_at: undefined,
format: 'json',
};
beforeEach(() => {
createComponent();
triggerGroupSelect(sharedGroup);
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData });
clickInviteButton();
});
it('calls Api groupShareWithGroup with the correct params', () => {
expect(Api.groupShareWithGroup).toHaveBeenCalledWith(propsData.id, groupPostData);
});
it('displays the successful toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
onComplete: expect.any(Function),
});
});
});
describe('when sharing the group fails', () => {
beforeEach(() => {
createInviteGroupToGroupWrapper();
triggerGroupSelect(sharedGroup);
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'groupShareWithGroup')
.mockRejectedValue({ response: { data: { success: false } } });
clickInviteButton();
});
it('does not show the toast message on failure', () => {
expect(wrapper.vm.$toast.show).not.toHaveBeenCalled();
});
it('displays the generic error for http server error', () => {
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
});
describe('clearing the invalid state and message', () => {
it('clears the error when the cancel button is clicked', async () => {
clickCancelButton();
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
});
it('clears the error when the modal is hidden', async () => {
wrapper.findComponent(GlModal).vm.$emit('hide');
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
});
});
});
});
});
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 '~/invite_members/components/invite_modal_base.vue';
import { CANCEL_BUTTON_TEXT, INVITE_BUTTON_TEXT } from '~/invite_members/constants';
import { propsData } from '../mock_data/modal_base';
describe('InviteModalBase', () => {
let wrapper;
const createComponent = (data = {}, props = {}) => {
wrapper = shallowMountExtended(InviteModalBase, {
propsData: {
...propsData,
...props,
},
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');
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);
});
});
});
});
export const propsData = {
id: '1',
name: 'test name',
isProject: false,
invalidGroups: [],
accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
defaultAccessLevel: 10,
helpLink: 'https://example.com',
};
export const sharedGroup = { id: '981' };
export const propsData = {
id: '1',
name: 'test name',
isProject: false,
accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
defaultAccessLevel: 30,
helpLink: 'https://example.com',
tasksToBeDoneOptions: [
{ text: 'First task', value: 'first' },
{ text: 'Second task', value: 'second' },
],
projects: [
{ text: 'First project', value: '1' },
{ text: 'Second project', value: '2' },
],
};
export const inviteSource = 'unknown';
export const newProjectPath = 'projects/new';
export const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
export const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' };
export const user3 = {
id: 'user-defined-token',
name: 'email@example.com',
username: 'one_2',
avatar_url: '',
};
export const user4 = {
id: 'user-defined-token',
name: 'email4@example.com',
username: 'one_4',
avatar_url: '',
};
export const GlEmoji = { template: '<img/>' };
export const propsData = {
modalTitle: '_modal_title_',
modalId: '_modal_id_',
name: '_name_',
accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
defaultAccessLevel: 10,
helpLink: 'https://example.com',
labelIntroText: '_label_intro_text_',
labelSearchField: '_label_search_field_',
formGroupDescription: '_form_group_description_',
};
...@@ -15,13 +15,28 @@ RSpec.describe InviteMembersHelper do ...@@ -15,13 +15,28 @@ RSpec.describe InviteMembersHelper do
helper.extend(Gitlab::Experimentation::ControllerConcern) helper.extend(Gitlab::Experimentation::ControllerConcern)
end end
describe '#common_invite_modal_dataset' do describe '#common_invite_group_modal_data' do
it 'has expected common attributes' do it 'has expected common attributes' do
attributes = { attributes = {
id: project.id, id: project.id,
name: project.name, name: project.name,
default_access_level: Gitlab::Access::GUEST, default_access_level: Gitlab::Access::GUEST,
invalid_groups: project.related_group_ids invalid_groups: project.related_group_ids,
help_link: help_page_url('user/permissions'),
is_project: 'true',
access_levels: ProjectMember.access_level_roles.to_json
}
expect(helper.common_invite_group_modal_data(project, ProjectMember, 'true')).to include(attributes)
end
end
describe '#common_invite_modal_dataset' do
it 'has expected common attributes' do
attributes = {
id: project.id,
name: project.name,
default_access_level: Gitlab::Access::GUEST
} }
expect(helper.common_invite_modal_dataset(project)).to include(attributes) expect(helper.common_invite_modal_dataset(project)).to include(attributes)
......
...@@ -8,7 +8,7 @@ module Spec ...@@ -8,7 +8,7 @@ module Spec
def invite_member(name, role: 'Guest', expires_at: nil) def invite_member(name, role: 'Guest', expires_at: nil)
click_on 'Invite members' click_on 'Invite members'
page.within '[data-testid="invite-members-modal"]' do page.within '[data-testid="invite-modal"]' do
find('[data-testid="members-token-select-input"]').set(name) find('[data-testid="members-token-select-input"]').set(name)
wait_for_requests wait_for_requests
......
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