diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-02-20 00:11:07 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-02-20 00:11:07 +0300 |
commit | 83d7a3922367d95a0875f9bdbdbf34a37f84e3fb (patch) | |
tree | 8b8bcfea60335e24f38d813e950624d30a4a94ff | |
parent | fe389e7d345a391a8b6e6392dcf1bbd23546b323 (diff) |
Add latest changes from gitlab-org/gitlab@master
66 files changed, 953 insertions, 507 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index c7e6b98a934..a9bb2909c39 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -24,6 +24,7 @@ const Api = { projectPackagesPath: '/api/:version/projects/:id/packages', projectPackagePath: '/api/:version/projects/:id/packages/:package_id', groupProjectsPath: '/api/:version/groups/:id/projects.json', + groupSharePath: '/api/:version/groups/:id/share', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', forkedProjectsPath: '/api/:version/projects/:id/forks', @@ -39,6 +40,7 @@ const Api = { projectRunnersPath: '/api/:version/projects/:id/runners', projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', projectSearchPath: '/api/:version/projects/:id/search', + projectSharePath: '/api/:version/projects/:id/share', projectMilestonesPath: '/api/:version/projects/:id/milestones', projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid', mergeRequestsPath: '/api/:version/merge_requests', @@ -365,6 +367,16 @@ const Api = { }); }, + projectShareWithGroup(id, options = {}) { + const url = Api.buildUrl(Api.projectSharePath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, { + expires_at: options.expires_at, + group_access: options.group_access, + group_id: options.group_id, + }); + }, + projectMilestones(id, params = {}) { const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id)); @@ -426,6 +438,16 @@ const Api = { }); }, + groupShareWithGroup(id, options = {}) { + const url = Api.buildUrl(Api.groupSharePath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, { + expires_at: options.expires_at, + group_access: options.group_access, + group_id: options.group_id, + }); + }, + commit(id, sha, params = {}) { const url = Api.buildUrl(this.commitPath) .replace(':id', encodeURIComponent(id)) diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue new file mode 100644 index 00000000000..4a72e97db8c --- /dev/null +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -0,0 +1,103 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownText, GlSearchBoxByType } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import Api from '~/api'; +import { s__ } from '~/locale'; +import { SEARCH_DELAY } from '../constants'; + +export default { + name: 'GroupSelect', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + }, + model: { + prop: 'selectedGroup', + }, + data() { + return { + isFetching: false, + groups: [], + selectedGroup: {}, + searchTerm: '', + }; + }, + computed: { + selectedGroupName() { + return this.selectedGroup.name || this.$options.i18n.dropdownText; + }, + isFetchResultEmpty() { + return this.groups.length === 0; + }, + }, + watch: { + searchTerm() { + this.retrieveGroups(); + }, + }, + mounted() { + this.retrieveGroups(); + }, + methods: { + retrieveGroups: debounce(function debouncedRetrieveGroups() { + this.isFetching = true; + return Api.groups(this.searchTerm, this.$options.defaultFetchOptions) + .then((response) => { + this.groups = response.map((group) => ({ + id: group.id, + name: group.full_name, + path: group.path, + })); + this.isFetching = false; + }) + .catch(() => { + this.isFetching = false; + }); + }, SEARCH_DELAY), + selectGroup(group) { + this.selectedGroup = group; + + this.$emit('input', this.selectedGroup); + }, + }, + i18n: { + dropdownText: s__('GroupSelect|Select a group'), + searchPlaceholder: s__('GroupSelect|Search groups'), + emptySearchResult: s__('GroupSelect|No matching results'), + }, + defaultFetchOptions: { + exclude_internal: true, + active: true, + }, +}; +</script> +<template> + <div> + <gl-dropdown + data-testid="group-select-dropdown" + :text="selectedGroupName" + block + menu-class="gl-w-full!" + > + <gl-search-box-by-type + v-model.trim="searchTerm" + :is-loading="isFetching" + :placeholder="$options.i18n.searchPlaceholder" + data-qa-selector="group_select_dropdown_search_field" + /> + <gl-dropdown-item + v-for="group in groups" + :key="group.id" + :name="group.name" + @click="selectGroup(group)" + > + {{ group.name }} + </gl-dropdown-item> + <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message"> + <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> + </gl-dropdown-text> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue new file mode 100644 index 00000000000..c9de078319a --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue @@ -0,0 +1,34 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlButton, + }, + props: { + displayText: { + type: String, + required: false, + default: s__('InviteMembers|Invite a group'), + }, + classes: { + type: String, + required: false, + default: '', + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal', { inviteeType: 'group' }); + }, + }, +}; +</script> + +<template> + <gl-button :class="classes" data-qa-selector="invite_a_group_button" @click="openModal"> + {{ displayText }} + </gl-button> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index f5a65882fba..cd9c3b0b5d3 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -11,9 +11,10 @@ import { } from '@gitlab/ui'; import { partition, isString } from 'lodash'; import Api from '~/api'; +import GroupSelect from '~/invite_members/components/group_select.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; -import { s__, __, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; export default { @@ -28,6 +29,7 @@ export default { GlButton, GlFormInput, MembersTokenSelect, + GroupSelect, }, props: { id: { @@ -60,21 +62,21 @@ export default { visible: true, modalId: 'invite-members-modal', selectedAccessLevel: this.defaultAccessLevel, + inviteeType: 'members', newUsersToInvite: [], selectedDate: undefined, + groupToBeSharedWith: {}, }; }, computed: { - inviteToName() { - return this.name.toUpperCase(); - }, - inviteToType() { - return this.isProject ? __('project') : __('group'); + isInviteGroup() { + return this.inviteeType === 'group'; }, introText() { - return sprintf(s__("InviteMembersModal|You're inviting members to the %{name} %{type}"), { - name: this.inviteToName, - type: this.inviteToType, + const inviteTo = this.isProject ? 'toProject' : 'toGroup'; + + return sprintf(this.$options.labels[this.inviteeType][inviteTo].introText, { + name: this.name.toUpperCase(), }); }, toastOptions() { @@ -82,12 +84,12 @@ export default { onComplete: () => { this.selectedAccessLevel = this.defaultAccessLevel; this.newUsersToInvite = []; + this.groupToBeSharedWith = {}; }, }; }, basePostData() { return { - access_level: this.selectedAccessLevel, expires_at: this.selectedDate, format: 'json', }; @@ -97,9 +99,16 @@ export default { (key) => this.accessLevels[key] === Number(this.selectedAccessLevel), ); }, + inviteDisabled() { + return ( + this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0 + ); + }, }, mounted() { - eventHub.$on('openModal', this.openModal); + eventHub.$on('openModal', (options) => { + this.openModal(options); + }); }, methods: { partitionNewUsersToInvite() { @@ -113,26 +122,42 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal() { + openModal({ inviteeType }) { + this.inviteeType = inviteeType; + this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, sendInvite() { - this.submitForm(); + if (this.isInviteGroup) { + this.submitShareWithGroup(); + } else { + this.submitInviteMembers(); + } this.closeModal(); }, cancelInvite() { this.selectedAccessLevel = this.defaultAccessLevel; this.selectedDate = undefined; - this.newUsersToInvite = ''; + this.newUsersToInvite = []; + this.groupToBeSharedWith = {}; this.closeModal(); }, changeSelectedItem(item) { this.selectedAccessLevel = item; }, - submitForm() { + submitShareWithGroup() { + const apiShareWithGroup = this.isProject + ? Api.projectShareWithGroup.bind(Api) + : Api.groupShareWithGroup.bind(Api); + + apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) + .then(this.showToastMessageSuccess) + .catch(this.showToastMessageError); + }, + submitInviteMembers() { const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const promises = []; @@ -155,10 +180,25 @@ export default { Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError); }, inviteByEmailPostData(usersToInviteByEmail) { - return { ...this.basePostData, email: usersToInviteByEmail }; + return { + ...this.basePostData, + email: usersToInviteByEmail, + access_level: this.selectedAccessLevel, + }; }, addByUserIdPostData(usersToAddById) { - return { ...this.basePostData, user_id: usersToAddById }; + return { + ...this.basePostData, + user_id: usersToAddById, + access_level: this.selectedAccessLevel, + }; + }, + shareWithGroupPostData(groupToBeSharedWith) { + return { + ...this.basePostData, + group_id: groupToBeSharedWith, + group_access: this.selectedAccessLevel, + }; }, showToastMessageSuccess() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); @@ -170,9 +210,28 @@ export default { }, }, labels: { - modalTitle: s__('InviteMembersModal|Invite team members'), - newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'), - userPlaceholder: s__('InviteMembersModal|Search for members to invite'), + members: { + modalTitle: s__('InviteMembersModal|Invite team members'), + searchField: s__('InviteMembersModal|GitLab member or Email address'), + placeHolder: s__('InviteMembersModal|Search for members to invite'), + toGroup: { + introText: s__("InviteMembersModal|You're inviting members to the %{name} group"), + }, + toProject: { + introText: s__("InviteMembersModal|You're inviting members to the %{name} project"), + }, + }, + group: { + modalTitle: s__('InviteMembersModal|Invite a group'), + searchField: s__('InviteMembersModal|Select a group to invite'), + placeHolder: s__('InviteMembersModal|Search for a group to invite'), + toGroup: { + introText: s__("InviteMembersModal|You're inviting a group to the %{name} group"), + }, + toProject: { + introText: s__("InviteMembersModal|You're inviting a group to the %{name} project"), + }, + }, accessLevel: s__('InviteMembersModal|Choose a role permission'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), @@ -189,27 +248,34 @@ export default { <gl-modal :modal-id="modalId" size="sm" - :title="$options.labels.modalTitle" + data-qa-selector="invite_members_modal_content" + :title="$options.labels[inviteeType].modalTitle" :header-close-label="$options.labels.headerCloseLabel" > - <div class="gl-ml-5 gl-mr-5"> - <div>{{ introText }}</div> + <div> + <p ref="introText">{{ introText }}</p> <label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{ - $options.labels.newUsersToInvite + $options.labels[inviteeType].searchField }}</label> <div class="gl-mt-2"> <members-token-select + v-if="!isInviteGroup" v-model="newUsersToInvite" - :label="$options.labels.newUsersToInvite" :aria-labelledby="$options.membersTokenSelectLabelId" - :placeholder="$options.labels.userPlaceholder" + :placeholder="$options.labels[inviteeType].placeHolder" /> + <group-select v-if="isInviteGroup" v-model="groupToBeSharedWith" /> </div> - <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> + <label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label> <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName"> + <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" @@ -223,7 +289,7 @@ export default { </gl-dropdown> </div> - <div class="gl-mt-2"> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> <gl-sprintf :message="$options.labels.readMoreText"> <template #link="{ content }"> <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> @@ -231,7 +297,7 @@ export default { </gl-sprintf> </div> - <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{ + <label class="gl-font-weight-bold gl-mt-5 gl-display-block" for="expires_at">{{ $options.labels.accessExpireDate }}</label> <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> @@ -253,15 +319,16 @@ export default { </div> <template #modal-footer> - <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3"> + <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"> <gl-button ref="cancelButton" @click="cancelInvite"> {{ $options.labels.cancelButtonText }} </gl-button> <div class="gl-mr-3"></div> <gl-button ref="inviteButton" - :disabled="!newUsersToInvite" + :disabled="inviteDisabled" variant="success" + data-qa-selector="invite_button" @click="sendInvite" >{{ $options.labels.inviteButtonText }}</gl-button > diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index eb97c458f88..f8cc74511d9 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -27,14 +27,14 @@ export default { }, methods: { openModal() { - eventHub.$emit('openModal'); + eventHub.$emit('openModal', { inviteeType: 'members' }); }, }, }; </script> <template> - <gl-link :class="classes" @click="openModal"> + <gl-link :class="classes" data-qa-selector="invite_members_button" @click="openModal"> <div v-if="icon" class="nav-icon-container"> <gl-icon :size="16" :name="icon" /> </div> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 233a214013b..db6a7888786 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/u import { debounce } from 'lodash'; import { __ } from '~/locale'; import { getUsers } from '~/rest_api'; -import { USER_SEARCH_DELAY } from '../constants'; +import { SEARCH_DELAY } from '../constants'; export default { components: { @@ -67,7 +67,7 @@ export default { .catch(() => { this.loading = false; }); - }, USER_SEARCH_DELAY), + }, SEARCH_DELAY), handleInput() { this.$emit('input', this.selectedTokens); }, diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 1ff2125c292..2044dad896f 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1 +1 @@ -export const USER_SEARCH_DELAY = 200; +export const SEARCH_DELAY = 200; diff --git a/app/assets/javascripts/invite_members/init_invite_group_trigger.js b/app/assets/javascripts/invite_members/init_invite_group_trigger.js new file mode 100644 index 00000000000..c01bb1bae28 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_group_trigger.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue'; + +export default function initInviteGroupTrigger() { + const el = document.querySelector('.js-invite-group-trigger'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: (createElement) => + createElement(InviteGroupTrigger, { + props: { + ...el.dataset, + }, + }), + }); +} diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index a4ba86dc6a1..4290971623e 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -60,15 +60,15 @@ export default { <template> <div> - <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" :dismissible="false"> + <gl-alert v-if="errorMessage" class="gl-mb-7" variant="danger" :dismissible="false"> {{ errorMessage }} </gl-alert> - <h2>{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <div v-if="showNewUI" - class="gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" + class="jira-connect-app-body gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" > <h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading"> {{ s__('Integrations|Linked namespaces') }} diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js index 7191fce3c33..96029e2711b 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/index.js @@ -77,6 +77,7 @@ export async function initJiraConnect() { Vue.use(GlFeatureFlagsPlugin); const { groupsPath, subscriptionsPath, usersPath } = el.dataset; + AP.sizeToParent(); return new Vue({ el, diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 3496f699b06..63afc6fe94d 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import { groupMemberRequestFormatter } from '~/groups/members/utils'; import groupsSelect from '~/groups_select'; +import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; @@ -70,5 +71,6 @@ memberExpirationDate('.js-access-expiration-date-groups'); mountRemoveMemberModal(); initInviteMembersModal(); initInviteMembersTrigger(); +initInviteGroupTrigger(); new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js index 679f0af8efc..e855447d5ce 100644 --- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js @@ -1,3 +1,5 @@ +/* eslint-disable no-new */ + import Vue from 'vue'; import Vuex from 'vuex'; import NewUserList from '~/user_lists/components/new_user_list.vue'; @@ -5,18 +7,16 @@ import createStore from '~/user_lists/store/new'; Vue.use(Vuex); -document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('js-new-user-list'); - const { userListsDocsPath, featureFlagsPath } = el.dataset; - return new Vue({ - el, - store: createStore(el.dataset), - provide: { - userListsDocsPath, - featureFlagsPath, - }, - render(h) { - return h(NewUserList); - }, - }); +const el = document.getElementById('js-new-user-list'); +const { userListsDocsPath, featureFlagsPath } = el.dataset; +new Vue({ + el, + store: createStore(el.dataset), + provide: { + userListsDocsPath, + featureFlagsPath, + }, + render(h) { + return h(NewUserList); + }, }); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index ed11b07be4a..8d403b7688a 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import { deprecatedCreateFlash as flash } from '~/flash'; import groupsSelect from '~/groups_select'; +import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { __ } from '~/locale'; @@ -29,6 +30,7 @@ memberExpirationDate('.js-access-expiration-date-groups'); mountRemoveMemberModal(); initInviteMembersModal(); initInviteMembersTrigger(); +initInviteGroupTrigger(); new Members(); // eslint-disable-line no-new new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue index 8aa6e29adf1..c5fdb5fc242 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue @@ -1,11 +1,11 @@ <script> +import { GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { isFunction } from 'lodash'; import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; -import tooltip from '../directives/tooltip'; export default { directives: { - tooltip, + GlTooltip, }, props: { title: { @@ -59,9 +59,8 @@ export default { <template> <span v-if="showTooltip" - v-tooltip + v-gl-tooltip="{ placement }" :title="title" - :data-placement="placement" class="js-show-tooltip gl-min-w-0" > <slot></slot> diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js deleted file mode 100644 index 0eb505bfce8..00000000000 --- a/app/assets/javascripts/vue_shared/directives/tooltip.js +++ /dev/null @@ -1,35 +0,0 @@ -import $ from 'jquery'; -import '~/commons/bootstrap'; -import { parseBoolean } from '~/lib/utils/common_utils'; - -export default { - bind(el) { - const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); - const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; - - $(el).tooltip({ - trigger: 'hover', - delay, - // By default, sanitize is run even if there is no `html` or `template` present - // so let's optimize to only run this when necessary. - // https://github.com/twbs/bootstrap/blob/c5966de27395a407f9a3d20d0eb2ff8e8fb7b564/js/src/tooltip.js#L716 - sanitize: parseBoolean(el.dataset.html) || Boolean(el.dataset.template), - }); - }, - - componentUpdated(el) { - $(el).tooltip('_fixTitle'); - - // update visible tooltips - const tooltipInstance = $(el).data('bs.tooltip'); - const tip = tooltipInstance.getTipElement(); - tooltipInstance.setElementContent( - $(tip.querySelectorAll('.tooltip-inner')), - tooltipInstance.getTitle(), - ); - }, - - unbind(el) { - $(el).tooltip('dispose'); - }, -}; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 7931f4deea0..1fe94a796f5 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -42,7 +42,6 @@ @import 'framework/notes'; @import 'framework/tabs'; @import 'framework/timeline'; -@import 'framework/tooltips'; @import 'framework/toggle'; @import 'framework/typography'; @import 'framework/zen'; diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index b0334da6943..b81e9828dec 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -283,12 +283,6 @@ ul.indent-list { to { transform: rotate(360deg); } } -.namespace-title { - .tooltip-inner { - max-width: 350px; - } -} - .horizontal-list { padding-left: 0; list-style: none; diff --git a/app/assets/stylesheets/framework/tooltips.scss b/app/assets/stylesheets/framework/tooltips.scss deleted file mode 100644 index edc2fb532c8..00000000000 --- a/app/assets/stylesheets/framework/tooltips.scss +++ /dev/null @@ -1,6 +0,0 @@ -.tooltip-inner { - font-size: $gl-font-size-small; - border-radius: $border-radius-default; - line-height: $gl-line-height; - font-weight: $gl-font-weight-normal; -} diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss index 25401a161da..eb2dd6e578e 100644 --- a/app/assets/stylesheets/page_bundles/jira_connect.scss +++ b/app/assets/stylesheets/page_bundles/jira_connect.scss @@ -20,36 +20,23 @@ @import '@gitlab/ui/src/components/base/tooltip/tooltip'; $atlaskit-border-color: #dfe1e6; +$header-height: 40px; -.ac-content { - margin: 20px; - - .subscription-form { - margin-bottom: 20px; - - .field-group-input { - display: flex; - padding-top: $gl-padding-4; +.subscription-form { + .field-group-input { + display: flex; + padding-top: $gl-padding-4; - .ak-button { - align-items: center; - height: auto; - margin-left: $btn-margin-5; - } + .ak-button { + align-items: center; + height: auto; + margin-left: $btn-margin-5; } } } -$header-height: 40px; - .jira-connect-header { - border-bottom: 1px solid $gray-100; - display: flex; - align-items: center; - justify-content: center; min-height: $header-height; - padding-left: 16px; - padding-right: 16px; position: fixed; top: 0; left: 0; @@ -57,7 +44,6 @@ $header-height: 40px; } .jira-connect-user { - font-size: $gl-font-size; position: fixed; top: 10px; right: 20px; @@ -65,20 +51,17 @@ $header-height: 40px; .jira-connect-app { margin-top: $header-height; + height: calc(100% - #{$header-height}); + max-width: 1000px; +} + +.jira-connect-app-body { max-width: 600px; - min-height: 95vh; - padding-top: 48px; - padding-left: 16px; - padding-right: 16px; margin-left: auto; margin-right: auto; - text-align: center; -} - -.gl-mt-5 { - margin-top: 16px; } +// for external_link buttons svg { fill: currentColor; @@ -115,12 +98,3 @@ svg { } } } - -.empty-subscriptions { - color: $gray-900; -} - -.browser-limitations-notice { - font-size: $gl-font-size; - margin-top: 32px; -} diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index a5257ff20bc..de9859230d5 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -16,8 +16,9 @@ = html_escape(_('You can invite a new member to %{strong_start}%{group_name}%{strong_end}.')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - if can_invite_members_for_group?(@group) .gl-w-half.gl-xs-w-full - .gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2.gl-mb-3 - .js-invite-members-trigger.gl-px-2.gl-sm-w-auto.gl-w-full.gl-mb-4{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite members') } } + .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3 + .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } } + .js-invite-members-trigger{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } } = render 'groups/invite_members_modal', group: @group - if can_manage_members && !can_invite_members_for_group?(@group) %hr.gl-mt-4 diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index a549ed3540b..6a549227185 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -1,61 +1,62 @@ -%header.jira-connect-header - = brand_header_logo +%header.jira-connect-header.gl-display-flex.gl-align-items-center.gl-justify-content-center.gl-px-5.gl-border-b-solid.gl-border-b-gray-100.gl-border-b-1.gl-bg-white + = link_to brand_header_logo, Gitlab.config.gitlab.url, target: '_blank', rel: 'noopener noreferrer' -.jira-connect-user +.jira-connect-user.gl-font-base - if current_user - user_link = link_to(current_user.to_reference, jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in') = _('Signed in to GitLab as %{user_link}').html_safe % { user_link: user_link } - elsif @subscriptions.present? = link_to _('Sign in to GitLab'), jira_connect_users_path, target: '_blank', rel: 'noopener noreferrer', class: 'js-jira-connect-sign-in' -.jira-connect-app +%main.jira-connect-app.gl-px-5.gl-pt-7.gl-mx-auto - if current_user.blank? && @subscriptions.empty? - %h2= s_('JiraService|GitLab for Jira Configuration') - %p= s_('JiraService|Sign in to GitLab.com to get started.') + .jira-connect-app-body.gl-text-center + %h2= s_('JiraService|GitLab for Jira Configuration') + %p= s_('JiraService|Sign in to GitLab.com to get started.') - .gl-mt-7 - - sign_in_button_class = new_jira_connect_ui? ? 'btn gl-button btn-confirm' : 'ak-button ak-button__appearance-primary' - = external_link _('Sign in to GitLab'), jira_connect_users_path, class: "#{sign_in_button_class} js-jira-connect-sign-in" + .gl-mt-7 + - sign_in_button_class = new_jira_connect_ui? ? 'btn gl-button btn-confirm' : 'ak-button ak-button__appearance-primary' + = external_link _('Sign in to GitLab'), jira_connect_users_path, class: "#{sign_in_button_class} js-jira-connect-sign-in" - .gl-mt-7 - %p Note: this integration only works with accounts on GitLab.com (SaaS). + .gl-mt-7 + %p= s_('Integrations|Note: this integration only works with accounts on GitLab.com (SaaS).') - else .js-jira-connect-app{ data: jira_connect_app_data(@subscriptions) } - - unless new_jira_connect_ui? - %form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path } - .ak-field-group - %label - GitLab namespace + .jira-connect-app-body + - unless new_jira_connect_ui? + %form#add-subscription-form.subscription-form.gl-mb-5{ action: jira_connect_subscriptions_path } + .ak-field-group + %label= _('GitLab namespace') - .ak-field-group.field-group-input - %input#namespace-input.ak-field-text{ type: 'text', required: true, placeholder: 'e.g. "MyCompany" or "MyCompany/GroupName"' } - %button.ak-button.ak-button__appearance-primary{ type: 'submit' } - Link namespace to Jira + .ak-field-group.field-group-input + %input#namespace-input.ak-field-text{ type: 'text', required: true, placeholder: 'e.g. "MyCompany" or "MyCompany/GroupName"' } + %button.ak-button.ak-button__appearance-primary{ type: 'submit' } + = s_('Integrations|Link namespace to Jira') - - if @subscriptions.present? - %table.subscriptions.gl-w-full - %thead - %tr - %th Namespace - %th Added - %th - %tbody - - @subscriptions.each do |subscription| + - if @subscriptions.present? + %table.subscriptions.gl-w-full + %thead %tr - %td= subscription.namespace.full_path - %td= subscription.created_at - %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'js-jira-connect-remove-subscription' - - else - %h4.empty-subscriptions - No linked namespaces - %p= s_('Integrations|Namespaces are your GitLab groups and subgroups that will be linked to this Jira instance.') + %th= _('Namespace') + %th= _('Added') + %th + %tbody + - @subscriptions.each do |subscription| + %tr + %td= subscription.namespace.full_path + %td= subscription.created_at + %td= link_to _('Remove'), jira_connect_subscription_path(subscription), class: 'js-jira-connect-remove-subscription' + - else + .gl-text-center + %h4= s_('Integrations|No linked namespaces') + %p= s_('Integrations|Namespaces are your GitLab groups and subgroups that will be linked to this Jira instance.') - %p.browser-limitations-notice - %strong Browser limitations: - Adding a namespace currently works only in browsers that allow cross‑site cookies. Please make sure to use - %a{ href: 'https://www.mozilla.org/en-US/firefox/', target: '_blank', rel: 'noopener noreferrer' } Firefox - or enable cross‑site cookies in your browser when adding a namespace. + %p.jira-connect-app-body.gl-mt-7.gl-font-base.gl-text-center + %strong= s_('Integrations|Browser limitations') + - firefox_link_url = 'https://www.mozilla.org/en-US/firefox/' + - firefox_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: firefox_link_url } + = s_('Integrations|Adding a namespace currently works only in browsers that allow cross‑site cookies. Please make sure to use %{firefox_link_start}Firefox%{firefox_link_end} or enable cross‑site cookies in your browser when adding a namespace.').html_safe % { firefox_link_start: firefox_link_start, firefox_link_end: '</a>'.html_safe } = link_to _('Learn more'), 'https://gitlab.com/gitlab-org/gitlab/-/issues/284211', target: '_blank', rel: 'noopener noreferrer' = webpack_bundle_tag 'performance_bar' if performance_bar_enabled? diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml index da45d84a83b..e9da940ca7a 100644 --- a/app/views/layouts/jira_connect.html.haml +++ b/app/views/layouts/jira_connect.html.haml @@ -12,5 +12,4 @@ = Gon::Base.render_data(nonce: content_security_policy_nonce) = yield :head %body - .ac-content - = yield + = yield diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index beb435d268a..509092b64c9 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -18,8 +18,9 @@ %p = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } .col-md-12.col-lg-6 - .gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2.gl-mb-3 - .js-invite-members-trigger.gl-px-2.gl-sm-w-auto.gl-w-full.gl-mb-4{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite members') } } + .gl-display-flex.gl-flex-wrap.gl-justify-content-end + .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } } + .js-invite-members-trigger{ data: { classes: 'btn btn-success gl-button gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } } = render 'projects/invite_members_modal', project: @project - else diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 59c6b27e942..b8481382577 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -355,14 +355,6 @@ :weight: 1 :idempotent: :tags: [] -- :name: cronjob:releases_create_evidence - :feature_category: :release_evidence - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: - :tags: [] - :name: cronjob:releases_manage_evidence :feature_category: :release_evidence :has_external_dependencies: @@ -2100,6 +2092,14 @@ :weight: 2 :idempotent: :tags: [] +- :name: releases_create_evidence + :feature_category: :release_evidence + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: remote_mirror_notification :feature_category: :source_code_management :has_external_dependencies: diff --git a/app/workers/releases/create_evidence_worker.rb b/app/workers/releases/create_evidence_worker.rb index db75fae1639..d22329216f9 100644 --- a/app/workers/releases/create_evidence_worker.rb +++ b/app/workers/releases/create_evidence_worker.rb @@ -3,7 +3,6 @@ module Releases class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :release_evidence diff --git a/changelogs/unreleased/bvl-fix-create-release-worker-is-not-cron.yml b/changelogs/unreleased/bvl-fix-create-release-worker-is-not-cron.yml new file mode 100644 index 00000000000..9ab4bc33b92 --- /dev/null +++ b/changelogs/unreleased/bvl-fix-create-release-worker-is-not-cron.yml @@ -0,0 +1,5 @@ +--- +title: move create_release_evidence sidekiq queue out of the cronjob namespace +merge_request: 54432 +author: +type: fixed diff --git a/changelogs/unreleased/jira-connect-app-style-cleanup.yml b/changelogs/unreleased/jira-connect-app-style-cleanup.yml new file mode 100644 index 00000000000..1f28ebb276b --- /dev/null +++ b/changelogs/unreleased/jira-connect-app-style-cleanup.yml @@ -0,0 +1,5 @@ +--- +title: Improve Jira connect app styling and i18n +merge_request: 53441 +author: +type: changed diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 85229a6ae2c..8fb44e63a77 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -302,6 +302,8 @@ - 2 - - refresh_license_compliance_checks - 2 +- - releases_create_evidence + - 1 - - remote_mirror_notification - 2 - - repository_check diff --git a/db/post_migrate/20210217100728_move_create_release_evidence_queue_out_of_cronjob_namespace.rb b/db/post_migrate/20210217100728_move_create_release_evidence_queue_out_of_cronjob_namespace.rb new file mode 100644 index 00000000000..22bead87dc1 --- /dev/null +++ b/db/post_migrate/20210217100728_move_create_release_evidence_queue_out_of_cronjob_namespace.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class MoveCreateReleaseEvidenceQueueOutOfCronjobNamespace < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def up + sidekiq_queue_migrate 'cronjob:releases_create_evidence', to: 'releases_create_evidence' + end + + def down + sidekiq_queue_migrate 'releases_create_evidence', to: 'cronjob:releases_create_evidence' + end +end diff --git a/db/schema_migrations/20210217100728 b/db/schema_migrations/20210217100728 new file mode 100644 index 00000000000..6027a27f8c2 --- /dev/null +++ b/db/schema_migrations/20210217100728 @@ -0,0 +1 @@ +281ea05a95785b7f1d2d805bf8fe071c0fa59425eb01b46eeb69ad21f5650e29
\ No newline at end of file diff --git a/doc/development/fe_guide/style/vue.md b/doc/development/fe_guide/style/vue.md index 0288238a9e5..d62145b4a4c 100644 --- a/doc/development/fe_guide/style/vue.md +++ b/doc/development/fe_guide/style/vue.md @@ -432,41 +432,7 @@ must be unique. It's advised to use `kebab-case` namespaces. Useful links: 1. [`key`](https://vuejs.org/v2/guide/list.html#key) -1. [Vue Style Guide: Keyed v-for](https://vuejs.org/v2/style-guide/#Keyed-v-for-essential ) - -## Vue and Bootstrap - -1. Tooltips: Do not rely on `has-tooltip` class name for Vue components - - ```html - // bad - <span - class="has-tooltip" - title="Some tooltip text"> - Text - </span> - - // good - <span - v-tooltip - title="Some tooltip text"> - Text - </span> - ``` - -1. Tooltips: When using a tooltip, include the tooltip directive, `./app/assets/javascripts/vue_shared/directives/tooltip.js` - -1. Don't change `data-original-title`. - - ```html - // bad - <span data-original-title="tooltip text">Foo</span> - - // good - <span title="tooltip text">Foo</span> - - $('span').tooltip('_fixTitle'); - ``` +1. [Vue Style Guide: Keyed v-for](https://vuejs.org/v2/style-guide/#Keyed-v-for-essential) ## Vue testing diff --git a/doc/development/prometheus.md b/doc/development/prometheus.md deleted file mode 100644 index 62f30871f54..00000000000 --- a/doc/development/prometheus.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -redirect_to: '../user/project/integrations/prometheus.md' ---- - -This document was moved to [another location](../user/project/integrations/prometheus.md). - -<!-- This redirect file can be deleted after February 1, 2021. --> -<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> diff --git a/doc/development/snowplow.md b/doc/development/snowplow.md index 1f4c46bb81e..c9b76014c74 100644 --- a/doc/development/snowplow.md +++ b/doc/development/snowplow.md @@ -358,6 +358,7 @@ There are several tools for developing and testing Snowplow Event 1. For frontend events, in the MR description section, add a screenshot of the event's relevant section using the [Snowplow Analytics Debugger](https://chrome.google.com/webstore/detail/snowplow-analytics-debugg/jbnlcgeengmijcghameodeaenefieedm) Chrome browser extension. 1. For backend events, please use Snowplow Micro and add the output of the Snowplow Micro good events `GET http://localhost:9090/micro/good`. +1. Include a member of the Product Intelligence team as a reviewer of your MR. Mention `@gitlab-org/growth/product_intelligence/engineers` in your MR to request a review. ### Snowplow Analytics Debugger Chrome Extension diff --git a/doc/integration/elasticsearch.md b/doc/integration/elasticsearch.md index a62e38d80ca..9e2ecdeeb5e 100644 --- a/doc/integration/elasticsearch.md +++ b/doc/integration/elasticsearch.md @@ -422,6 +422,7 @@ The following are some available Rake tasks: | [`sudo gitlab-rake gitlab:elastic:projects_not_indexed`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Displays which projects are not indexed. | | [`sudo gitlab-rake gitlab:elastic:reindex_cluster`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/lib/tasks/gitlab/elastic.rake) | Schedules a zero-downtime cluster reindexing task. This feature should be used with an index that was created after GitLab 13.0. | | [`sudo gitlab-rake gitlab:elastic:mark_reindex_failed`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/lib/tasks/gitlab/elastic.rake)`] | Mark the most recent re-index job as failed. | +| [`sudo gitlab-rake gitlab:elastic:list_pending_migrations`](https://gitlab.com/gitlab-org/gitlab/blob/master/ee/lib/tasks/gitlab/elastic.rake)`] | List pending migrations. Pending migrations include those that have not yet started, have started but not finished, and those that are halted. | ### Environment variables diff --git a/doc/update/index.md b/doc/update/index.md index 574f86865a1..9871d0aba2c 100644 --- a/doc/update/index.md +++ b/doc/update/index.md @@ -138,6 +138,33 @@ pending_job_classes = scheduled_queue.select { |job| job["class"] == "Background pending_job_classes.each { |job_class| Gitlab::BackgroundMigration.steal(job_class) } ``` +## Checking for pending Elasticsearch migrations + +This section is only applicable if you have enabled the [Elasticsearch +integration](../integration/elasticsearch.md). + +Certain major releases might require [Elasticsearch +migrations](../integration/elasticsearch.md#background-migrations) to be +finished. You can find pending migrations by running the following command: + +**For Omnibus installations** + +```shell +sudo gitlab-rake gitlab:elastic:list_pending_migrations +``` + +**For installations from source** + +```shell +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:elastic:list_pending_migrations +``` + +### What do I do if my Elasticsearch migrations are stuck? + +See [how to retry a halted +migration](../integration/elasticsearch.md#retry-a-halted-migration). + ## Upgrade paths Although you can generally upgrade through multiple GitLab versions in one go, diff --git a/doc/user/packages/workflows/monorepo.md b/doc/user/packages/workflows/monorepo.md deleted file mode 100644 index abba9df6ec2..00000000000 --- a/doc/user/packages/workflows/monorepo.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -redirect_to: '../npm_registry/index.md' -disqus_identifier: 'https://docs.gitlab.com/ee/user/packages/workflows/monorepo.html' ---- - -This document was moved to [another location](../npm_registry/index.md). - -<!-- This redirect file can be deleted after <2021-02-14>. --> -<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> diff --git a/doc/user/project/merge_requests/getting_started.md b/doc/user/project/merge_requests/getting_started.md index 1de18874d0f..d7c35efd017 100644 --- a/doc/user/project/merge_requests/getting_started.md +++ b/doc/user/project/merge_requests/getting_started.md @@ -122,7 +122,7 @@ Requesting a code review is an important part of contributing code. However, dec your code and asking for a review are no easy tasks. Using the "assignee" field for both authors and reviewers makes it hard for others to determine who's doing what on a merge request. -GitLab Merge Request Reviewers enable you to request a review of your work, and +The Merge Request Reviewers feature enables you to request a review of your work, and see the status of the review. Reviewers help distinguish the roles of the users involved in the merge request. In comparison to an **Assignee**, who is directly responsible for creating or merging a merge request, a **Reviewer** is a team member diff --git a/doc/user/project/milestones/burndown_charts.md b/doc/user/project/milestones/burndown_charts.md deleted file mode 100644 index 2985e5da2ab..00000000000 --- a/doc/user/project/milestones/burndown_charts.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -redirect_to: 'burndown_and_burnup_charts.md' ---- - -This document was moved to [another location](burndown_and_burnup_charts.md). - -<!-- This redirect file can be deleted after February 1, 2021. --> -<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bc9a8a84d7e..613a9f395c7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -13756,6 +13756,9 @@ msgstr "" msgid "GitLab metadata URL" msgstr "" +msgid "GitLab namespace" +msgstr "" + msgid "GitLab project export" msgstr "" @@ -14479,6 +14482,15 @@ msgstr "" msgid "GroupSAML|should be a random persistent ID, emails are discouraged" msgstr "" +msgid "GroupSelect|No matching results" +msgstr "" + +msgid "GroupSelect|Search groups" +msgstr "" + +msgid "GroupSelect|Select a group" +msgstr "" + msgid "GroupSettings|Apply integration settings to all Projects" msgstr "" @@ -16042,12 +16054,18 @@ msgstr "" msgid "Integrations|Add namespace" msgstr "" +msgid "Integrations|Adding a namespace currently works only in browsers that allow cross‑site cookies. Please make sure to use %{firefox_link_start}Firefox%{firefox_link_end} or enable cross‑site cookies in your browser when adding a namespace." +msgstr "" + msgid "Integrations|All details" msgstr "" msgid "Integrations|All projects inheriting these settings will also be reset." msgstr "" +msgid "Integrations|Browser limitations" +msgstr "" + msgid "Integrations|Comment detail:" msgstr "" @@ -16087,6 +16105,9 @@ msgstr "" msgid "Integrations|Issues created in Jira are shown here once you have created the issues in project setup in Jira." msgstr "" +msgid "Integrations|Link namespace to Jira" +msgstr "" + msgid "Integrations|Link namespaces" msgstr "" @@ -16099,6 +16120,12 @@ msgstr "" msgid "Integrations|No available namespaces." msgstr "" +msgid "Integrations|No linked namespaces" +msgstr "" + +msgid "Integrations|Note: this integration only works with accounts on GitLab.com (SaaS)." +msgstr "" + msgid "Integrations|Projects using custom settings will not be affected." msgstr "" @@ -16303,6 +16330,9 @@ msgstr "" msgid "Invite Members" msgstr "" +msgid "Invite a group" +msgstr "" + msgid "Invite group" msgstr "" @@ -16399,19 +16429,40 @@ msgstr "" msgid "InviteMembersModal|Invite" msgstr "" +msgid "InviteMembersModal|Invite a group" +msgstr "" + msgid "InviteMembersModal|Invite team members" msgstr "" msgid "InviteMembersModal|Members were successfully added" msgstr "" +msgid "InviteMembersModal|Search for a group to invite" +msgstr "" + msgid "InviteMembersModal|Search for members to invite" msgstr "" +msgid "InviteMembersModal|Select a group to invite" +msgstr "" + msgid "InviteMembersModal|Some of the members could not be added" msgstr "" -msgid "InviteMembersModal|You're inviting members to the %{name} %{type}" +msgid "InviteMembersModal|You're inviting a group to the %{name} group" +msgstr "" + +msgid "InviteMembersModal|You're inviting a group to the %{name} project" +msgstr "" + +msgid "InviteMembersModal|You're inviting members to the %{name} group" +msgstr "" + +msgid "InviteMembersModal|You're inviting members to the %{name} project" +msgstr "" + +msgid "InviteMembers|Invite a group" msgstr "" msgid "InviteMembers|Invite team members" @@ -489,6 +489,7 @@ module QA autoload :ProjectSelector, 'qa/page/component/project_selector' autoload :Snippet, 'qa/page/component/snippet' autoload :NewSnippet, 'qa/page/component/new_snippet' + autoload :InviteMembersModal, 'qa/page/component/invite_members_modal' module Issuable autoload :Common, 'qa/page/component/issuable/common' diff --git a/qa/qa/flow/project.rb b/qa/qa/flow/project.rb index 8a9e2c86332..db42a3a3594 100644 --- a/qa/qa/flow/project.rb +++ b/qa/qa/flow/project.rb @@ -5,6 +5,16 @@ module QA module Project module_function + def add_member(project:, username:) + project.visit! + + Page::Project::Menu.perform(&:click_members) + + Page::Project::Members.perform do |member_settings| + member_settings.add_member(username) + end + end + def go_to_create_project_from_template if Page::Project::NewExperiment.perform(&:shown?) Page::Project::NewExperiment.perform(&:click_create_from_template_link) diff --git a/qa/qa/page/component/invite_members_modal.rb b/qa/qa/page/component/invite_members_modal.rb new file mode 100644 index 00000000000..fbddb37f15e --- /dev/null +++ b/qa/qa/page/component/invite_members_modal.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module QA + module Page + module Component + module InviteMembersModal + extend QA::Page::PageConcern + + def self.included(base) + super + + base.view 'app/assets/javascripts/invite_members/components/invite_members_modal.vue' do + element :invite_button + element :access_level_dropdown + element :invite_members_modal_content + end + + base.view 'app/assets/javascripts/invite_members/components/group_select.vue' do + element :group_select_dropdown_search_field + end + + base.view 'app/assets/javascripts/invite_members/components/invite_group_trigger.vue' do + element :invite_a_group_button + end + + base.view 'app/assets/javascripts/invite_members/components/invite_members_trigger.vue' do + element :invite_members_button + end + end + + def open_invite_members_modal + click_element :invite_members_button + end + + def open_invite_group_modal + click_element :invite_a_group_button + end + + def add_member(username, access_level = Resource::Members::AccessLevel::DEVELOPER) + open_invite_members_modal + + within_element(:invite_members_modal_content) do + fill_element :access_level_dropdown, with: access_level + + fill_in 'Search for members to invite', with: username + + Support::WaitForRequests.wait_for_requests + + click_button username + + click_element :invite_button + end + + Support::WaitForRequests.wait_for_requests + + page.refresh + end + + def invite_group(group_name, group_access = Resource::Members::AccessLevel::GUEST) + open_invite_group_modal + + fill_element :access_level_dropdown, with: group_access + + click_button 'Select a group' + fill_element :group_select_dropdown_search_field, group_name + + Support::WaitForRequests.wait_for_requests + + click_button group_name + + click_element :invite_button + + Support::WaitForRequests.wait_for_requests + + page.refresh + end + end + end + end +end diff --git a/qa/qa/page/group/members.rb b/qa/qa/page/group/members.rb index 87423ae9d75..68a169d5a7f 100644 --- a/qa/qa/page/group/members.rb +++ b/qa/qa/page/group/members.rb @@ -4,18 +4,13 @@ module QA module Page module Group class Members < Page::Base - include QA::Page::Component::Select2 + include Page::Component::InviteMembersModal include Page::Component::UsersSelect view 'app/assets/javascripts/vue_shared/components/remove_member_modal.vue' do element :remove_member_modal_content end - view 'app/views/shared/members/_invite_member.html.haml' do - element :member_select_field - element :invite_member_button - end - view 'app/assets/javascripts/pages/groups/group_members/index.js' do element :member_row element :groups_list @@ -32,31 +27,9 @@ module QA end view 'app/views/groups/group_members/index.html.haml' do - element :invite_group_tab element :groups_list_tab end - view 'app/views/shared/members/_invite_group.html.haml' do - element :group_select_field - element :invite_group_button - end - - def select_group(group_name) - click_element :group_select_field - search_and_select(group_name) - end - - def invite_group(group_name) - click_element :invite_group_tab - select_group(group_name) - click_element :invite_group_button - end - - def add_member(username) - select_user :member_select_field, username - click_element :invite_member_button - end - def update_access_level(username, access_level) within_element(:member_row, text: username) do click_element :access_level_dropdown diff --git a/qa/qa/page/project/members.rb b/qa/qa/page/project/members.rb index 7e722e02ad0..09264d95aed 100644 --- a/qa/qa/page/project/members.rb +++ b/qa/qa/page/project/members.rb @@ -4,21 +4,18 @@ module QA module Page module Project class Members < Page::Base - include QA::Page::Component::Select2 - - view 'app/views/shared/members/_invite_member.html.haml' do - element :member_select_field - element :invite_member_button - end + include QA::Page::Component::InviteMembersModal view 'app/views/projects/project_members/index.html.haml' do - element :invite_group_tab element :groups_list_tab end - view 'app/views/shared/members/_invite_group.html.haml' do - element :group_select_field - element :invite_group_button + view 'app/assets/javascripts/invite_members/components/invite_group_trigger.vue' do + element :invite_a_group_button + end + + view 'app/assets/javascripts/invite_members/components/invite_members_trigger.vue' do + element :invite_members_button end view 'app/assets/javascripts/pages/projects/project_members/index.js' do @@ -33,25 +30,7 @@ module QA element :remove_group_link_modal_content end - def select_group(group_name) - click_element :group_select_field - search_and_select(group_name) - end - - def invite_group(group_name) - click_element :invite_group_tab - select_group(group_name) - click_element :invite_group_button - end - - def add_member(username) - click_element :member_select_field - search_and_select username - click_element :invite_member_button - end - def remove_group(group_name) - click_element :invite_group_tab click_element :groups_list_tab within_element(:group_row, text: group_name) do diff --git a/qa/qa/resource/members.rb b/qa/qa/resource/members.rb index a88980f26d8..c8f9feeca15 100644 --- a/qa/qa/resource/members.rb +++ b/qa/qa/resource/members.rb @@ -26,10 +26,23 @@ module QA JSON.parse(get(Runtime::API::Request.new(api_client, api_members_path).url).body) end + def invite_group(group, access_level = AccessLevel::GUEST) + Support::Retrier.retry_until do + QA::Runtime::Logger.debug(%Q[Sharing #{self.class.name} with #{group.name}]) + + response = post Runtime::API::Request.new(api_client, api_share_path).url, { group_id: group.id, group_access: access_level } + response.code == QA::Support::Api::HTTP_STATUS_CREATED + end + end + def api_members_path "#{api_get_path}/members" end + def api_share_path + "#{api_get_path}/share" + end + class AccessLevel NO_ACCESS = 0 MINIMAL_ACCESS = 5 diff --git a/qa/qa/resource/personal_access_token.rb b/qa/qa/resource/personal_access_token.rb index 6b2301ba916..463e780f89b 100644 --- a/qa/qa/resource/personal_access_token.rb +++ b/qa/qa/resource/personal_access_token.rb @@ -22,7 +22,7 @@ module QA token_page.fill_token_name(name || 'api-test-token') token_page.check_api # Expire in 2 days just in case the token is created just before midnight - token_page.fill_expiry_date(Date.today + 2) + token_page.fill_expiry_date(Time.now.utc.to_date + 2) token_page.click_create_token_button end end diff --git a/qa/qa/resource/project.rb b/qa/qa/resource/project.rb index efb6c2c0591..28d0844e71c 100644 --- a/qa/qa/resource/project.rb +++ b/qa/qa/resource/project.rb @@ -272,10 +272,6 @@ module QA parse_body(get(Runtime::API::Request.new(api_client, api_pipelines_path).url)) end - def share_with_group(invitee, access_level = Resource::Members::AccessLevel::DEVELOPER) - post Runtime::API::Request.new(api_client, "/projects/#{id}/share").url, { group_id: invitee.id, group_access: access_level } - end - private def transform_api_resource(api_resource) diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/2fa_recovery_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/2fa_recovery_spec.rb index e38a9f47bd6..8e03444b113 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/login/2fa_recovery_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/login/2fa_recovery_spec.rb @@ -29,6 +29,7 @@ module QA end before do + Runtime::Feature.enable(:invite_members_group_modal, group: group) group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER) end diff --git a/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb index 2fc3ff2f18f..a8650d633c8 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb @@ -31,6 +31,7 @@ module QA let(:two_fa_expected_text) { /The group settings for.*require you to enable Two-Factor Authentication for your account.*You need to do this before/ } before do + Runtime::Feature.enable(:invite_members_group_modal, group: group) group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER) end diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/add_project_member_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/add_project_member_spec.rb index 85cfefd6c5c..728df77a266 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/project/add_project_member_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/project/add_project_member_spec.rb @@ -6,7 +6,6 @@ module QA before do Runtime::Feature.enable('vue_project_members_list') end - after do Runtime::Feature.disable('vue_project_members_list') end @@ -16,9 +15,13 @@ module QA user = Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) - Resource::Project.fabricate_via_api! do |project| + project = Resource::Project.fabricate_via_api! do |project| project.name = 'add-member-project' - end.visit! + end + + Runtime::Feature.enable(:invite_members_group_modal) + + project.visit! Page::Project::Menu.perform(&:click_members) Page::Project::Members.perform do |members| diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb index d54ce0ac0fc..c8c483fc2aa 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb @@ -21,6 +21,7 @@ module QA end before do + Runtime::Feature.enable(:invite_members_group_modal, group: group) group.add_member(user, Resource::Members::AccessLevel::MAINTAINER) end diff --git a/qa/qa/specs/features/browser_ui/1_manage/project/protected_tags_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/project/protected_tags_spec.rb index e40dde64675..600ce36d1e4 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/project/protected_tags_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/project/protected_tags_spec.rb @@ -10,6 +10,10 @@ module QA end end + before do + Runtime::Feature.enable(:invite_members_group_modal, project: project) + end + let(:developer_user) { Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_1, Runtime::Env.gitlab_qa_password_1) } let(:maintainer_user) { Resource::User.fabricate_or_use(Runtime::Env.gitlab_qa_username_2, Runtime::Env.gitlab_qa_password_2) } let(:tag_name) { 'v0.0.1' } diff --git a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb index f623efe5a3a..7c15937ef5e 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb @@ -17,6 +17,7 @@ module QA before do Runtime::Feature.enable('vue_project_members_list', project: project) + Runtime::Feature.enable(:invite_members_group_modal) Flow::Login.sign_in end diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb index a7f862e8911..9129584d110 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb @@ -18,6 +18,8 @@ module QA describe 'check xss occurence in @mentions in issues', :requires_admin do before do + Runtime::Feature.enable(:invite_members_group_modal) + Flow::Login.sign_in project.add_member(user) diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb index cc815fd0e67..2ce8c749e43 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb @@ -13,6 +13,7 @@ module QA before do Flow::Login.sign_in + Runtime::Feature.enable(:invite_members_group_modal, project: project) project.add_member(user) diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/real_time_assignee_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/real_time_assignee_spec.rb index 11f93d6a97e..2654531bc2c 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/real_time_assignee_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/real_time_assignee_spec.rb @@ -14,6 +14,7 @@ module QA before do Runtime::Feature.enable('real_time_issue_sidebar', project: project) Runtime::Feature.enable('broadcast_issue_updates', project: project) + Runtime::Feature.enable(:invite_members_group_modal, project: project) Flow::Login.sign_in diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/move_project_create_fork_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/move_project_create_fork_spec.rb index 7a727ffb291..0f6edca654b 100644 --- a/qa/qa/specs/features/browser_ui/3_create/repository/move_project_create_fork_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/repository/move_project_create_fork_spec.rb @@ -19,6 +19,7 @@ module QA end before do + Runtime::Feature.enable(:invite_members_group_modal, project: parent_project) parent_project.add_member(user) end diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb index c27d0afba6f..3b637a10abe 100644 --- a/spec/features/groups/members/manage_members_spec.rb +++ b/spec/features/groups/members/manage_members_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Groups > Members > Manage members' do sign_in(user1) end - shared_examples 'includes the correct Invite Members link' do |should_include, should_not_include| + shared_examples 'includes the correct Invite link' do |should_include, should_not_include| it 'includes either the form or the modal trigger' do group.add_owner(user1) @@ -31,15 +31,13 @@ RSpec.describe 'Groups > Members > Manage members' do stub_feature_flags(invite_members_group_modal: true) end - it_behaves_like 'includes the correct Invite Members link', '.js-invite-members-trigger', '.invite-users-form' + it_behaves_like 'includes the correct Invite link', '.js-invite-members-trigger', '.invite-users-form' + it_behaves_like 'includes the correct Invite link', '.js-invite-group-trigger', '.invite-group-form' end context 'when Invite Members modal is disabled' do - before do - stub_feature_flags(invite_members_group_modal: false) - end - - it_behaves_like 'includes the correct Invite Members link', '.invite-users-form', '.js-invite-members-trigger' + it_behaves_like 'includes the correct Invite link', '.invite-users-form', '.js-invite-members-trigger' + it_behaves_like 'includes the correct Invite link', '.invite-group-form', '.js-invite-group-trigger' end it 'update user to owner level', :js do diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index d2522a0124a..d6e1b170dd3 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -482,6 +482,30 @@ describe('Api', () => { }); }); + describe('projectShareWithGroup', () => { + it('invites a group to share access with the authenticated project', () => { + const projectId = 1; + const sharedGroupId = 99; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/share`; + const options = { + group_id: sharedGroupId, + group_access: 10, + expires_at: undefined, + }; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).reply(200, { + status: 'success', + }); + + return Api.projectShareWithGroup(projectId, options).then(({ data }) => { + expect(data.status).toBe('success'); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, options); + }); + }); + }); + describe('projectMilestones', () => { it('fetches project milestones', (done) => { const projectId = 1; @@ -638,6 +662,30 @@ describe('Api', () => { }); }); + describe('groupShareWithGroup', () => { + it('invites a group to share access with the authenticated group', () => { + const groupId = 1; + const sharedGroupId = 99; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/share`; + const options = { + group_id: sharedGroupId, + group_access: 10, + expires_at: undefined, + }; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).reply(200, { + status: 'success', + }); + + return Api.groupShareWithGroup(groupId, options).then(({ data }) => { + expect(data.status).toBe('success'); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, options); + }); + }); + }); + describe('commit', () => { const projectId = 'user/project'; const sha = 'abcd0123'; diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js new file mode 100644 index 00000000000..2a6985de136 --- /dev/null +++ b/spec/frontend/invite_members/components/group_select_spec.js @@ -0,0 +1,90 @@ +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import GroupSelect from '~/invite_members/components/group_select.vue'; + +const createComponent = () => { + return mount(GroupSelect, {}); +}; + +const group1 = { id: 1, full_name: 'Group One' }; +const group2 = { id: 2, full_name: 'Group Two' }; +const allGroups = [group1, group2]; + +describe('GroupSelect', () => { + let wrapper; + + beforeEach(() => { + jest.spyOn(Api, 'groups').mockResolvedValue(allGroups); + + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggle = () => findDropdown().find('button[aria-haspopup="true"]'); + const findDropdownItemByText = (text) => + wrapper + .findAllComponents(GlDropdownItem) + .wrappers.find((dropdownItemWrapper) => dropdownItemWrapper.text() === text); + + it('renders GlSearchBoxByType with default attributes', () => { + expect(findSearchBoxByType().exists()).toBe(true); + expect(findSearchBoxByType().vm.$attrs).toMatchObject({ + placeholder: 'Search groups', + }); + }); + + describe('when user types in the search input', () => { + let resolveApiRequest; + + beforeEach(() => { + jest.spyOn(Api, 'groups').mockImplementation( + () => + new Promise((resolve) => { + resolveApiRequest = resolve; + }), + ); + + findSearchBoxByType().vm.$emit('input', group1.name); + }); + + it('calls the API', () => { + resolveApiRequest({ data: allGroups }); + + expect(Api.groups).toHaveBeenCalledWith(group1.name, { + active: true, + exclude_internal: true, + }); + }); + + it('displays loading icon while waiting for API call to resolve', async () => { + expect(findSearchBoxByType().props('isLoading')).toBe(true); + + resolveApiRequest({ data: allGroups }); + await waitForPromises(); + + expect(findSearchBoxByType().props('isLoading')).toBe(false); + }); + }); + + describe('when group is selected from the dropdown', () => { + beforeEach(() => { + findDropdownItemByText(group1.full_name).vm.$emit('click'); + }); + + it('emits `input` event used by `v-model`', () => { + expect(wrapper.emitted('input')[0][0].id).toEqual(group1.id); + }); + + it('sets dropdown toggle text to selected item', () => { + expect(findDropdownToggle().text()).toBe(group1.full_name); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_group_trigger_spec.js b/spec/frontend/invite_members/components/invite_group_trigger_spec.js new file mode 100644 index 00000000000..cb9967ebe8c --- /dev/null +++ b/spec/frontend/invite_members/components/invite_group_trigger_spec.js @@ -0,0 +1,50 @@ +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue'; +import eventHub from '~/invite_members/event_hub'; + +const displayText = 'Invite a group'; + +const createComponent = (props = {}) => { + return mount(InviteGroupTrigger, { + propsData: { + displayText, + ...props, + }, + }); +}; + +describe('InviteGroupTrigger', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findButton = () => wrapper.findComponent(GlButton); + + describe('displayText', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('includes the correct displayText for the link', () => { + expect(findButton().text()).toBe(displayText); + }); + }); + + describe('when button is clicked', () => { + beforeEach(() => { + eventHub.$emit = jest.fn(); + + wrapper = createComponent(); + + findButton().trigger('click'); + }); + + it('emits event that triggers opening the modal', () => { + expect(eventHub.$emit).toHaveBeenLastCalledWith('openModal', { inviteeType: 'group' }); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index e310a00133c..ca086549f3f 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -6,8 +6,9 @@ import Api from '~/api'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; const id = '1'; -const name = 'testgroup'; +const name = 'test name'; const isProject = false; +const inviteeType = 'members'; const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; const defaultAccessLevel = '10'; const helpLink = 'https://example.com'; @@ -20,16 +21,19 @@ const user3 = { username: 'one_2', avatar_url: '', }; +const sharedGroup = { id: '981' }; -const createComponent = (data = {}) => { +const createComponent = (data = {}, props = {}) => { return shallowMount(InviteMembersModal, { propsData: { id, name, isProject, + inviteeType, accessLevels, defaultAccessLevel, helpLink, + ...props, }, data() { return data; @@ -46,6 +50,22 @@ const createComponent = (data = {}) => { }); }; +const createInviteMembersToProjectWrapper = () => { + return createComponent({ inviteeType: 'members' }, { isProject: true }); +}; + +const createInviteMembersToGroupWrapper = () => { + return createComponent({ inviteeType: 'members' }, { isProject: false }); +}; + +const createInviteGroupToProjectWrapper = () => { + return createComponent({ inviteeType: 'group' }, { isProject: true }); +}; + +const createInviteGroupToGroupWrapper = () => { + return createComponent({ inviteeType: 'group' }, { isProject: false }); +}; + describe('InviteMembersModal', () => { let wrapper; @@ -54,12 +74,13 @@ describe('InviteMembersModal', () => { wrapper = null; }); - const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownItems = () => findDropdown().findAll(GlDropdownItem); - const findDatepicker = () => wrapper.find(GlDatepicker); - const findLink = () => wrapper.find(GlLink); - const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); - const findInviteButton = () => wrapper.find({ ref: 'inviteButton' }); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + const findLink = () => wrapper.findComponent(GlLink); + const findIntroText = () => wrapper.find({ ref: 'introText' }).text(); + const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' }); + const findInviteButton = () => wrapper.findComponent({ ref: 'inviteButton' }); const clickInviteButton = () => findInviteButton().vm.$emit('click'); describe('rendering the modal', () => { @@ -68,7 +89,7 @@ describe('InviteMembersModal', () => { }); it('renders the modal with the correct title', () => { - expect(wrapper.find(GlModal).props('title')).toBe('Invite team members'); + expect(wrapper.findComponent(GlModal).props('title')).toBe('Invite team members'); }); it('renders the Cancel button text correctly', () => { @@ -102,6 +123,44 @@ describe('InviteMembersModal', () => { }); }); + describe('displaying the correct introText', () => { + describe('when inviting to a project', () => { + describe('when inviting members', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteMembersToProjectWrapper(); + + expect(findIntroText()).toBe("You're inviting members to the TEST NAME project"); + }); + }); + + describe('when sharing with a group', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteGroupToProjectWrapper(); + + expect(findIntroText()).toBe("You're inviting a group to the TEST NAME project"); + }); + }); + }); + + describe('when inviting to a group', () => { + describe('when inviting members', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteMembersToGroupWrapper(); + + expect(wrapper.html()).toContain("You're inviting members to the TEST NAME group"); + }); + }); + + describe('when sharing with a group', () => { + it('includes the correct invitee, type, and formatted name', () => { + wrapper = createInviteGroupToGroupWrapper(); + + expect(wrapper.html()).toContain("You're inviting a group to the TEST NAME group"); + }); + }); + }); + }); + describe('submitting the invite form', () => { const apiErrorMessage = 'Member already exists'; @@ -115,8 +174,9 @@ describe('InviteMembersModal', () => { describe('when invites are sent successfully', () => { beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user1] }); + wrapper = createInviteMembersToGroupWrapper(); + wrapper.setData({ newUsersToInvite: [user1] }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); @@ -283,5 +343,58 @@ describe('InviteMembersModal', () => { }); }); }); + + describe('when inviting a group to share', () => { + describe('when sharing the group is successful', () => { + const groupPostData = { + group_id: sharedGroup.id, + group_access: '10', + expires_at: undefined, + format: 'json', + }; + + beforeEach(() => { + wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); + + wrapper.setData({ inviteeType: 'group' }); + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); + jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); + + clickInviteButton(); + }); + + it('calls Api groupShareWithGroup with the correct params', () => { + expect(Api.groupShareWithGroup).toHaveBeenCalledWith(id, groupPostData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); + }); + + describe('when sharing the group fails', () => { + beforeEach(() => { + wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); + + wrapper.setData({ inviteeType: 'group' }); + wrapper.vm.$toast = { show: jest.fn() }; + + jest + .spyOn(Api, 'groupShareWithGroup') + .mockRejectedValue({ response: { data: { success: false } } }); + + jest.spyOn(wrapper.vm, 'showToastMessageError'); + + clickInviteButton(); + }); + + it('displays the generic error toastMessage', async () => { + await waitForPromises(); + + expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + }); + }); + }); }); }); diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index 18d6662d2d4..8beadf12a36 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -23,7 +23,7 @@ describe('InviteMembersTrigger', () => { }); describe('displayText', () => { - const findLink = () => wrapper.find(GlLink); + const findLink = () => wrapper.findComponent(GlLink); beforeEach(() => { wrapper = createComponent(); @@ -35,7 +35,7 @@ describe('InviteMembersTrigger', () => { }); describe('icon', () => { - const findIcon = () => wrapper.find(GlIcon); + const findIcon = () => wrapper.findComponent(GlIcon); it('includes the correct icon when an icon is sent', () => { wrapper = createComponent({ icon }); diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index a945b99bd54..f6e79d3607f 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -37,7 +37,7 @@ describe('MembersTokenSelect', () => { wrapper = null; }); - const findTokenSelector = () => wrapper.find(GlTokenSelector); + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); describe('rendering the token-selector component', () => { it('renders with the correct props', () => { diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js index 27c9b099306..380b7231acd 100644 --- a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js +++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js @@ -11,6 +11,15 @@ jest.mock('~/lib/utils/dom_utils', () => ({ throw new Error('this needs to be mocked'); }), })); +jest.mock('@gitlab/ui', () => ({ + GlTooltipDirective: { + bind(el, binding) { + el.classList.add('gl-tooltip'); + el.setAttribute('data-original-title', el.title); + el.dataset.placement = binding.value.placement; + }, + }, +})); describe('TooltipOnTruncate component', () => { let wrapper; @@ -52,7 +61,7 @@ describe('TooltipOnTruncate component', () => { wrapper = parent.find(TooltipOnTruncate); }; - const hasTooltip = () => wrapper.classes('js-show-tooltip'); + const hasTooltip = () => wrapper.classes('gl-tooltip'); afterEach(() => { wrapper.destroy(); diff --git a/spec/frontend/vue_shared/directives/tooltip_spec.js b/spec/frontend/vue_shared/directives/tooltip_spec.js deleted file mode 100644 index 99e8b5b552b..00000000000 --- a/spec/frontend/vue_shared/directives/tooltip_spec.js +++ /dev/null @@ -1,157 +0,0 @@ -import { mount } from '@vue/test-utils'; -import $ from 'jquery'; -import { escape } from 'lodash'; -import tooltip from '~/vue_shared/directives/tooltip'; - -const DEFAULT_TOOLTIP_TEMPLATE = '<div v-tooltip :title="tooltip"></div>'; -const HTML_TOOLTIP_TEMPLATE = '<div v-tooltip data-html="true" :title="tooltip"></div>'; - -describe('Tooltip directive', () => { - let wrapper; - - function createTooltipContainer({ - template = DEFAULT_TOOLTIP_TEMPLATE, - text = 'some text', - } = {}) { - wrapper = mount( - { - directives: { tooltip }, - data: () => ({ tooltip: text }), - template, - }, - { attachTo: document.body }, - ); - } - - async function showTooltip() { - $(wrapper.vm.$el).tooltip('show'); - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - } - - function findTooltipInnerHtml() { - return document.querySelector('.tooltip-inner').innerHTML; - } - - function findTooltipHtml() { - return document.querySelector('.tooltip').innerHTML; - } - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('with a single tooltip', () => { - it('should have tooltip plugin applied', () => { - createTooltipContainer(); - - expect($(wrapper.vm.$el).data('bs.tooltip')).toBeDefined(); - }); - - it('displays the title as tooltip', () => { - createTooltipContainer(); - - $(wrapper.vm.$el).tooltip('show'); - - jest.runOnlyPendingTimers(); - - const tooltipElement = document.querySelector('.tooltip-inner'); - - expect(tooltipElement.textContent).toContain('some text'); - }); - - it.each` - condition | template | sanitize - ${'does not contain any html'} | ${DEFAULT_TOOLTIP_TEMPLATE} | ${false} - ${'contains html'} | ${HTML_TOOLTIP_TEMPLATE} | ${true} - `('passes sanitize=$sanitize if the tooltip $condition', ({ template, sanitize }) => { - createTooltipContainer({ template }); - - expect($(wrapper.vm.$el).data('bs.tooltip').config.sanitize).toEqual(sanitize); - }); - - it('updates a visible tooltip', async () => { - createTooltipContainer(); - - $(wrapper.vm.$el).tooltip('show'); - jest.runOnlyPendingTimers(); - - const tooltipElement = document.querySelector('.tooltip-inner'); - - wrapper.vm.tooltip = 'other text'; - - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - expect(tooltipElement.textContent).toContain('other text'); - }); - - describe('tooltip sanitization', () => { - it('reads tooltip content as text if data-html is not passed', async () => { - createTooltipContainer({ text: 'sample text<script>alert("XSS!!")</script>' }); - - await showTooltip(); - - const result = findTooltipInnerHtml(); - expect(result).toEqual('sample text<script>alert("XSS!!")</script>'); - }); - - it('sanitizes tooltip if data-html is passed', async () => { - createTooltipContainer({ - template: HTML_TOOLTIP_TEMPLATE, - text: 'sample text<script>alert("XSS!!")</script>', - }); - - await showTooltip(); - - const result = findTooltipInnerHtml(); - expect(result).toEqual('sample text'); - expect(result).not.toContain('XSS!!'); - }); - - it('sanitizes tooltip if data-template is passed', async () => { - const tooltipTemplate = escape( - '<div class="tooltip" role="tooltip"><div onclick="alert(\'XSS!\')" class="arrow"></div><div class="tooltip-inner"></div></div>', - ); - - createTooltipContainer({ - template: `<div v-tooltip :title="tooltip" data-html="false" data-template="${tooltipTemplate}"></div>`, - }); - - await showTooltip(); - - const result = findTooltipHtml(); - expect(result).toEqual( - // objectionable element is removed - '<div class="arrow"></div><div class="tooltip-inner">some text</div>', - ); - expect(result).not.toContain('XSS!!'); - }); - }); - }); - - describe('with multiple tooltips', () => { - beforeEach(() => { - createTooltipContainer({ - template: ` - <div> - <div - v-tooltip - class="js-look-for-tooltip" - title="foo"> - </div> - <div - v-tooltip - title="bar"> - </div> - </div> - `, - }); - }); - - it('should have tooltip plugin applied to all instances', () => { - expect($(wrapper.vm.$el).find('.js-look-for-tooltip').data('bs.tooltip')).toBeDefined(); - }); - }); -}); |