Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/api.js22
-rw-r--r--app/assets/javascripts/invite_members/components/group_select.vue103
-rw-r--r--app/assets/javascripts/invite_members/components/invite_group_trigger.vue34
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue131
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue4
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue4
-rw-r--r--app/assets/javascripts/invite_members/constants.js2
-rw-r--r--app/assets/javascripts/invite_members/init_invite_group_trigger.js20
-rw-r--r--app/assets/javascripts/jira_connect/components/app.vue6
-rw-r--r--app/assets/javascripts/jira_connect/index.js1
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js28
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue7
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js35
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/lists.scss6
-rw-r--r--app/assets/stylesheets/framework/tooltips.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss56
-rw-r--r--app/views/groups/group_members/index.html.haml5
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml83
-rw-r--r--app/views/layouts/jira_connect.html.haml3
-rw-r--r--app/views/projects/project_members/index.html.haml5
-rw-r--r--app/workers/all_queues.yml16
-rw-r--r--app/workers/releases/create_evidence_worker.rb1
-rw-r--r--changelogs/unreleased/bvl-fix-create-release-worker-is-not-cron.yml5
-rw-r--r--changelogs/unreleased/jira-connect-app-style-cleanup.yml5
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--db/post_migrate/20210217100728_move_create_release_evidence_queue_out_of_cronjob_namespace.rb16
-rw-r--r--db/schema_migrations/202102171007281
-rw-r--r--doc/development/fe_guide/style/vue.md36
-rw-r--r--doc/development/prometheus.md8
-rw-r--r--doc/development/snowplow.md1
-rw-r--r--doc/integration/elasticsearch.md1
-rw-r--r--doc/update/index.md27
-rw-r--r--doc/user/packages/workflows/monorepo.md9
-rw-r--r--doc/user/project/merge_requests/getting_started.md2
-rw-r--r--doc/user/project/milestones/burndown_charts.md8
-rw-r--r--locale/gitlab.pot53
-rw-r--r--qa/qa.rb1
-rw-r--r--qa/qa/flow/project.rb10
-rw-r--r--qa/qa/page/component/invite_members_modal.rb80
-rw-r--r--qa/qa/page/group/members.rb29
-rw-r--r--qa/qa/page/project/members.rb35
-rw-r--r--qa/qa/resource/members.rb13
-rw-r--r--qa/qa/resource/personal_access_token.rb2
-rw-r--r--qa/qa/resource/project.rb4
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/login/2fa_recovery_spec.rb1
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/login/log_in_with_2fa_spec.rb1
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/project/add_project_member_spec.rb9
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/project/import_github_repo_spec.rb1
-rw-r--r--qa/qa/specs/features/browser_ui/1_manage/project/protected_tags_spec.rb4
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb1
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb2
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb1
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/issue/real_time_assignee_spec.rb1
-rw-r--r--qa/qa/specs/features/browser_ui/3_create/repository/move_project_create_fork_spec.rb1
-rw-r--r--spec/features/groups/members/manage_members_spec.rb12
-rw-r--r--spec/frontend/api_spec.js48
-rw-r--r--spec/frontend/invite_members/components/group_select_spec.js90
-rw-r--r--spec/frontend/invite_members/components/invite_group_trigger_spec.js50
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js133
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js4
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js11
-rw-r--r--spec/frontend/vue_shared/directives/tooltip_spec.js157
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"
diff --git a/qa/qa.rb b/qa/qa.rb
index 1cf6d7b79f7..a1ea71c81cc 100644
--- a/qa/qa.rb
+++ b/qa/qa.rb
@@ -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&lt;script&gt;alert("XSS!!")&lt;/script&gt;');
- });
-
- 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();
- });
- });
-});