summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/delete_label_modal.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal.vue146
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue105
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js35
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_modal.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/registry_search.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/remove_member_modal.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql8
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql14
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue246
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue249
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue88
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/url_sync.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/user_date.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue25
46 files changed, 1186 insertions, 663 deletions
diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
index 3d49a1cb1c5..a74e9d97143 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -7,6 +7,7 @@ import {
splitCamelCase,
} from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
+import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants';
const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!';
const tdClass = 'gl-border-gray-100! gl-p-5!';
@@ -42,6 +43,11 @@ export default {
type: Boolean,
required: true,
},
+ statuses: {
+ type: Object,
+ required: false,
+ default: () => PAGE_CONFIG.OPERATIONS.STATUSES,
+ },
},
fields: [
{
@@ -71,6 +77,8 @@ export default {
let value;
if (fieldName === 'environment') {
value = fieldValue?.name;
+ } else if (fieldName === 'status') {
+ value = this.statuses[fieldValue] || fieldValue;
} else {
value = fieldValue;
}
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 82b3545117f..08d3e163257 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -44,6 +44,16 @@ export default {
required: false,
default: () => [],
},
+ selectedClass: {
+ type: String,
+ required: false,
+ default: 'selected',
+ },
+ },
+ data() {
+ return {
+ isMenuOpen: false,
+ };
},
computed: {
groupedDefaultAwards() {
@@ -68,7 +78,7 @@ export default {
methods: {
getAwardClassBindings(awardList) {
return {
- selected: this.hasReactionByCurrentUser(awardList),
+ [this.selectedClass]: this.hasReactionByCurrentUser(awardList),
disabled: this.currentUserId === NO_USER_ID,
};
},
@@ -147,6 +157,11 @@ export default {
const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName;
this.$emit('award', parsedName);
+
+ if (document.activeElement) document.activeElement.blur();
+ },
+ setIsMenuOpen(menuOpen) {
+ this.isMenuOpen = menuOpen;
},
},
};
@@ -172,8 +187,10 @@ export default {
<div v-if="canAwardEmoji" class="award-menu-holder">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
- toggle-class="add-reaction-button gl-relative!"
+ :toggle-class="['add-reaction-button gl-relative!', { 'is-active': isMenuOpen }]"
@click="handleAward"
+ @shown="setIsMenuOpen(true)"
+ @hidden="setIsMenuOpen(false)"
>
<template #button-content>
<span class="reaction-control-icon reaction-control-icon-neutral">
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
index db61d0f6b05..9c2ed5abf04 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -11,6 +11,16 @@ export default {
type: String,
required: true,
},
+ isRawContent: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ fileName: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
mounted() {
eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index 5bb31f55e6c..f477610ff1d 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -1,14 +1,17 @@
<script>
/* eslint-disable vue/no-v-html */
import { GlIcon } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
export default {
components: {
GlIcon,
+ EditorLite: () =>
+ import(/* webpackChunkName: 'EditorLite' */ '~/vue_shared/components/editor_lite.vue'),
},
- mixins: [ViewerMixin],
+ mixins: [ViewerMixin, glFeatureFlagsMixin()],
inject: ['blobHash'],
data() {
return {
@@ -19,6 +22,9 @@ export default {
lineNumbers() {
return this.content.split('\n').length;
},
+ refactorBlobViewerEnabled() {
+ return this.glFeatures.refactorBlobViewer;
+ },
},
mounted() {
const { hash } = window.location;
@@ -45,27 +51,31 @@ export default {
};
</script>
<template>
- <div
- class="file-content code js-syntax-highlight"
- data-qa-selector="file_content"
- :class="$options.userColorScheme"
- >
- <div class="line-numbers">
- <a
- v-for="line in lineNumbers"
- :id="`L${line}`"
- :key="line"
- class="diff-line-num js-line-number"
- :href="`#LC${line}`"
- :data-line-number="line"
- @click="scrollToLine(`#LC${line}`)"
- >
- <gl-icon :size="12" name="link" />
- {{ line }}
- </a>
- </div>
- <div class="blob-content">
- <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
+ <div>
+ <editor-lite
+ v-if="isRawContent && refactorBlobViewerEnabled"
+ :value="content"
+ :file-name="fileName"
+ :editor-options="{ readOnly: true }"
+ />
+ <div v-else class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
+ <div class="line-numbers">
+ <a
+ v-for="line in lineNumbers"
+ :id="`L${line}`"
+ :key="line"
+ class="diff-line-num js-line-number"
+ :href="`#LC${line}`"
+ :data-line-number="line"
+ @click="scrollToLine(`#LC${line}`)"
+ >
+ <gl-icon :size="12" name="link" />
+ {{ line }}
+ </a>
+ </div>
+ <div class="blob-content">
+ <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
index cd5f63afc79..f14e1992901 100644
--- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
@@ -56,6 +56,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
+ :aria-label="$options.copyURLTooltip"
:data-clipboard-text="sshLink"
data-qa-selector="copy_ssh_url_button"
icon="copy-to-clipboard"
@@ -75,6 +76,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
+ :aria-label="$options.copyURLTooltip"
:data-clipboard-text="httpLink"
data-qa-selector="copy_http_url_button"
icon="copy-to-clipboard"
diff --git a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue
new file mode 100644
index 00000000000..1ff0938d086
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlModal, GlSprintf, GlButton } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlButton,
+ },
+ props: {
+ selector: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ labelName: '',
+ subjectName: '',
+ destroyPath: '',
+ modalId: uniqueId('modal-delete-label-'),
+ };
+ },
+ mounted() {
+ document.querySelectorAll(this.selector).forEach((button) => {
+ button.addEventListener('click', (e) => {
+ e.preventDefault();
+
+ const { labelName, subjectName, destroyPath } = button.dataset;
+ this.labelName = labelName;
+ this.subjectName = subjectName;
+ this.destroyPath = destroyPath;
+ this.openModal();
+ });
+ });
+ },
+ methods: {
+ openModal() {
+ this.$refs.modal.show();
+ },
+ closeModal() {
+ this.$refs.modal.hide();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal ref="modal" :modal-id="modalId">
+ <template #modal-title>
+ <gl-sprintf :message="__('Delete label: %{labelName}')">
+ <template #labelName>
+ {{ labelName }}
+ </template>
+ </gl-sprintf>
+ </template>
+ <gl-sprintf
+ :message="
+ __(
+ `%{strongStart}${labelName}%{strongEnd} will be permanently deleted from ${subjectName}. This cannot be undone.`,
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ <template #modal-footer>
+ <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button>
+ <gl-button
+ category="primary"
+ variant="danger"
+ :href="destroyPath"
+ data-method="delete"
+ data-testid="delete-button"
+ >{{ __('Delete label') }}</gl-button
+ >
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
deleted file mode 100644
index 3f55f43edbb..00000000000
--- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-<script>
-/* eslint-disable vue/require-default-prop */
-import { __ } from '~/locale';
-
-export default {
- name: 'DeprecatedModal', // use GlModal instead
-
- props: {
- id: {
- type: String,
- required: false,
- },
- title: {
- type: String,
- required: false,
- },
- text: {
- type: String,
- required: false,
- },
- hideFooter: {
- type: Boolean,
- required: false,
- default: false,
- },
- kind: {
- type: String,
- required: false,
- default: 'primary',
- },
- modalDialogClass: {
- type: String,
- required: false,
- default: '',
- },
- closeKind: {
- type: String,
- required: false,
- default: 'default',
- },
- closeButtonLabel: {
- type: String,
- required: false,
- default: __('Cancel'),
- },
- primaryButtonLabel: {
- type: String,
- required: false,
- default: '',
- },
- secondaryButtonLabel: {
- type: String,
- required: false,
- default: '',
- },
- submitDisabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- computed: {
- btnKindClass() {
- return {
- [`btn-${this.kind}`]: true,
- };
- },
- btnCancelKindClass() {
- return {
- [`btn-${this.closeKind}`]: true,
- };
- },
- },
-
- methods: {
- emitCancel(event) {
- this.$emit('cancel', event);
- },
- emitSubmit(event) {
- this.$emit('submit', event);
- },
- },
-};
-</script>
-
-<template>
- <div class="modal-open">
- <div :id="id" :class="id ? '' : 'd-block'" class="modal" role="dialog" tabindex="-1">
- <div :class="modalDialogClass" class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <slot name="header">
- <h4 class="modal-title float-left">{{ title }}</h4>
- <button
- type="button"
- class="close float-right"
- data-dismiss="modal"
- :aria-label="__('Close')"
- @click="emitCancel($event)"
- >
- <span aria-hidden="true">&times;</span>
- </button>
- </slot>
- </div>
- <div class="modal-body">
- <slot :text="text" name="body">
- <p>{{ text }}</p>
- </slot>
- </div>
- <div v-if="!hideFooter" class="modal-footer">
- <button
- :class="btnCancelKindClass"
- type="button"
- class="btn"
- data-dismiss="modal"
- @click="emitCancel($event)"
- >
- {{ closeButtonLabel }}
- </button>
-
- <slot v-if="secondaryButtonLabel" name="secondary-button">
- <button v-if="secondaryButtonLabel" type="button" class="btn" data-dismiss="modal">
- {{ secondaryButtonLabel }}
- </button>
- </slot>
-
- <button
- v-if="primaryButtonLabel"
- :disabled="submitDisabled"
- :class="btnKindClass"
- type="button"
- class="btn js-primary-button"
- data-dismiss="modal"
- data-qa-selector="save_changes_button"
- @click="emitSubmit($event)"
- >
- {{ primaryButtonLabel }}
- </button>
- </div>
- </div>
- </div>
- </div>
- <div v-if="!id" class="modal-backdrop fade show"></div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 4ec54b33bce..fbadb202d51 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -3,6 +3,7 @@ import { GlIcon } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import Mousetrap from 'mousetrap';
import VirtualList from 'vue-virtual-scroll-list';
+import { keysFor, MR_GO_TO_FILE } from '~/behaviors/shortcuts/keybindings';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import Item from './item.vue';
@@ -128,7 +129,7 @@ export default {
this.focusedIndex = 0;
}
- Mousetrap.bind(['t', 'mod+p'], (e) => {
+ Mousetrap.bind(keysFor(MR_GO_TO_FILE), (e) => {
if (e.preventDefault) {
e.preventDefault();
}
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index f7cfb59be01..e622b505570 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -128,6 +128,7 @@ const fileExtensionIcons = {
c: 'c',
m: 'c',
h: 'h',
+ 'c++': 'cpp',
cc: 'cpp',
cpp: 'cpp',
mm: 'cpp',
@@ -402,14 +403,15 @@ const fileNameIcons = {
'gradle.properties': 'gradle',
gradlew: 'gradle',
'gradle-wrapper.properties': 'gradle',
- license: 'certificate',
- 'license.md': 'certificate',
- 'license.md.rendered': 'certificate',
- 'license.txt': 'certificate',
- licence: 'certificate',
- 'licence.md': 'certificate',
- 'licence.md.rendered': 'certificate',
- 'licence.txt': 'certificate',
+ COPYING: 'certificate',
+ 'COPYING.LESSER': 'certificate',
+ LICENSE: 'certificate',
+ LICENCE: 'certificate',
+ 'LICENSE.md': 'certificate',
+ 'LICENCE.md': 'certificate',
+ 'LICENSE.txt': 'certificate',
+ 'LICENCE.txt': 'certificate',
+ '.gitlab-license': 'certificate',
dockerfile: 'docker',
'docker-compose.yml': 'docker',
'.mailmap': 'email',
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 97a8f681faf..107ced550c1 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -58,7 +58,7 @@ export default {
type: String,
required: false,
default: '',
- validator: (value) => value === '' || /(_desc)|(_asc)/g.test(value),
+ validator: (value) => value === '' || /(_desc)|(_asc)/gi.test(value),
},
showCheckbox: {
type: Boolean,
@@ -363,6 +363,7 @@ export default {
<gl-button
v-gl-tooltip
:title="sortDirectionTooltip"
+ :aria-label="sortDirectionTooltip"
:icon="sortDirectionIcon"
class="flex-shrink-1"
@click="handleSortDirectionClick"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index d53c829a48e..aeb698a3adb 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -45,6 +45,9 @@ export default {
activeAuthor() {
return this.authors.find((author) => author.username.toLowerCase() === this.currentValue);
},
+ activeAuthorAvatar() {
+ return this.avatarUrl(this.activeAuthor);
+ },
},
watch: {
active: {
@@ -74,6 +77,9 @@ export default {
this.loading = false;
});
},
+ avatarUrl(author) {
+ return author.avatarUrl || author.avatar_url;
+ },
searchAuthors: debounce(function debouncedSearch({ data }) {
this.fetchAuthorBySearchTerm(data);
}, DEBOUNCE_DELAY),
@@ -92,7 +98,7 @@ export default {
<gl-avatar
v-if="activeAuthor"
:size="16"
- :src="activeAuthor.avatar_url"
+ :src="activeAuthorAvatar"
shape="circle"
class="gl-mr-2"
/>
@@ -115,7 +121,7 @@ export default {
:value="author.username"
>
<div class="d-flex">
- <gl-avatar :size="32" :src="author.avatar_url" />
+ <gl-avatar :size="32" :src="avatarUrl(author)" />
<div>
<div>{{ author.name }}</div>
<div>@{{ author.username }}</div>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
new file mode 100644
index 00000000000..98190d716c9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -0,0 +1,105 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { __ } from '~/locale';
+
+import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants';
+import { stripQuotes } from '../filtered_search_utils';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ emojis: this.config.initialEmojis || [],
+ defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeEmoji() {
+ return this.emojis.find(
+ (emoji) => emoji.name.toLowerCase() === stripQuotes(this.currentValue),
+ );
+ },
+ },
+ methods: {
+ fetchEmojiBySearchTerm(searchTerm) {
+ this.loading = true;
+ this.config
+ .fetchEmojis(searchTerm)
+ .then((res) => {
+ this.emojis = Array.isArray(res) ? res : res.data;
+ })
+ .catch(() => createFlash(__('There was a problem fetching emojis.')))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchEmojis: debounce(function debouncedSearch({ data }) {
+ this.fetchEmojiBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchEmojis"
+ >
+ <template #view="{ inputValue }">
+ <gl-emoji v-if="activeEmoji" :data-name="activeEmoji.name" />
+ <span v-else>{{ inputValue }}</span>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="emoji in defaultEmojis"
+ :key="emoji.value"
+ :value="emoji.value"
+ >
+ {{ emoji.value }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider v-if="defaultEmojis.length" />
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="emoji in emojis"
+ :key="emoji.name"
+ :value="emoji.name"
+ >
+ <div class="gl-display-flex">
+ <gl-emoji :data-name="emoji.name" />
+ <span class="gl-ml-3">{{ emoji.name }}</span>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
new file mode 100644
index 00000000000..101c7150c55
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import createFlash from '~/flash';
+import { isNumeric } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import { DEBOUNCE_DELAY } from '../constants';
+import { stripQuotes } from '../filtered_search_utils';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ epics: this.config.initialEpics || [],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ /*
+ * When the URL contains the epic_iid, we'd get: '123'
+ */
+ if (isNumeric(this.value.data)) {
+ return parseInt(this.value.data, 10);
+ }
+
+ /*
+ * When the token is added in current session it'd be: 'Foo::&123'
+ */
+ const id = this.value.data.split('::&')[1];
+
+ if (id) {
+ return parseInt(id, 10);
+ }
+
+ return this.value.data;
+ },
+ activeEpic() {
+ const currentValueIsString = typeof this.currentValue === 'string';
+ return this.epics.find(
+ (epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue,
+ );
+ },
+ },
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.epics.length) {
+ this.searchEpics({ data: this.currentValue });
+ }
+ },
+ },
+ },
+ methods: {
+ fetchEpicsBySearchTerm(searchTerm = '') {
+ this.loading = true;
+ this.config
+ .fetchEpics(searchTerm)
+ .then(({ data }) => {
+ this.epics = data;
+ })
+ .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ fetchSingleEpic(iid) {
+ this.loading = true;
+ this.config
+ .fetchSingleEpic(iid)
+ .then(({ data }) => {
+ this.epics = [data];
+ })
+ .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchEpics: debounce(function debouncedSearch({ data }) {
+ if (isNumeric(data)) {
+ return this.fetchSingleEpic(data);
+ }
+ return this.fetchEpicsBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+
+ getEpicValue(epic) {
+ return `${epic.title}::&${epic.iid}`;
+ },
+ },
+ stripQuotes,
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchEpics"
+ >
+ <template #view="{ inputValue }">
+ <span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span>
+ </template>
+ <template #suggestions>
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="epic in epics"
+ :key="epic.id"
+ :value="getEpicValue(epic)"
+ >
+ <div>{{ epic.title }}</div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 9c2a644b7a9..76b005772ec 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -46,7 +46,7 @@ export default {
},
activeLabel() {
return this.labels.find(
- (label) => label.title.toLowerCase() === stripQuotes(this.currentValue),
+ (label) => this.getLabelName(label).toLowerCase() === stripQuotes(this.currentValue),
);
},
containerStyle() {
@@ -69,6 +69,21 @@ export default {
},
},
methods: {
+ /**
+ * There's an inconsistency between private and public API
+ * for labels where label name is included in a different
+ * property;
+ *
+ * Private API => `label.title`
+ * Public API => `label.name`
+ *
+ * This method allows compatibility as there may be instances
+ * where `config.fetchLabels` provided externally may still be
+ * using either of the two APIs.
+ */
+ getLabelName(label) {
+ return label.name || label.title;
+ },
fetchLabelBySearchTerm(searchTerm) {
this.loading = true;
this.config
@@ -85,7 +100,7 @@ export default {
});
},
searchLabels: debounce(function debouncedSearch({ data }) {
- this.fetchLabelBySearchTerm(data);
+ if (!this.loading) this.fetchLabelBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
@@ -100,7 +115,7 @@ export default {
>
<template #view-token="{ inputValue, cssClasses, listeners }">
<gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners"
- >~{{ activeLabel ? activeLabel.title : inputValue }}</gl-token
+ >~{{ activeLabel ? getLabelName(activeLabel) : inputValue }}</gl-token
>
</template>
<template #suggestions>
@@ -114,13 +129,17 @@ export default {
<gl-dropdown-divider v-if="defaultLabels.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
- <gl-filtered-search-suggestion v-for="label in labels" :key="label.id" :value="label.title">
- <div class="gl-display-flex">
+ <gl-filtered-search-suggestion
+ v-for="label in labels"
+ :key="label.id"
+ :value="getLabelName(label)"
+ >
+ <div class="gl-display-flex gl-align-items-center">
<span
:style="{ backgroundColor: label.color }"
class="gl-display-inline-block mr-2 p-2"
></span>
- <div>{{ label.title }}</div>
+ <div>{{ getLabelName(label) }}</div>
</div>
</gl-filtered-search-suggestion>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue
deleted file mode 100644
index b649dac029a..00000000000
--- a/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { GlToggle } from '@gitlab/ui';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
-export default {
- name: 'GlToggleVuex',
- components: {
- GlToggle,
- },
- props: {
- stateProperty: {
- type: String,
- required: true,
- },
- storeModule: {
- type: String,
- required: false,
- default: null,
- },
- setAction: {
- type: String,
- required: false,
- default() {
- return `set${capitalizeFirstCharacter(this.stateProperty)}`;
- },
- },
- },
- computed: {
- value: {
- get() {
- const { state } = this.$store;
- const { stateProperty, storeModule } = this;
- return storeModule ? state[storeModule][stateProperty] : state[stateProperty];
- },
- set(value) {
- const { stateProperty, storeModule, setAction } = this;
- const action = storeModule ? `${storeModule}/${setAction}` : setAction;
- this.$store.dispatch(action, { key: stateProperty, value });
- },
- },
- },
-};
-</script>
-
-<template>
- <gl-toggle v-model="value">
- <slot v-bind="{ value }"></slot>
- </gl-toggle>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index b4cac13168a..f169921d8a6 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -121,13 +121,7 @@ export default {
:title="user.email"
class="js-user-link commit-committer-link"
>
- <user-avatar-image
- :img-src="avatarUrl"
- :img-alt="userAvatarAltText"
- :tooltip-text="user.name"
- :img-size="24"
- />
-
+ <user-avatar-image :img-src="avatarUrl" :img-alt="userAvatarAltText" :size="24" />
{{ user.name }}
</gl-link>
<gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]">
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index 051c65bae70..f36b9107a6e 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlPopover } from '@gitlab/ui';
+import { GlButton, GlPopover, GlSafeHtmlDirective } from '@gitlab/ui';
/**
* Render a button with a question mark icon
@@ -11,6 +11,9 @@ export default {
GlButton,
GlPopover,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
options: {
type: Object,
@@ -22,15 +25,13 @@ export default {
</script>
<template>
<span>
- <gl-button ref="popoverTrigger" variant="link" icon="question" tabindex="0" />
- <gl-popover triggers="hover focus" :target="() => $refs.popoverTrigger.$el" v-bind="options">
- <template #title>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <span v-html="options.title"></span>
+ <gl-button ref="popoverTrigger" variant="link" icon="question" :aria-label="__('Help')" />
+ <gl-popover :target="() => $refs.popoverTrigger.$el" v-bind="options">
+ <template v-if="options.title" #title>
+ <span v-safe-html="options.title"></span>
</template>
<template #default>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div v-html="options.content"></div>
+ <div v-safe-html="options.content"></div>
</template>
</gl-popover>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js
new file mode 100644
index 00000000000..b115b1fb34b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js
@@ -0,0 +1,35 @@
+/**
+ * Return the union of the given components' props options. Required props take
+ * precendence over non-required props of the same name.
+ *
+ * This makes two assumptions:
+ * - All given components define their props in verbose object format.
+ * - The components all agree on the `type` of a common prop.
+ *
+ * @param {object[]} components The components to derive the union from.
+ * @returns {object} The union of the props of the given components.
+ */
+export const propsUnion = (components) =>
+ components.reduce((acc, component) => {
+ Object.entries(component.props ?? {}).forEach(([propName, propOptions]) => {
+ if (process.env.NODE_ENV !== 'production') {
+ if (typeof propOptions !== 'object' || !('type' in propOptions)) {
+ throw new Error(
+ `Cannot create props union: expected verbose prop options for prop "${propName}"`,
+ );
+ }
+
+ if (propName in acc && acc[propName]?.type !== propOptions?.type) {
+ throw new Error(
+ `Cannot create props union: incompatible prop types for prop "${propName}"`,
+ );
+ }
+ }
+
+ if (!(propName in acc) || propOptions.required) {
+ acc[propName] = propOptions;
+ }
+ });
+
+ return acc;
+ }, {});
diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
index 10887aee689..90ac20fe748 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -34,6 +34,7 @@ export default {
boundary="window"
right
menu-class="gl-w-full!"
+ data-qa-selector="apply_suggestion_button"
@shown="$refs.commitMessage.$el.focus()"
>
<gl-dropdown-form class="gl-px-4! gl-m-0!">
@@ -44,12 +45,14 @@ export default {
v-model="message"
:placeholder="defaultCommitMessage"
submit-on-enter
+ data-qa-selector="commit_message_textbox"
@submit="onApply"
/>
<gl-button
class="gl-w-auto! gl-mt-3 gl-text-center! gl-hover-text-white! gl-transition-medium! float-right"
category="primary"
variant="success"
+ data-qa-selector="commit_with_custom_message_button"
@click="onApply"
>
{{ __('Apply') }}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 25d01dc550f..80b7a9b7d05 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -62,6 +62,11 @@ export default {
required: false,
default: true,
},
+ uploadsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
enableAutocomplete: {
type: Boolean,
required: false,
@@ -72,6 +77,11 @@ export default {
required: false,
default: null,
},
+ lines: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
note: {
type: Object,
required: false,
@@ -110,6 +120,20 @@ export default {
return this.referencedUsers.length >= referencedUsersThreshold;
},
lineContent() {
+ if (this.lines.length) {
+ return this.lines
+ .map((line) => {
+ const { rich_text: richText, text } = line;
+
+ if (text) {
+ return text;
+ }
+
+ return unescape(stripHtml(richText).replace(/\n/g, ''));
+ })
+ .join('\\n');
+ }
+
if (this.line) {
const { rich_text: richText, text } = this.line;
@@ -144,6 +168,9 @@ export default {
false,
);
},
+ suggestionsStartIndex() {
+ return Math.max(this.lines.length - 1, 0);
+ },
},
watch: {
isSubmitting(isSubmitting) {
@@ -229,12 +256,14 @@ export default {
ref="gl-form"
:class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
class="js-vue-markdown-field md-area position-relative gfm-form"
+ :data-uploads-path="uploadsPath"
>
<markdown-header
:preview-markdown="previewMarkdown"
:line-content="lineContent"
:can-suggest="canSuggest"
:show-suggest-popover="showSuggestPopover"
+ :suggestion-start-index="suggestionsStartIndex"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 5bc1786d692..01cf0beea3a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,6 +1,7 @@
<script>
import { GlPopover, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import $ from 'jquery';
+import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
@@ -36,6 +37,11 @@ export default {
required: false,
default: false,
},
+ suggestionStartIndex: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -53,7 +59,9 @@ export default {
].join('\n');
},
mdSuggestion() {
- return ['```suggestion:-0+0', `{text}`, '```'].join('\n');
+ return [['```', `suggestion:-${this.suggestionStartIndex}+0`].join(''), `{text}`, '```'].join(
+ '\n',
+ );
},
isMac() {
// Accessing properties using ?. to allow tests to use
@@ -116,6 +124,11 @@ export default {
.catch(() => {});
},
},
+ shortcuts: {
+ bold: keysFor(BOLD_TEXT),
+ italic: keysFor(ITALIC_TEXT),
+ link: keysFor(LINK_TEXT),
+ },
};
</script>
@@ -143,7 +156,7 @@ export default {
:button-title="
sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey })
"
- shortcuts="mod+b"
+ :shortcuts="$options.shortcuts.bold"
icon="bold"
/>
<toolbar-button
@@ -151,7 +164,7 @@ export default {
:button-title="
sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey })
"
- shortcuts="mod+i"
+ :shortcuts="$options.shortcuts.italic"
icon="italic"
/>
<toolbar-button
@@ -208,7 +221,7 @@ export default {
:button-title="
sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey })
"
- shortcuts="mod+k"
+ :shortcuts="$options.shortcuts.link"
icon="link"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 7c28e74e256..83b8a6ae562 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -1,13 +1,11 @@
<script>
import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ApplySuggestion from './apply_suggestion.vue';
export default {
components: { GlIcon, GlButton, GlLoadingIcon, ApplySuggestion },
directives: { 'gl-tooltip': GlTooltipDirective },
- mixins: [glFeatureFlagsMixin()],
props: {
batchSuggestionsCount: {
type: Number,
@@ -59,9 +57,6 @@ export default {
};
},
computed: {
- canBeBatched() {
- return Boolean(this.glFeatures.batchSuggestions);
- },
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
@@ -118,7 +113,7 @@ export default {
<gl-loading-icon class="d-flex-center mr-2" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
- <div v-else-if="canApply && canBeBatched && isBatched" class="d-flex align-items-center">
+ <div v-else-if="canApply && isBatched" class="d-flex align-items-center">
<gl-button
class="btn-inverted js-remove-from-batch-btn btn-grouped"
:disabled="isApplying"
@@ -142,7 +137,7 @@ export default {
</div>
<div v-else class="d-flex align-items-center">
<gl-button
- v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton"
+ v-if="suggestionsCount > 1 && !isDisableButton"
class="btn-inverted js-add-to-batch-btn btn-grouped"
data-qa-selector="add_suggestion_batch_button"
:disabled="isDisableButton"
@@ -152,6 +147,7 @@ export default {
</gl-button>
<apply-suggestion
v-if="isLoggedIn"
+ v-gl-tooltip.viewport="tooltipMessage"
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
class="gl-ml-3"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 387b100a04f..7393a8791b7 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,13 +1,18 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
+import { isExperimentVariant } from '~/experimentation/utils';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
export default {
+ inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT,
components: {
GlButton,
GlLink,
GlLoadingIcon,
GlSprintf,
GlIcon,
+ InviteMembersTrigger,
},
props: {
markdownDocsPath: {
@@ -29,6 +34,9 @@ export default {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
+ inviteCommentEnabled() {
+ return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link');
+ },
},
};
</script>
@@ -37,9 +45,9 @@ export default {
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank">{{
- __('Markdown is supported')
- }}</gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank">
+ {{ __('Markdown is supported') }}
+ </gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
<gl-sprintf
@@ -59,6 +67,16 @@ export default {
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
+ <invite-members-trigger
+ v-if="inviteCommentEnabled"
+ classes="gl-mr-3 gl-vertical-align-text-bottom"
+ :display-text="s__('InviteMember|Invite Member')"
+ icon="assignee"
+ variant="link"
+ :track-experiment="$options.inviteMembersInComment"
+ :trigger-source="$options.inviteMembersInComment"
+ data-track-event="comment_invite_click"
+ />
<span class="uploading-progress-container hide">
<gl-icon name="media" />
<span class="attaching-file-message"></span>
diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
index 7b36d57dfbf..38afd56bae6 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -101,6 +101,7 @@ export default {
:data-clipboard-target="target"
:data-clipboard-text="text"
:title="title"
+ :aria-label="title"
:category="category"
icon="copy-to-clipboard"
/>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 50972a8c32c..149909d263e 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -28,6 +28,7 @@ import {
import $ from 'jquery';
import { mapGetters, mapActions, mapState } from 'vuex';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
+import { __ } from '~/locale';
import initMRPopovers from '~/mr_popover/';
import noteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -37,6 +38,9 @@ import TimelineEntryItem from './timeline_entry_item.vue';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
export default {
+ i18n: {
+ deleteButtonLabel: __('Remove description history'),
+ },
name: 'SystemNote',
components: {
GlIcon,
@@ -139,7 +143,8 @@ export default {
<gl-button
v-if="displayDeleteButton"
v-gl-tooltip
- :title="__('Remove description history')"
+ :title="$options.i18n.deleteButtonLabel"
+ :aria-label="$options.i18n.deleteButtonLabel"
variant="default"
category="tertiary"
icon="remove"
diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
new file mode 100644
index 00000000000..ff2847624c5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+
+export default {
+ components: {
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ schedules: {
+ type: Array,
+ required: true,
+ },
+ userName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ title() {
+ return this.isCurrentUser
+ ? s__('OnCallSchedules|You are currently a part of:')
+ : sprintf(s__('OnCallSchedules|User %{name} is currently part of:'), {
+ name: this.userName,
+ });
+ },
+ footer() {
+ return this.isCurrentUser
+ ? s__(
+ 'OnCallSchedules|Removing yourself may put your on-call team at risk of missing a notification.',
+ )
+ : s__(
+ 'OnCallSchedules|Removing this user may put their on-call team at risk of missing a notification.',
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <p data-testid="title">{{ title }}</p>
+
+ <ul data-testid="schedules-list">
+ <li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`">
+ <gl-sprintf
+ :message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')"
+ >
+ <template #schedule>
+ <gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link>
+ </template>
+ <template #project>
+ <gl-link :href="schedule.projectUrl" target="_blank">{{
+ schedule.projectName
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+
+ <p data-testid="footer">{{ footer }}</p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
deleted file mode 100644
index e193883b6e9..00000000000
--- a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import createEventHub from '~/helpers/event_hub_factory';
-
-// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml
-export const callbackName = 'recaptchaDialogCallback';
-
-export const eventHub = createEventHub();
-
-const throwDuplicateCallbackError = () => {
- throw new Error(`${callbackName} is already defined!`);
-};
-
-if (window[callbackName]) {
- throwDuplicateCallbackError();
-}
-
-const callback = () => eventHub.$emit('submit');
-
-Object.defineProperty(window, callbackName, {
- get: () => callback,
- set: throwDuplicateCallbackError,
-});
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
deleted file mode 100644
index fc1f3675a3d..00000000000
--- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<script>
-/* eslint-disable vue/no-v-html */
-import DeprecatedModal from './deprecated_modal.vue';
-import { eventHub } from './recaptcha_eventhub';
-
-export default {
- name: 'RecaptchaModal',
-
- components: {
- DeprecatedModal,
- },
-
- props: {
- html: {
- type: String,
- required: false,
- default: '',
- },
- },
-
- data() {
- return {
- script: {},
- scriptSrc: 'https://www.recaptcha.net/recaptcha/api.js',
- };
- },
-
- watch: {
- html() {
- this.appendRecaptchaScript();
- },
- },
-
- mounted() {
- eventHub.$on('submit', this.submit);
-
- if (this.html) {
- this.appendRecaptchaScript();
- }
- },
-
- beforeDestroy() {
- eventHub.$off('submit', this.submit);
- },
-
- methods: {
- appendRecaptchaScript() {
- this.removeRecaptchaScript();
-
- const script = document.createElement('script');
- script.src = this.scriptSrc;
- script.classList.add('js-recaptcha-script');
- script.async = true;
- script.defer = true;
-
- this.script = script;
-
- document.body.appendChild(script);
- },
-
- removeRecaptchaScript() {
- if (this.script instanceof Element) this.script.remove();
- },
-
- close() {
- this.removeRecaptchaScript();
- this.$emit('close');
- },
-
- submit() {
- this.$el.querySelector('form').submit();
- },
- },
-};
-</script>
-
-<template>
- <deprecated-modal
- :hide-footer="true"
- :title="__('Please solve the reCAPTCHA')"
- kind="warning"
- class="recaptcha-modal js-recaptcha-modal"
- @cancel="close"
- >
- <div slot="body">
- <p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p>
- <div ref="recaptcha" v-html="html"></div>
- </div>
- </deprecated-modal>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
index 62453a25f62..0825c3a76ea 100644
--- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
@@ -1,5 +1,6 @@
<script>
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
const ASCENDING_ORDER = 'asc';
const DESCENDING_ORDER = 'desc';
@@ -45,18 +46,60 @@ export default {
isSortAscending() {
return this.sorting.sort === ASCENDING_ORDER;
},
+ baselineQueryStringFilters() {
+ return this.tokens.reduce((acc, curr) => {
+ acc[curr.type] = '';
+ return acc;
+ }, {});
+ },
},
methods: {
+ generateQueryData({ sorting = {}, filter = [] } = {}) {
+ // Ensure that we clean up the query when we remove a token from the search
+ const result = { ...this.baselineQueryStringFilters, ...sorting, search: [] };
+
+ filter.forEach((f) => {
+ if (f.type === FILTERED_SEARCH_TERM) {
+ result.search.push(f.value.data);
+ } else {
+ result[f.type] = f.value.data;
+ }
+ });
+ return result;
+ },
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
+ const newQueryString = this.generateQueryData({
+ sorting: { ...this.sorting, sort },
+ filter: this.filter,
+ });
this.$emit('sorting:changed', { sort });
+ this.$emit('query:changed', newQueryString);
},
onSortItemClick(item) {
+ const newQueryString = this.generateQueryData({
+ sorting: { ...this.sorting, orderBy: item },
+ filter: this.filter,
+ });
this.$emit('sorting:changed', { orderBy: item });
+ this.$emit('query:changed', newQueryString);
+ },
+ submitSearch() {
+ const newQueryString = this.generateQueryData({
+ sorting: this.sorting,
+ filter: this.filter,
+ });
+ this.$emit('filter:submit');
+ this.$emit('query:changed', newQueryString);
},
clearSearch() {
+ const newQueryString = this.generateQueryData({
+ sorting: this.sorting,
+ });
+
this.$emit('filter:changed', []);
this.$emit('filter:submit');
+ this.$emit('query:changed', newQueryString);
},
},
};
@@ -69,7 +112,7 @@ export default {
class="gl-mr-4 gl-flex-fill-1"
:placeholder="__('Filter results')"
:available-tokens="tokens"
- @submit="$emit('filter:submit')"
+ @submit="submitSearch"
@clear="clearSearch"
/>
<gl-sorting
diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
index 88d1b15aee3..dff3a6a8c3f 100644
--- a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
@@ -1,8 +1,10 @@
<script>
import { GlFormCheckbox, GlModal } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { parseBoolean } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
-import { __ } from '~/locale';
+import { s__, __ } from '~/locale';
+import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
export default {
actionCancel: {
@@ -12,6 +14,7 @@ export default {
components: {
GlFormCheckbox,
GlModal,
+ OncallSchedulesList,
},
data() {
return {
@@ -22,8 +25,20 @@ export default {
isAccessRequest() {
return parseBoolean(this.modalData.isAccessRequest);
},
+ isInvite() {
+ return parseBoolean(this.modalData.isInvite);
+ },
+ isGroupMember() {
+ return this.modalData.memberType === 'GroupMember';
+ },
actionText() {
- return this.isAccessRequest ? __('Deny access request') : __('Remove member');
+ if (this.isAccessRequest) {
+ return __('Deny access request');
+ } else if (this.isInvite) {
+ return s__('Member|Revoke invite');
+ }
+
+ return __('Remove member');
},
actionPrimary() {
return {
@@ -33,6 +48,21 @@ export default {
},
};
},
+ showUnassignIssuablesCheckbox() {
+ return !this.isAccessRequest && !this.isInvite;
+ },
+ isPartOfOncallSchedules() {
+ return !this.isAccessRequest && this.oncallSchedules.schedules?.length;
+ },
+ oncallSchedules() {
+ let schedules = {};
+ try {
+ schedules = JSON.parse(this.modalData.oncallSchedules);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ return schedules;
+ },
},
mounted() {
document.addEventListener('click', this.handleClick);
@@ -68,9 +98,18 @@ export default {
<form ref="form" :action="modalData.memberPath" method="post">
<p data-testid="modal-message">{{ modalData.message }}</p>
+ <oncall-schedules-list
+ v-if="isPartOfOncallSchedules"
+ :schedules="oncallSchedules.schedules"
+ :user-name="oncallSchedules.name"
+ />
+
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <gl-form-checkbox v-if="!isAccessRequest" name="unassign_issuables">
+ <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
+ {{ __('Also remove direct user membership from subgroups and projects') }}
+ </gl-form-checkbox>
+ <gl-form-checkbox v-if="showUnassignIssuablesCheckbox" name="unassign_issuables">
{{ __('Also unassign this user from related issues and merge requests') }}
</gl-form-checkbox>
</form>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
index 4271f6053ed..85a67c087bb 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
@@ -21,7 +21,11 @@ export default {
};
</script>
<template>
- <button v-gl-tooltip="{ title: tooltip }" class="p-0 gl-display-flex toolbar-button">
+ <button
+ v-gl-tooltip="{ title: tooltip }"
+ :aria-label="tooltip"
+ class="p-0 gl-display-flex toolbar-button"
+ >
<gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
index ff0626167a9..76f152e5453 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
@@ -1,4 +1,4 @@
-query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) {
+query getRunnerPlatforms {
runnerPlatforms {
nodes {
name
@@ -11,10 +11,4 @@ query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) {
}
}
}
- project(fullPath: $projectPath) {
- id
- }
- group(fullPath: $groupPath) {
- id
- }
}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
index 643c1991807..c0248a35e3f 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
@@ -1,15 +1,5 @@
-query runnerSetupInstructions(
- $platform: String!
- $architecture: String!
- $projectId: ID!
- $groupId: ID!
-) {
- runnerSetup(
- platform: $platform
- architecture: $architecture
- projectId: $projectId
- groupId: $groupId
- ) {
+query runnerSetupInstructions($platform: String!, $architecture: String!) {
+ runnerSetup(platform: $platform, architecture: $architecture) {
installInstructions
registerInstructions
}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
index 1d6db576942..d886a67fff7 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
@@ -1,155 +1,31 @@
<script>
-import {
- GlAlert,
- GlButton,
- GlModal,
- GlModalDirective,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
-} from '@gitlab/ui';
-import { __, s__ } from '~/locale';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import {
- PLATFORMS_WITHOUT_ARCHITECTURES,
- INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES,
-} from './constants';
-import getRunnerPlatforms from './graphql/queries/get_runner_platforms.query.graphql';
-import getRunnerSetupInstructions from './graphql/queries/get_runner_setup.query.graphql';
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import RunnerInstructionsModal from './runner_instructions_modal.vue';
export default {
components: {
- GlAlert,
GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlModal,
- GlIcon,
- ModalCopyButton,
+ RunnerInstructionsModal,
},
directives: {
GlModalDirective,
},
- inject: {
- projectPath: {
- default: '',
- },
- groupPath: {
- default: '',
- },
- },
- apollo: {
- runnerPlatforms: {
- query: getRunnerPlatforms,
- variables() {
- return {
- projectPath: this.projectPath,
- groupPath: this.groupPath,
- };
- },
- error() {
- this.showAlert = true;
- },
- result({ data }) {
- this.project = data?.project;
- this.group = data?.group;
-
- this.selectPlatform(this.platforms[0].name);
- },
- },
+ modalId: 'runner-instructions-modal',
+ i18n: {
+ buttonText: s__('Runners|Show Runner installation instructions'),
},
data() {
return {
- showAlert: false,
- selectedPlatformArchitectures: [],
- selectedPlatform: {
- name: '',
- },
- selectedArchitecture: {},
- runnerPlatforms: {},
- instructions: {},
- project: {},
- group: {},
+ opened: false,
};
},
- computed: {
- isPlatformSelected() {
- return Object.keys(this.selectedPlatform).length > 0;
- },
- instructionsEmpty() {
- return Object.keys(this.instructions).length === 0;
- },
- groupId() {
- return this.group?.id ?? '';
- },
- projectId() {
- return this.project?.id ?? '';
- },
- platforms() {
- return this.runnerPlatforms?.nodes;
- },
- hasArchitecureList() {
- return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatform?.name);
- },
- instructionsWithoutArchitecture() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.instructions;
- },
- runnerInstallationLink() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.link;
- },
- },
methods: {
- selectPlatform(name) {
- this.selectedPlatform = this.platforms.find((platform) => platform.name === name);
- if (this.hasArchitecureList) {
- this.selectedPlatformArchitectures = this.selectedPlatform?.architectures?.nodes;
- [this.selectedArchitecture] = this.selectedPlatformArchitectures;
- this.selectArchitecture(this.selectedArchitecture);
- }
- },
- selectArchitecture(architecture) {
- this.selectedArchitecture = architecture;
-
- this.$apollo.addSmartQuery('instructions', {
- variables() {
- return {
- platform: this.selectedPlatform.name,
- architecture: this.selectedArchitecture.name,
- projectId: this.projectId,
- groupId: this.groupId,
- };
- },
- query: getRunnerSetupInstructions,
- update(data) {
- return data?.runnerSetup;
- },
- error() {
- this.showAlert = true;
- },
- });
- },
- toggleAlert(state) {
- this.showAlert = state;
+ onClick() {
+ // lazily mount modal to prevent premature instructions requests
+ this.opened = true;
},
},
- modalId: 'installation-instructions-modal',
- i18n: {
- installARunner: s__('Runners|Install a Runner'),
- architecture: s__('Runners|Architecture'),
- downloadInstallBinary: s__('Runners|Download and Install Binary'),
- downloadLatestBinary: s__('Runners|Download Latest Binary'),
- registerRunner: s__('Runners|Register Runner'),
- method: __('Method'),
- fetchError: s__('Runners|An error has occurred fetching instructions'),
- instructions: s__('Runners|Show Runner installation instructions'),
- copyInstructions: s__('Runners|Copy instructions'),
- },
- closeButton: {
- text: __('Close'),
- attributes: [{ variant: 'default' }],
- },
};
</script>
<template>
@@ -158,104 +34,10 @@ export default {
v-gl-modal-directive="$options.modalId"
class="gl-mt-4"
data-testid="show-modal-button"
+ @click="onClick"
>
- {{ $options.i18n.instructions }}
+ {{ $options.i18n.buttonText }}
</gl-button>
- <gl-modal
- :modal-id="$options.modalId"
- :title="$options.i18n.installARunner"
- :action-secondary="$options.closeButton"
- >
- <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
- {{ $options.i18n.fetchError }}
- </gl-alert>
- <h5>{{ __('Environment') }}</h5>
- <gl-button-group class="gl-mb-5">
- <gl-button
- v-for="platform in platforms"
- :key="platform.name"
- data-testid="platform-button"
- @click="selectPlatform(platform.name)"
- >
- {{ platform.humanReadableName }}
- </gl-button>
- </gl-button-group>
- <template v-if="hasArchitecureList">
- <template v-if="isPlatformSelected">
- <h5>
- {{ $options.i18n.architecture }}
- </h5>
- <gl-dropdown class="gl-mb-5" :text="selectedArchitecture.name">
- <gl-dropdown-item
- v-for="architecture in selectedPlatformArchitectures"
- :key="architecture.name"
- data-testid="architecture-dropdown-item"
- @click="selectArchitecture(architecture)"
- >
- {{ architecture.name }}
- </gl-dropdown-item>
- </gl-dropdown>
- <div class="gl-display-flex gl-align-items-center gl-mb-5">
- <h5>{{ $options.i18n.downloadInstallBinary }}</h5>
- <gl-button
- class="gl-ml-auto"
- :href="selectedArchitecture.downloadLocation"
- download
- data-testid="binary-download-button"
- >
- {{ $options.i18n.downloadLatestBinary }}
- </gl-button>
- </div>
- </template>
- <template v-if="!instructionsEmpty">
- <div class="gl-display-flex">
- <pre
- class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
- data-testid="binary-instructions"
- >
-
- {{ instructions.installInstructions }}
- </pre
- >
- <modal-copy-button
- :title="$options.i18n.copyInstructions"
- :text="instructions.installInstructions"
- :modal-id="$options.modalId"
- css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
- category="tertiary"
- />
- </div>
-
- <hr />
- <h5 class="gl-mb-5">{{ $options.i18n.registerRunner }}</h5>
- <h5 class="gl-mb-5">{{ $options.i18n.method }}</h5>
- <div class="gl-display-flex">
- <pre
- class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
- data-testid="runner-instructions"
- >
- {{ instructions.registerInstructions }}
- </pre
- >
- <modal-copy-button
- :title="$options.i18n.copyInstructions"
- :text="instructions.registerInstructions"
- :modal-id="$options.modalId"
- css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
- category="tertiary"
- />
- </div>
- </template>
- </template>
- <template v-else>
- <div>
- <p>{{ instructionsWithoutArchitecture }}</p>
- <gl-button :href="runnerInstallationLink">
- <gl-icon name="external-link" />
- {{ s__('Runners|View installation instructions') }}
- </gl-button>
- </div>
- </template>
- </gl-modal>
+ <runner-instructions-modal v-if="opened" :modal-id="$options.modalId" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
new file mode 100644
index 00000000000..795b4f58ac5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -0,0 +1,249 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlModal,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlLoadingIcon,
+ GlSkeletonLoader,
+} from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+import { __, s__ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import {
+ PLATFORMS_WITHOUT_ARCHITECTURES,
+ INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES,
+} from './constants';
+import getRunnerPlatformsQuery from './graphql/queries/get_runner_platforms.query.graphql';
+import getRunnerSetupInstructionsQuery from './graphql/queries/get_runner_setup.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlIcon,
+ GlLoadingIcon,
+ GlSkeletonLoader,
+ ModalCopyButton,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ platforms: {
+ query: getRunnerPlatformsQuery,
+ update(data) {
+ return data?.runnerPlatforms?.nodes.map(({ name, humanReadableName, architectures }) => {
+ return {
+ name,
+ humanReadableName,
+ architectures: architectures?.nodes || [],
+ };
+ });
+ },
+ result() {
+ // Select first platform by default
+ if (this.platforms?.[0]) {
+ this.selectPlatform(this.platforms[0]);
+ }
+ },
+ error() {
+ this.toggleAlert(true);
+ },
+ },
+ instructions: {
+ query: getRunnerSetupInstructionsQuery,
+ skip() {
+ return !this.selectedPlatform;
+ },
+ variables() {
+ return {
+ platform: this.selectedPlatformName,
+ architecture: this.selectedArchitectureName || '',
+ };
+ },
+ update(data) {
+ return data?.runnerSetup;
+ },
+ error() {
+ this.toggleAlert(true);
+ },
+ },
+ },
+ data() {
+ return {
+ platforms: [],
+ selectedPlatform: null,
+ selectedArchitecture: null,
+ showAlert: false,
+ instructions: {},
+ };
+ },
+ computed: {
+ platformsEmpty() {
+ return isEmpty(this.platforms);
+ },
+ instructionsEmpty() {
+ return isEmpty(this.instructions);
+ },
+ selectedPlatformName() {
+ return this.selectedPlatform?.name;
+ },
+ selectedArchitectureName() {
+ return this.selectedArchitecture?.name;
+ },
+ hasArchitecureList() {
+ return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatformName);
+ },
+ instructionsWithoutArchitecture() {
+ return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.instructions;
+ },
+ runnerInstallationLink() {
+ return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.link;
+ },
+ },
+ methods: {
+ selectPlatform(platform) {
+ this.selectedPlatform = platform;
+
+ if (!platform.architectures?.some(({ name }) => name === this.selectedArchitectureName)) {
+ // Select first architecture when current value is not available
+ this.selectArchitecture(platform.architectures[0]);
+ }
+ },
+ selectArchitecture(architecture) {
+ this.selectedArchitecture = architecture;
+ },
+ toggleAlert(state) {
+ this.showAlert = state;
+ },
+ },
+ i18n: {
+ installARunner: s__('Runners|Install a runner'),
+ architecture: s__('Runners|Architecture'),
+ downloadInstallBinary: s__('Runners|Download and install binary'),
+ downloadLatestBinary: s__('Runners|Download latest binary'),
+ registerRunnerCommand: s__('Runners|Command to register runner'),
+ fetchError: s__('Runners|An error has occurred fetching instructions'),
+ copyInstructions: s__('Runners|Copy instructions'),
+ },
+ closeButton: {
+ text: __('Close'),
+ attributes: [{ variant: 'default' }],
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.i18n.installARunner"
+ :action-secondary="$options.closeButton"
+ >
+ <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
+ {{ $options.i18n.fetchError }}
+ </gl-alert>
+
+ <gl-skeleton-loader v-if="platformsEmpty && $apollo.loading" />
+
+ <template v-if="!platformsEmpty">
+ <h5>
+ {{ __('Environment') }}
+ </h5>
+ <gl-button-group class="gl-mb-3">
+ <gl-button
+ v-for="platform in platforms"
+ :key="platform.name"
+ :selected="selectedPlatform && selectedPlatform.name === platform.name"
+ data-testid="platform-button"
+ @click="selectPlatform(platform)"
+ >
+ {{ platform.humanReadableName }}
+ </gl-button>
+ </gl-button-group>
+ </template>
+ <template v-if="hasArchitecureList">
+ <template v-if="selectedPlatform">
+ <h5>
+ {{ $options.i18n.architecture }}
+ <gl-loading-icon v-if="$apollo.loading" inline />
+ </h5>
+
+ <gl-dropdown class="gl-mb-3" :text="selectedArchitectureName">
+ <gl-dropdown-item
+ v-for="architecture in selectedPlatform.architectures"
+ :key="architecture.name"
+ :is-check-item="true"
+ :is-checked="selectedArchitectureName === architecture.name"
+ data-testid="architecture-dropdown-item"
+ @click="selectArchitecture(architecture)"
+ >
+ {{ architecture.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-display-flex gl-align-items-center gl-mb-3">
+ <h5>{{ $options.i18n.downloadInstallBinary }}</h5>
+ <gl-button
+ class="gl-ml-auto"
+ :href="selectedArchitecture.downloadLocation"
+ download
+ icon="download"
+ data-testid="binary-download-button"
+ >
+ {{ $options.i18n.downloadLatestBinary }}
+ </gl-button>
+ </div>
+ </template>
+ <template v-if="!instructionsEmpty">
+ <div class="gl-display-flex">
+ <pre
+ class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="binary-instructions"
+ >{{ instructions.installInstructions }}</pre
+ >
+ <modal-copy-button
+ :title="$options.i18n.copyInstructions"
+ :text="instructions.installInstructions"
+ :modal-id="$options.modalId"
+ css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ />
+ </div>
+
+ <h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5>
+ <div class="gl-display-flex">
+ <pre
+ class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="register-command"
+ >{{ instructions.registerInstructions }}</pre
+ >
+ <modal-copy-button
+ :title="$options.i18n.copyInstructions"
+ :text="instructions.registerInstructions"
+ :modal-id="$options.modalId"
+ css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ />
+ </div>
+ </template>
+ </template>
+ <template v-else>
+ <div>
+ <p>{{ instructionsWithoutArchitecture }}</p>
+ <gl-button :href="runnerInstallationLink">
+ <gl-icon name="external-link" />
+ {{ s__('Runners|View installation instructions') }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
new file mode 100644
index 00000000000..bbc7e6e7a6e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { s__, __, sprintf } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+/**
+ * Renders an inline field, whose value can be copied to the clipboard,
+ * for use in the GitLab sidebar (issues, MRs, etc.).
+ */
+export default {
+ name: 'CopyableField',
+ components: {
+ GlLoadingIcon,
+ ClipboardButton,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clipboardTooltipText: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ clipboardProps() {
+ return {
+ category: 'tertiary',
+ tooltipBoundary: 'viewport',
+ tooltipPlacement: 'left',
+ text: this.value,
+ title:
+ this.clipboardTooltipText ||
+ sprintf(this.$options.i18n.clipboardTooltip, { name: this.name }),
+ };
+ },
+ loadingIconLabel() {
+ return sprintf(this.$options.i18n.loadingIconLabel, { name: this.name });
+ },
+ templateText() {
+ return sprintf(this.$options.i18n.templateText, {
+ name: this.name,
+ value: this.value,
+ });
+ },
+ },
+ i18n: {
+ loadingIconLabel: __('Loading %{name}'),
+ clipboardTooltip: __('Copy %{name}'),
+ templateText: s__('Sidebar|%{name}: %{value}'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <clipboard-button
+ v-if="!isLoading"
+ css-class="sidebar-collapsed-icon dont-change-state gl-rounded-0! gl-hover-bg-transparent"
+ v-bind="clipboardProps"
+ />
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between hide-collapsed"
+ >
+ <span
+ class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap"
+ :title="value"
+ >
+ {{ templateText }}
+ </span>
+
+ <gl-loading-icon v-if="isLoading" inline :label="loadingIconLabel" />
+ <clipboard-button v-else size="small" v-bind="clipboardProps" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
index 1d3bd312b09..320e2048f1c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
@@ -164,6 +164,7 @@ export default {
variant="link"
icon="close"
class="gl-mr-2 gl-w-auto! gl-p-2!"
+ :aria-label="__('Close')"
@click.prevent="handleDropdownCloseClick"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index 426ae430ce7..f547433f322 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -172,9 +172,11 @@ export default {
after: this.handleVuexActionDispatch,
});
+ document.addEventListener('mousedown', this.handleDocumentMousedown);
document.addEventListener('click', this.handleDocumentClick);
},
beforeDestroy() {
+ document.removeEventListener('mousedown', this.handleDocumentMousedown);
document.removeEventListener('click', this.handleDocumentClick);
},
methods: {
@@ -197,11 +199,36 @@ export default {
}
},
/**
+ * This method stores a mousedown event's target.
+ * Required by the click listener because the click
+ * event itself has no reference to this element.
+ */
+ handleDocumentMousedown({ target }) {
+ this.mousedownTarget = target;
+ },
+ /**
* This method listens for document-wide click event
* and toggle dropdown if user clicks anywhere outside
* the dropdown while dropdown is visible.
*/
handleDocumentClick({ target }) {
+ // We also perform the toggle exception check for the
+ // last mousedown event's target to avoid hiding the
+ // box when the mousedown happened inside the box and
+ // only the mouseup did not.
+ if (
+ this.showDropdownContents &&
+ !this.preventDropdownToggleOnClick(target) &&
+ !this.preventDropdownToggleOnClick(this.mousedownTarget)
+ ) {
+ this.toggleDropdownContents();
+ }
+ },
+ /**
+ * This method checks whether a given click target
+ * should prevent the dropdown from being toggled.
+ */
+ preventDropdownToggleOnClick(target) {
// This approach of element detection is needed
// as the dropdown wrapper is not using `GlDropdown` as
// it will also require us to use `BDropdownForm`
@@ -216,19 +243,20 @@ export default {
target?.parentElement?.classList.contains(className),
);
- const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
+ const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
(className) => $(target).parents(className).length,
);
- if (
- this.showDropdownContents &&
- !hadExceptionParent &&
- !hasExceptionClass &&
- !this.$refs.dropdownButtonCollapsed?.$el.contains(target) &&
- !this.$refs.dropdownContents?.$el.contains(target)
- ) {
- this.toggleDropdownContents();
- }
+ const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
+
+ const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target);
+
+ return (
+ hasExceptionClass ||
+ hasExceptionParent ||
+ isInDropdownButtonCollapsed ||
+ isInDropdownContents
+ );
},
handleDropdownClose(labels) {
// Only emit label updates if there are any labels to update
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
index ef5f052527b..17904f20341 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
@@ -30,5 +30,8 @@ export default {
<gl-dropdown-form>
<slot name="items"></slot>
</gl-dropdown-form>
+ <template #footer>
+ <slot name="footer"></slot>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 459ea27e9cd..3885127fa8e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
@@ -9,11 +10,13 @@ query issueParticipants($fullPath: ID!, $iid: String!) {
participants {
nodes {
...User
+ ...UserAvailability
}
}
assignees {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
index 43bd9f17e9a..63482873b69 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
@@ -7,11 +8,13 @@ query getMrParticipants($fullPath: ID!, $iid: String!) {
participants {
nodes {
...User
+ ...UserAvailability
}
}
assignees {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
index 8ee8de2cb5c..3f40c0368d7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issuableSetAssignees: issueSetAssignees(
@@ -9,11 +10,13 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
assignees {
nodes {
...User
+ ...UserAvailability
}
}
participants {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
index a0f15a07692..77140ea36d8 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
mergeRequestSetAssignees(
@@ -9,11 +10,13 @@ mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!,
assignees {
nodes {
...User
+ ...UserAvailability
}
}
participants {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue
index 2844d9e9e94..925c6008836 100644
--- a/app/assets/javascripts/vue_shared/components/url_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/url_sync.vue
@@ -2,11 +2,18 @@
import { historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
+/**
+ * Renderless component to update the query string,
+ * the update is done by updating the query property or
+ * by using updateQuery method in the scoped slot.
+ * note: do not use both prop and updateQuery method.
+ */
export default {
props: {
query: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
},
watch: {
@@ -14,12 +21,19 @@ export default {
immediate: true,
deep: true,
handler(newQuery) {
- historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }));
+ if (newQuery) {
+ this.updateQuery(newQuery);
+ }
},
},
},
+ methods: {
+ updateQuery(newQuery) {
+ historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }));
+ },
+ },
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.({ updateQuery: this.updateQuery });
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/user_date.vue b/app/assets/javascripts/vue_shared/components/user_date.vue
new file mode 100644
index 00000000000..38dddbf72c2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_date.vue
@@ -0,0 +1,29 @@
+<script>
+import { formatDate } from '~/lib/utils/datetime_utility';
+import { __ } from '~/locale';
+import { SHORT_DATE_FORMAT } from '../constants';
+
+export default {
+ props: {
+ date: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ formattedDate() {
+ const { date } = this;
+ if (date === null) {
+ return __('Never');
+ }
+ return formatDate(new Date(date), SHORT_DATE_FORMAT);
+ },
+ },
+};
+</script>
+<template>
+ <span>
+ {{ formattedDate }}
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index dbd8efec948..11f484b2cdf 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -1,11 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import {
- GlPopover,
- GlLink,
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlIcon,
-} from '@gitlab/ui';
+import { GlPopover, GlLink, GlSkeletonLoader, GlIcon } from '@gitlab/ui';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import { glEmojiTag } from '../../../emoji';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
@@ -19,7 +14,7 @@ export default {
GlIcon,
GlLink,
GlPopover,
- GlSkeletonLoading,
+ GlSkeletonLoader,
UserAvatarImage,
UserNameWithStatus,
},
@@ -60,20 +55,18 @@ export default {
<template>
<!-- 200ms delay so not every mouseover triggers Popover -->
- <gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top">
+ <gl-popover :target="target" :delay="200" boundary="viewport" placement="top">
<div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
<div class="gl-p-2 flex-shrink-1">
<user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" />
</div>
- <div class="gl-p-2 gl-w-full">
+ <div class="gl-p-2 gl-w-full gl-min-w-0">
<template v-if="userIsLoading">
- <!-- `gl-skeleton-loading` does not support equal length lines -->
- <!-- This can be migrated to `gl-skeleton-loader` when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/872 is completed -->
- <gl-skeleton-loading
- v-for="n in $options.maxSkeletonLines"
- :key="n"
- :lines="1"
- class="animation-container-small gl-mb-2"
+ <gl-skeleton-loader
+ :lines="$options.maxSkeletonLines"
+ preserve-aspect-ratio="none"
+ equal-width-lines
+ :height="52"
/>
</template>
<template v-else>