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:
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/content_transition.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue112
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql13
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/utils.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue80
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue110
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue117
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue117
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue126
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue44
24 files changed, 923 insertions, 241 deletions
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index b6010d4b70c..96970f4ce2f 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -199,12 +199,15 @@ export default {
<div v-if="canAwardEmoji" class="award-menu-holder gl-my-2">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
+ v-gl-tooltip.viewport
+ :title="__('Add reaction')"
:toggle-class="['add-reaction-button btn-icon gl-relative!', { 'is-active': isMenuOpen }]"
@click="handleAward"
@shown="setIsMenuOpen(true)"
@hidden="setIsMenuOpen(false)"
>
<template #button-content>
+ <span class="gl-sr-only">{{ __('Add reaction') }}</span>
<span class="reaction-control-icon reaction-control-icon-neutral">
<gl-icon name="slight-smile" />
</span>
diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
index 7563c35dfc8..7a166f9a3e4 100644
--- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
@@ -7,6 +7,7 @@
:invalid-feedback="__('Please enter a valid hex (#RRGGBB or #RGB) color value')"
:label="__('Background color')"
:value="#FF0000"
+ :suggestedColors="{ '#ff0000': 'Red', '#808080': 'Gray' }",
state="isValidColor"
/>
*/
@@ -48,6 +49,11 @@ export default {
required: false,
default: null,
},
+ suggestedColors: {
+ type: Object,
+ required: false,
+ default: () => gon.suggested_label_colors,
+ },
},
computed: {
description() {
@@ -55,9 +61,6 @@ export default {
? this.$options.i18n.fullDescription
: this.$options.i18n.shortDescription;
},
- suggestedColors() {
- return gon.suggested_label_colors;
- },
previewColor() {
if (this.state) {
return { backgroundColor: this.value };
diff --git a/app/assets/javascripts/vue_shared/components/content_transition.vue b/app/assets/javascripts/vue_shared/components/content_transition.vue
new file mode 100644
index 00000000000..446610d6b91
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/content_transition.vue
@@ -0,0 +1,32 @@
+<script>
+export default {
+ props: {
+ currentSlot: {
+ type: String,
+ required: true,
+ },
+ slots: {
+ type: Array,
+ required: true,
+ },
+ transitionName: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ shouldShow(key) {
+ return this.currentSlot === key;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <transition v-for="{ key, attributes } in slots" :key="key" :name="transitionName">
+ <div v-show="shouldShow(key)" v-bind="attributes">
+ <slot :name="key"></slot>
+ </div>
+ </transition>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
index 153b0981813..2a79ccc2648 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
@@ -1,22 +1,28 @@
<script>
import {
+ GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
} from '@gitlab/ui';
import { __ } from '~/locale';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
+ GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownForm,
GlDropdownDivider,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
+ TooltipOnTruncate,
},
props: {
selectText: {
@@ -39,6 +45,11 @@ export default {
required: false,
default: () => [],
},
+ groupedOptions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
isLoading: {
type: Boolean,
required: false,
@@ -79,11 +90,7 @@ export default {
if (Array.isArray(this.selected)) {
return this.selected.some((label) => label.title === option.title);
}
- return (
- this.selected &&
- ((option.name && this.selected.name === option.name) ||
- (option.title && this.selected.title === option.title))
- );
+ return this.selected && option.id && this.selected.id === option.id;
},
showDropdown() {
this.$refs.dropdown.show();
@@ -101,6 +108,9 @@ export default {
// TODO: this has some knowledge of the context where the component is used. We could later rework it.
return option.username || null;
},
+ optionKey(option) {
+ return option.key ? option.key : option.id;
+ },
},
i18n: {
noMatchingResults: __('No matching results'),
@@ -154,10 +164,10 @@ export default {
</template>
<gl-dropdown-item
v-for="option in options"
- :key="option.id"
+ :key="optionKey(option)"
:is-checked="isSelected(option)"
- :is-check-centered="true"
- :is-check-item="true"
+ is-check-centered
+ is-check-item
:avatar-url="avatarUrl(option)"
:secondary-text="secondaryText(option)"
data-testid="unselected-option"
@@ -167,6 +177,36 @@ export default {
{{ option.title }}
</slot>
</gl-dropdown-item>
+ <template v-for="(optionGroup, index) in groupedOptions">
+ <gl-dropdown-divider v-if="index !== 0" :key="index" />
+ <gl-dropdown-section-header :key="optionGroup.id">
+ <div class="gl-display-flex gl-max-w-full">
+ <tooltip-on-truncate
+ :title="optionGroup.title"
+ class="gl-text-truncate gl-flex-grow-1"
+ >
+ {{ optionGroup.title }}
+ </tooltip-on-truncate>
+ <span v-if="optionGroup.secondaryText" class="gl-float-right gl-font-weight-normal">
+ <gl-icon name="clock" class="gl-mr-2" />
+ {{ optionGroup.secondaryText }}
+ </span>
+ </div>
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="option in optionGroup.options"
+ :key="optionKey(option)"
+ :is-checked="isSelected(option)"
+ is-check-centered
+ is-check-item
+ data-testid="unselected-option"
+ @click="selectOption(option)"
+ >
+ <slot name="item" :item="option">
+ {{ option.title }}
+ </slot>
+ </gl-dropdown-item>
+ </template>
<gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!">
{{ $options.i18n.noMatchingResults }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index 157068b2c0f..e7923e0b55e 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -76,9 +76,10 @@ export default {
},
data() {
return {
+ hasFetched: false, // use this to avoid flash of `No suggestions found` before fetching
searchKey: '',
recentSuggestions: this.config.recentSuggestionsStorageKey
- ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey)
+ ? getRecentlyUsedSuggestions(this.config.recentSuggestionsStorageKey) ?? []
: [],
};
},
@@ -86,6 +87,9 @@ export default {
isRecentSuggestionsEnabled() {
return Boolean(this.config.recentSuggestionsStorageKey);
},
+ suggestionsEnabled() {
+ return !this.config.suggestionsDisabled;
+ },
recentTokenIds() {
return this.recentSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]);
},
@@ -134,17 +138,6 @@ export default {
showAvailableSuggestions() {
return this.availableSuggestions.length > 0;
},
- showSuggestions() {
- // These conditions must match the template under `#suggestions` slot
- // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65817#note_632619411
- return (
- this.showDefaultSuggestions ||
- this.showRecentSuggestions ||
- this.showPreloadedSuggestions ||
- this.suggestionsLoading ||
- this.showAvailableSuggestions
- );
- },
searchTerm() {
return this.searchBy && this.activeTokenValue
? this.activeTokenValue[this.searchBy]
@@ -161,6 +154,13 @@ export default {
}
},
},
+ suggestionsLoading: {
+ handler(loading) {
+ if (loading) {
+ this.hasFetched = true;
+ }
+ },
+ },
},
methods: {
handleInput: debounce(function debouncedSearch({ data, operator }) {
@@ -216,7 +216,7 @@ export default {
<template #view="viewTokenProps">
<slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
</template>
- <template v-if="showSuggestions" #suggestions>
+ <template v-if="suggestionsEnabled" #suggestions>
<template v-if="showDefaultSuggestions">
<gl-filtered-search-suggestion
v-for="token in availableDefaultSuggestions"
@@ -238,12 +238,13 @@ export default {
:suggestions="preloadedSuggestions"
></slot>
<gl-loading-icon v-if="suggestionsLoading" size="sm" />
+ <template v-else-if="showAvailableSuggestions">
+ <slot name="suggestions-list" :suggestions="availableSuggestions"></slot>
+ </template>
<gl-dropdown-text v-else-if="showNoMatchesText">
{{ __('No matches found') }}
</gl-dropdown-text>
- <template v-else>
- <slot name="suggestions-list" :suggestions="availableSuggestions"></slot>
- </template>
+ <gl-dropdown-text v-else-if="hasFetched">{{ __('No suggestions found') }}</gl-dropdown-text>
</template>
</gl-filtered-search-token>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index cbf38984e23..e1020ce656b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -48,6 +48,11 @@ export default {
required: false,
default: '',
},
+ enablePreview: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
addSpacingClasses: {
type: Boolean,
required: false,
@@ -113,6 +118,7 @@ export default {
markdownPreviewLoading: false,
previewMarkdown: false,
suggestions: this.note.suggestions || [],
+ debouncedFetchMarkdownLoading: false,
};
},
computed: {
@@ -198,12 +204,22 @@ export default {
const justRemovedAll = hadAll && !hasAll;
if (justAddedAll) {
+ this.debouncedFetchMarkdownLoading = false;
this.debouncedFetchMarkdown();
} else if (justRemovedAll) {
+ this.debouncedFetchMarkdownLoading = true;
this.referencedUsers = [];
}
},
},
+ enablePreview: {
+ immediate: true,
+ handler(newVal) {
+ if (!newVal) {
+ this.showWriteTab();
+ }
+ },
+ },
},
mounted() {
// GLForm class handles all the toolbar buttons
@@ -271,7 +287,12 @@ export default {
},
debouncedFetchMarkdown: debounce(function debouncedFetchMarkdown() {
- return this.fetchMarkdown();
+ return this.fetchMarkdown().then(() => {
+ if (this.debouncedFetchMarkdownLoading) {
+ this.referencedUsers = [];
+ this.debouncedFetchMarkdownLoading = false;
+ }
+ });
}, 400),
renderMarkdown(data = {}) {
@@ -301,6 +322,7 @@ export default {
:preview-markdown="previewMarkdown"
:line-content="lineContent"
:can-suggest="canSuggest"
+ :enable-preview="enablePreview"
:show-suggest-popover="showSuggestPopover"
:suggestion-start-index="suggestionsStartIndex"
data-testid="markdownHeader"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 3b99afa9e3d..13189670e17 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,7 +1,13 @@
<script>
import { GlPopover, GlButton, GlTooltipDirective, GlTabs, GlTab } from '@gitlab/ui';
import $ from 'jquery';
-import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings';
+import {
+ keysFor,
+ BOLD_TEXT,
+ ITALIC_TEXT,
+ STRIKETHROUGH_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';
@@ -43,6 +49,11 @@ export default {
required: false,
default: 0,
},
+ enablePreview: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -144,6 +155,7 @@ export default {
shortcuts: {
bold: keysFor(BOLD_TEXT),
italic: keysFor(ITALIC_TEXT),
+ strikethrough: keysFor(STRIKETHROUGH_TEXT),
link: keysFor(LINK_TEXT),
},
i18n: {
@@ -164,6 +176,7 @@ export default {
@click="writeMarkdownTab($event)"
/>
<gl-tab
+ v-if="enablePreview"
title-link-class="gl-pt-3 gl-px-3 js-md-preview-button"
:title="$options.i18n.previewTabTitle"
:active="previewMarkdown"
@@ -194,6 +207,16 @@ export default {
icon="italic"
/>
<toolbar-button
+ tag="~~"
+ :button-title="
+ sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), {
+ modifierKey,
+ })
+ "
+ :shortcuts="$options.shortcuts.strikethrough"
+ icon="strikethrough"
+ />
+ <toolbar-button
:prepend="true"
:tag="tag"
:button-title="__('Insert a quote')"
diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
index 0b302f22062..7a7074da084 100644
--- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -1,12 +1,7 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
-import { escape } from 'lodash';
+import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-function buildDocsLinkStart(path) {
- return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`;
-}
-
const NoteableTypeText = {
Issue: __('issue'),
Epic: __('epic'),
@@ -17,6 +12,7 @@ export default {
components: {
GlIcon,
GlLink,
+ GlSprintf,
},
props: {
isLocked: {
@@ -59,20 +55,6 @@ export default {
noteableTypeText() {
return NoteableTypeText[this.noteableType];
},
- confidentialAndLockedDiscussionText() {
- return sprintf(
- __(
- 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
- ),
- {
- noteableTypeText: this.noteableTypeText,
- confidentialLinkStart: buildDocsLinkStart(this.confidentialNoteableDocsPath),
- lockedLinkStart: buildDocsLinkStart(this.lockedNoteableDocsPath),
- linkEnd: '</a>',
- },
- false,
- );
- },
confidentialContextText() {
return sprintf(__('This is a confidential %{noteableTypeText}.'), {
noteableTypeText: this.noteableTypeText,
@@ -91,9 +73,23 @@ export default {
<gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
<span v-if="isLockedAndConfidential" ref="lockedAndConfidential">
- <span
- v-html="confidentialAndLockedDiscussionText /* eslint-disable-line vue/no-v-html */"
- ></span>
+ <span>
+ <gl-sprintf
+ :message="
+ __(
+ 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{confidentialLinkEnd} and %{lockedLinkStart}locked%{lockedLinkEnd}.',
+ )
+ "
+ >
+ <template #noteableTypeText>{{ noteableTypeText }}</template>
+ <template #confidentialLink="{ content }">
+ <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #lockedLink="{ content }">
+ <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
{{
__("People without permission will never get a notification and won't be able to comment.")
}}
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
index 46361c6eb32..88c975b97b9 100644
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/constants.js
@@ -1,7 +1,5 @@
import { s__, sprintf } from '~/locale';
-export const EXPERIMENT_NAME = 'ci_runner_templates';
-
export const README_URL =
'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md';
@@ -16,7 +14,11 @@ export const EASY_BUTTONS = [
templateName:
'easybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml',
description: s__(
- 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. Default choice for Linux Docker executor.',
+ 'Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot.',
+ ),
+ moreDetails1: s__('Runners|No spot. This is the default choice for Linux Docker executor.'),
+ moreDetails2: s__(
+ 'Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
),
},
{
@@ -28,12 +30,20 @@ export const EASY_BUTTONS = [
),
{ percentage: '100%' },
),
+ moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
+ ),
},
{
stackName: 'win2019-shell-non-spot',
templateName: 'easybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml',
description: s__(
- 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor.',
+ 'Runners|Windows 2019 Shell with manual scaling and optional scheduling. Non-spot.',
+ ),
+ moreDetails1: s__('Runners|No spot. Default choice for Windows Shell executor.'),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
),
},
{
@@ -45,5 +55,9 @@ export const EASY_BUTTONS = [
),
{ percentage: '100%' },
),
+ moreDetails1: sprintf(s__('Runners|%{percentage} spot.'), { percentage: '100%' }),
+ moreDetails2: s__(
+ 'Runners|Capacity of 1 enables warm HA through Auto Scaling group re-spawn. Capacity of 2 enables hot HA because the service is available even when a node is lost. Capacity of 3 or more enables hot HA and manual scaling of runner fleet.',
+ ),
},
];
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
index 57cc25caa25..eee65d90285 100644
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
@@ -1,35 +1,44 @@
<script>
-import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
-import awsCloudFormationImageUrl from 'images/aws-cloud-formation.png';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
-import { getBaseURL, objectToQuery } from '~/lib/utils/url_utility';
-import { __, s__ } from '~/locale';
import {
- EXPERIMENT_NAME,
- README_URL,
- CF_BASE_URL,
- TEMPLATES_BASE_URL,
- EASY_BUTTONS,
-} from './constants';
+ GlModal,
+ GlSprintf,
+ GlLink,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlAccordion,
+ GlAccordionItem,
+} from '@gitlab/ui';
+import Tracking from '~/tracking';
+import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
+import { README_URL, CF_BASE_URL, TEMPLATES_BASE_URL, EASY_BUTTONS } from './constants';
export default {
components: {
GlModal,
GlSprintf,
GlLink,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlAccordion,
+ GlAccordionItem,
},
+ mixins: [Tracking.mixin()],
props: {
modalId: {
type: String,
required: true,
},
- imgSrc: {
- type: String,
- required: false,
- default: awsCloudFormationImageUrl,
- },
+ },
+ data() {
+ return {
+ selected: this.$options.easyButtons[0],
+ };
},
methods: {
+ borderBottom(idx) {
+ return idx < this.$options.easyButtons.length - 1;
+ },
easyButtonUrl(easyButton) {
const params = {
templateURL: TEMPLATES_BASE_URL + easyButton.templateName,
@@ -39,21 +48,30 @@ export default {
return CF_BASE_URL + objectToQuery(params);
},
trackCiRunnerTemplatesClick(stackName) {
- const tracking = new ExperimentTracking(EXPERIMENT_NAME);
- tracking.event(`template_clicked_${stackName}`);
+ this.track('template_clicked', {
+ label: stackName,
+ });
+ },
+ handleModalPrimary() {
+ this.trackCiRunnerTemplatesClick(this.selected.stackName);
+ visitUrl(this.easyButtonUrl(this.selected), true);
},
},
i18n: {
title: s__('Runners|Deploy GitLab Runner in AWS'),
instructions: s__(
- 'Runners|For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet.',
+ 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.',
),
- dont_see_what_you_are_looking_for: s__(
- "Rnners|Don't see what you are looking for? See the full list of options, including a fully customizable option, %{linkStart}here%{linkEnd}.",
- ),
- note: s__(
- 'Runners|If you do not select an AWS VPC, the runner will deploy to the Default VPC in the AWS Region you select. Please consult with your AWS administrator to understand if there are any security risks to deploying into the Default VPC in any given region in your AWS account.',
+ chooseRunner: s__('Runners|Choose your preferred GitLab Runner'),
+ dontSeeWhatYouAreLookingFor: s__(
+ "Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.",
),
+ moreDetails: __('More Details'),
+ lessDetails: __('Less Details'),
+ },
+ deployButton: {
+ text: s__('Runners|Deploy GitLab Runner in AWS'),
+ attributes: [{ variant: 'confirm' }],
},
closeButton: {
text: __('Cancel'),
@@ -67,37 +85,41 @@ export default {
<gl-modal
:modal-id="modalId"
:title="$options.i18n.title"
+ :action-primary="$options.deployButton"
:action-secondary="$options.closeButton"
size="sm"
+ @primary="handleModalPrimary"
>
<p>{{ $options.i18n.instructions }}</p>
- <ul class="gl-list-style-none gl-p-0 gl-mb-0">
- <li v-for="easyButton in $options.easyButtons" :key="easyButton.templateName">
- <gl-link
- :href="easyButtonUrl(easyButton)"
- target="_blank"
- class="gl-display-flex gl-font-weight-bold"
- @click="trackCiRunnerTemplatesClick(easyButton.stackName)"
- >
- <img
- :title="easyButton.stackName"
- :alt="easyButton.stackName"
- :src="imgSrc"
- width="46"
- height="46"
- class="gl-mt-2 gl-mr-5 gl-mb-6"
- />
+ <gl-form-radio-group v-model="selected" :label="$options.i18n.chooseRunner" label-sr-only>
+ <gl-form-radio
+ v-for="(easyButton, idx) in $options.easyButtons"
+ :key="easyButton.templateName"
+ :value="easyButton"
+ class="gl-py-5 gl-pl-8"
+ :class="{ 'gl-border-b': borderBottom(idx) }"
+ >
+ <div class="gl-mt-n1 gl-pl-4 gl-pb-2 gl-font-weight-bold">
{{ easyButton.description }}
- </gl-link>
- </li>
- </ul>
+ <gl-accordion :header-level="3" class="gl-pt-3">
+ <gl-accordion-item
+ :title="$options.i18n.moreDetails"
+ :title-visible="$options.i18n.lessDetails"
+ class="gl-font-weight-normal"
+ >
+ <p class="gl-pt-2">{{ easyButton.moreDetails1 }}</p>
+ <p class="gl-m-0">{{ easyButton.moreDetails2 }}</p>
+ </gl-accordion-item>
+ </gl-accordion>
+ </div>
+ </gl-form-radio>
+ </gl-form-radio-group>
<p>
- <gl-sprintf :message="$options.i18n.dont_see_what_you_are_looking_for">
+ <gl-sprintf :message="$options.i18n.dontSeeWhatYouAreLookingFor">
<template #link="{ content }">
<gl-link :href="$options.readmeUrl" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
- <p class="gl-font-sm gl-mb-0">{{ $options.i18n.note }}</p>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
index 81e19e48d75..7127940bb05 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
@@ -10,8 +10,14 @@ query getMrAssignees($fullPath: ID!, $iid: String!) {
nodes {
...User
...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
}
}
+ userPermissions {
+ canMerge
+ }
}
}
}
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 77140ea36d8..5fec2ccbdfb 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
@@ -2,21 +2,18 @@
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
- mergeRequestSetAssignees(
+ issuableSetAssignees: mergeRequestSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
- mergeRequest {
+ issuable: mergeRequest {
id
assignees {
nodes {
...User
...UserAvailability
- }
- }
- participants {
- nodes {
- ...User
- ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index 011cad4267c..6a0bf07c8b4 100644
--- a/app/assets/javascripts/vue_shared/components/source_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -46,6 +46,11 @@ export default {
required: false,
default: () => ({}),
},
+ debounceValue: {
+ type: Number,
+ required: false,
+ default: CONTENT_UPDATE_DEBOUNCE,
+ },
},
data() {
return {
@@ -73,9 +78,7 @@ export default {
...this.editorOptions,
});
- this.editor.onDidChangeModelContent(
- debounce(this.onFileChange.bind(this), CONTENT_UPDATE_DEBOUNCE),
- );
+ this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), this.debounceValue));
},
beforeDestroy() {
this.editor.dispose();
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index 5aae1812de3..4a78cbacec0 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -35,16 +35,20 @@ export default {
},
highlightedContent() {
let highlightedContent;
+ let { language } = this;
if (this.hljs) {
- if (!this.language) {
- highlightedContent = this.hljs.highlightAuto(this.content).value;
+ if (!language) {
+ const hljsHighlightAuto = this.hljs.highlightAuto(this.content);
+
+ highlightedContent = hljsHighlightAuto.value;
+ language = hljsHighlightAuto.language;
} else if (this.languageDefinition) {
highlightedContent = this.hljs.highlight(this.content, { language: this.language }).value;
}
}
- return wrapLines(highlightedContent);
+ return wrapLines(highlightedContent, language);
},
},
watch: {
@@ -110,7 +114,7 @@ export default {
data-qa-selector="blob_viewer_file_content"
>
<line-numbers :lines="lineNumbers" />
- <pre class="code gl-pb-0!"><code v-safe-html="highlightedContent"></code>
+ <pre class="code highlight gl-pb-0!"><code v-safe-html="highlightedContent"></code>
</pre>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
index e64e564bf61..d726a8a55ff 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js
@@ -1,11 +1,13 @@
-export const wrapLines = (content) => {
+export const wrapLines = (content, language) => {
+ const isValidLanguage = /^[a-z\d\-_]+$/.test(language); // To prevent the possibility of a vulnerability we only allow languages that contain alphanumeric characters ([a-z\d), dashes (-) or underscores (_).
+
return (
content &&
content
.split('\n')
.map((line, i) => {
let formattedLine;
- const idAttribute = `id="LC${i + 1}"`;
+ const attributes = `id="LC${i + 1}" lang="${isValidLanguage ? language : ''}"`;
if (line.includes('<span class="hljs') && !line.includes('</span>')) {
/**
@@ -14,9 +16,9 @@ export const wrapLines = (content) => {
* example (before): <span class="hljs-code">```bash
* example (after): <span id="LC67" class="hljs-code">```bash
*/
- formattedLine = line.replace(/(?=class="hljs)/, `${idAttribute} `);
+ formattedLine = line.replace(/(?=class="hljs)/, `${attributes} `);
} else {
- formattedLine = `<span ${idAttribute} class="line">${line}</span>`;
+ formattedLine = `<span ${attributes} class="line">${line}</span>`;
}
return formattedLine;
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index efb99eb0d94..d07f65cf5c1 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -1,30 +1,33 @@
<script>
/* This is a re-usable vue component for rendering a user avatar that
- does not need to link to the user's profile. The image and an optional
- tooltip can be configured by props passed to this component.
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
- Sample configuration:
+ Sample configuration:
- <user-avatar-image
- :lazy="true"
- :img-src="userAvatarSrc"
- :img-alt="tooltipText"
- :tooltip-text="tooltipText"
- tooltip-placement="top"
- />
+ <user-avatar-image
+ lazy
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
-*/
+ */
-import { GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
-import { placeholderImage } from '../../../lazy_loader';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import UserAvatarImageNew from './user_avatar_image_new.vue';
+import UserAvatarImageOld from './user_avatar_image_old.vue';
export default {
name: 'UserAvatarImage',
components: {
- GlTooltip,
+ UserAvatarImageNew,
+ UserAvatarImageOld,
},
+ mixins: [glFeatureFlagMixin()],
props: {
lazy: {
type: Boolean,
@@ -62,51 +65,14 @@ export default {
default: 'top',
},
},
- computed: {
- // API response sends null when gravatar is disabled and
- // we provide an empty string when we use it inside user avatar link.
- // In both cases we should render the defaultAvatarUrl
- sanitizedSource() {
- let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- // Only adds the width to the URL if its not a base64 data image
- if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
- baseSrc += `?width=${this.size}`;
- return baseSrc;
- },
- resultantSrcAttribute() {
- return this.lazy ? placeholderImage : this.sanitizedSource;
- },
- avatarSizeClass() {
- return `s${this.size}`;
- },
- },
};
</script>
<template>
- <span>
- <img
- ref="userAvatarImage"
- :class="{
- lazy: lazy,
- [avatarSizeClass]: true,
- [cssClasses]: true,
- }"
- :src="resultantSrcAttribute"
- :width="size"
- :height="size"
- :alt="imgAlt"
- :data-src="sanitizedSource"
- class="avatar"
- />
- <gl-tooltip
- v-if="tooltipText || $slots.default"
- :target="() => $refs.userAvatarImage"
- :placement="tooltipPlacement"
- boundary="window"
- class="js-user-avatar-image-tooltip"
- >
- <slot> {{ tooltipText }} </slot>
- </gl-tooltip>
- </span>
+ <user-avatar-image-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <slot></slot>
+ </user-avatar-image-new>
+ <user-avatar-image-old v-else v-bind="$props">
+ <slot></slot>
+ </user-avatar-image-old>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
new file mode 100644
index 00000000000..f52a3471ea4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
@@ -0,0 +1,106 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar that
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar
+ lazy
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+ */
+
+import { GlTooltip, GlAvatar } from '@gitlab/ui';
+import defaultAvatarUrl from 'images/no_avatar.png';
+import { __ } from '~/locale';
+import { placeholderImage } from '../../../lazy_loader';
+
+export default {
+ name: 'UserAvatarImageNew',
+ components: {
+ GlTooltip,
+ GlAvatar,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: __('user avatar'),
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ computed: {
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside user avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ sanitizedSource() {
+ let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ // Only adds the width to the URL if its not a base64 data image
+ if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
+ baseSrc += `?width=${this.size}`;
+ return baseSrc;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-avatar
+ ref="userAvatar"
+ :class="{
+ lazy: lazy,
+ [cssClasses]: true,
+ }"
+ :src="resultantSrcAttribute"
+ :data-src="sanitizedSource"
+ :size="size"
+ :alt="imgAlt"
+ />
+
+ <gl-tooltip
+ :target="() => $refs.userAvatar.$el"
+ :placement="tooltipPlacement"
+ boundary="window"
+ >
+ <slot> {{ tooltipText }}</slot>
+ </gl-tooltip>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
new file mode 100644
index 00000000000..bca10c76038
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_old.vue
@@ -0,0 +1,110 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar that
+ does not need to link to the user's profile. The image and an optional
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-image
+ lazy
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :tooltip-text="tooltipText"
+ tooltip-placement="top"
+ />
+
+ */
+
+import { GlTooltip } from '@gitlab/ui';
+import defaultAvatarUrl from 'images/no_avatar.png';
+import { __ } from '~/locale';
+import { placeholderImage } from '../../../lazy_loader';
+
+export default {
+ name: 'UserAvatarImageOld',
+ components: {
+ GlTooltip,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: defaultAvatarUrl,
+ },
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: __('user avatar'),
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ },
+ computed: {
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside user avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ sanitizedSource() {
+ let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ // Only adds the width to the URL if its not a base64 data image
+ if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
+ baseSrc += `?width=${this.size}`;
+ return baseSrc;
+ },
+ resultantSrcAttribute() {
+ return this.lazy ? placeholderImage : this.sanitizedSource;
+ },
+ avatarSizeClass() {
+ return `s${this.size}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <img
+ ref="userAvatarImage"
+ :class="{
+ lazy: lazy,
+ [avatarSizeClass]: true,
+ [cssClasses]: true,
+ }"
+ :src="resultantSrcAttribute"
+ :width="size"
+ :height="size"
+ :alt="imgAlt"
+ :data-src="sanitizedSource"
+ class="avatar"
+ />
+ <gl-tooltip
+ :target="() => $refs.userAvatarImage"
+ :placement="tooltipPlacement"
+ boundary="window"
+ >
+ <slot> {{ tooltipText }}</slot>
+ </gl-tooltip>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
index 04423aac651..887deff17c9 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -17,18 +17,17 @@
*/
-import { GlLink, GlTooltipDirective } from '@gitlab/ui';
-import userAvatarImage from './user_avatar_image.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import UserAvatarLinkNew from './user_avatar_link_new.vue';
+import UserAvatarLinkOld from './user_avatar_link_old.vue';
export default {
name: 'UserAvatarLink',
components: {
- GlLink,
- userAvatarImage,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ UserAvatarLinkNew,
+ UserAvatarLinkOld,
},
+ mixins: [glFeatureFlagMixin()],
props: {
lazy: {
type: Boolean,
@@ -76,36 +75,21 @@ export default {
default: '',
},
},
- computed: {
- shouldShowUsername() {
- return this.username.length > 0;
- },
- avatarTooltipText() {
- return this.shouldShowUsername ? '' : this.tooltipText;
- },
- },
};
</script>
<template>
- <gl-link :href="linkHref" class="user-avatar-link">
- <user-avatar-image
- :img-src="imgSrc"
- :img-alt="imgAlt"
- :css-classes="imgCssClasses"
- :size="imgSize"
- :tooltip-text="avatarTooltipText"
- :tooltip-placement="tooltipPlacement"
- :lazy="lazy"
- >
- <slot></slot> </user-avatar-image
- ><span
- v-if="shouldShowUsername"
- v-gl-tooltip
- :title="tooltipText"
- :tooltip-placement="tooltipPlacement"
- class="js-user-avatar-link-username"
- >{{ username }}</span
- ><slot name="avatar-badge"></slot>
- </gl-link>
+ <user-avatar-link-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <slot></slot>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
+ </user-avatar-link-new>
+
+ <user-avatar-link-old v-else v-bind="$props">
+ <slot></slot>
+ <template #avatar-badge>
+ <slot name="avatar-badge"></slot>
+ </template>
+ </user-avatar-link-old>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
new file mode 100644
index 00000000000..3b459569274
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
@@ -0,0 +1,117 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+ a clickable link (likely to the user's profile). The link, image, and
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-link
+ :link-href="userProfileUrl"
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :img-size="20"
+ :tooltip-text="tooltipText"
+ :tooltip-placement="top"
+ :username="username"
+ />
+
+*/
+
+import { GlAvatarLink, GlTooltipDirective } from '@gitlab/ui';
+import UserAvatarImage from './user_avatar_image.vue';
+
+export default {
+ name: 'UserAvatarLinkNew',
+ components: {
+ UserAvatarImage,
+ GlAvatarLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ linkHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ username: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ shouldShowUsername() {
+ return this.username.length > 0;
+ },
+ avatarTooltipText() {
+ return this.shouldShowUsername ? '' : this.tooltipText;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-link :href="linkHref" class="user-avatar-link">
+ <user-avatar-image
+ :img-src="imgSrc"
+ :img-alt="imgAlt"
+ :css-classes="imgCssClasses"
+ :size="imgSize"
+ :tooltip-text="avatarTooltipText"
+ :tooltip-placement="tooltipPlacement"
+ :lazy="lazy"
+ >
+ <slot></slot>
+ </user-avatar-image>
+
+ <span
+ v-if="shouldShowUsername"
+ v-gl-tooltip
+ :title="tooltipText"
+ :tooltip-placement="tooltipPlacement"
+ class="gl-ml-3"
+ data-testid="user-avatar-link-username"
+ >
+ {{ username }}
+ </span>
+
+ <slot name="avatar-badge"></slot>
+ </gl-avatar-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue
new file mode 100644
index 00000000000..c2e46e61e1b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_old.vue
@@ -0,0 +1,117 @@
+<script>
+/* This is a re-usable vue component for rendering a user avatar wrapped in
+ a clickable link (likely to the user's profile). The link, image, and
+ tooltip can be configured by props passed to this component.
+
+ Sample configuration:
+
+ <user-avatar-link
+ :link-href="userProfileUrl"
+ :img-src="userAvatarSrc"
+ :img-alt="tooltipText"
+ :img-size="20"
+ :tooltip-text="tooltipText"
+ :tooltip-placement="top"
+ :username="username"
+ />
+
+*/
+
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+import UserAvatarImage from './user_avatar_image.vue';
+
+export default {
+ name: 'UserAvatarLinkOld',
+ components: {
+ GlLink,
+ UserAvatarImage,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ lazy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ linkHref: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSrc: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgAlt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ tooltipText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ username: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ shouldShowUsername() {
+ return this.username.length > 0;
+ },
+ avatarTooltipText() {
+ return this.shouldShowUsername ? '' : this.tooltipText;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-link :href="linkHref" class="user-avatar-link">
+ <user-avatar-image
+ :img-src="imgSrc"
+ :img-alt="imgAlt"
+ :css-classes="imgCssClasses"
+ :size="imgSize"
+ :tooltip-text="avatarTooltipText"
+ :tooltip-placement="tooltipPlacement"
+ :lazy="lazy"
+ >
+ <slot></slot>
+ </user-avatar-image>
+
+ <span
+ v-if="shouldShowUsername"
+ v-gl-tooltip
+ :title="tooltipText"
+ :tooltip-placement="tooltipPlacement"
+ data-testid="user-avatar-link-username"
+ >
+ {{ username }}
+ </span>
+ <slot name="avatar-badge"></slot>
+ </gl-link>
+ </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 05e0c3b0be3..41507ca94e2 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
@@ -116,7 +116,7 @@ export default {
<div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
- <div v-if="user.bot" class="gl-text-blue-500">
+ <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
<gl-icon name="question" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
<gl-sprintf :message="__('Learn more about %{username}')">
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index b85cae0c64f..9df5254155e 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -1,4 +1,5 @@
<script>
+import { debounce } from 'lodash';
import {
GlDropdown,
GlDropdownForm,
@@ -6,11 +7,14 @@ import {
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
+ GlTooltipDirective,
} from '@gitlab/ui';
-import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
-import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants';
+import { IssuableType } from '~/issues/constants';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { participantsQueries, userSearchQueries } from '~/sidebar/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
export default {
i18n: {
@@ -25,6 +29,9 @@ export default {
SidebarParticipant,
GlLoadingIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
headerText: {
type: String,
@@ -58,13 +65,18 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: IssuableType.Issue,
},
isEditing: {
type: Boolean,
required: false,
default: true,
},
+ issuableId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -89,28 +101,35 @@ export default {
};
},
update(data) {
- return data.workspace?.issuable?.participants.nodes;
+ return data.workspace?.issuable?.participants.nodes.map((node) => ({
+ ...node,
+ canMerge: false,
+ }));
},
error() {
this.$emit('error');
},
},
searchUsers: {
- query: searchUsers,
+ query() {
+ return userSearchQueries[this.issuableType].query;
+ },
variables() {
- return {
- fullPath: this.fullPath,
- search: this.search,
- first: 20,
- };
+ return this.searchUsersVariables;
},
skip() {
return !this.isEditing;
},
update(data) {
- return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || [];
+ return (
+ data.workspace?.users?.nodes
+ .filter((x) => x?.user)
+ .map((node) => ({
+ ...node.user,
+ canMerge: node.mergeRequestInteraction?.canMerge || false,
+ })) || []
+ );
},
- debounce: ASSIGNEES_DEBOUNCE_DELAY,
error() {
this.$emit('error');
this.isSearching = false;
@@ -121,6 +140,23 @@ export default {
},
},
computed: {
+ isMergeRequest() {
+ return this.issuableType === IssuableType.MergeRequest;
+ },
+ searchUsersVariables() {
+ const variables = {
+ fullPath: this.fullPath,
+ search: this.search,
+ first: 20,
+ };
+ if (!this.isMergeRequest) {
+ return variables;
+ }
+ return {
+ ...variables,
+ mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId),
+ };
+ },
isLoading() {
return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
},
@@ -135,8 +171,8 @@ export default {
// TODO this de-duplication is temporary (BE fix required)
// https://gitlab.com/gitlab-org/gitlab/-/issues/327822
- const mergedSearchResults = filteredParticipants
- .concat(this.searchUsers)
+ const mergedSearchResults = this.searchUsers
+ .concat(filteredParticipants)
.reduce(
(acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]),
[],
@@ -179,6 +215,7 @@ export default {
return this.selectedFiltered.length === 0;
},
},
+
watch: {
// We need to add this watcher to track the moment when user is alredy typing
// but query is still not started due to debounce
@@ -188,15 +225,21 @@ export default {
}
},
},
+ created() {
+ this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ },
methods: {
selectAssignee(user) {
let selected = [...this.value];
if (!this.allowMultipleAssignees) {
selected = [user];
+ this.$emit('input', selected);
+ this.$refs.dropdown.hide();
+ this.$emit('toggle');
} else {
selected.push(user);
+ this.$emit('input', selected);
}
- this.$emit('input', selected);
},
unselect(name) {
const selected = this.value.filter((user) => user.username !== name);
@@ -205,6 +248,9 @@ export default {
focusSearch() {
this.$refs.search.focusInput();
},
+ showDropdown() {
+ this.$refs.dropdown.show();
+ },
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
@@ -216,22 +262,37 @@ export default {
const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
if (currentUser) {
+ currentUser.canMerge = this.currentUser.canMerge;
const index = usersCopy.indexOf(currentUser);
usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
}
return usersCopy;
},
+ setSearchKey(value) {
+ this.search = value.trim();
+ },
+ tooltipText(user) {
+ if (!this.isMergeRequest) {
+ return '';
+ }
+ return user.canMerge ? '' : __('Cannot merge');
+ },
},
};
</script>
<template>
- <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
+ <gl-dropdown ref="dropdown" :text="text" @toggle="$emit('toggle')" @shown="focusSearch">
<template #header>
<p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
<gl-dropdown-divider />
- <gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" />
+ <gl-search-box-by-type
+ ref="search"
+ :value="search"
+ class="js-dropdown-input-field"
+ @input="debouncedSearchKeyUpdate"
+ />
</template>
<gl-dropdown-form class="gl-relative gl-min-h-7">
<gl-loading-icon
@@ -247,7 +308,7 @@ export default {
:is-checked="selectedIsEmpty"
:is-check-centered="true"
data-testid="unassign"
- @click="$emit('input', [])"
+ @click.native.capture.stop="$emit('input', [])"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned
@@ -258,27 +319,44 @@ export default {
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
+ v-gl-tooltip.left.viewport
+ :title="tooltipText(item)"
+ boundary="viewport"
is-checked
is-check-centered
data-testid="selected-participant"
- @click.stop="unselect(item.username)"
+ @click.native.capture.stop="unselect(item.username)"
>
- <sidebar-participant :user="item" />
+ <sidebar-participant :user="item" :issuable-type="issuableType" />
</gl-dropdown-item>
<template v-if="showCurrentUser">
<gl-dropdown-divider />
- <gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)">
- <sidebar-participant :user="currentUser" class="gl-pl-6!" />
+ <gl-dropdown-item
+ data-testid="current-user"
+ @click.native.capture.stop="selectAssignee(currentUser)"
+ >
+ <sidebar-participant
+ :user="currentUser"
+ :issuable-type="issuableType"
+ class="gl-pl-6!"
+ />
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
+ v-gl-tooltip.left.viewport
+ :title="tooltipText(unselectedUser)"
+ boundary="viewport"
data-testid="unselected-participant"
- @click="selectAssignee(unselectedUser)"
+ @click.native.capture.stop="selectAssignee(unselectedUser)"
>
- <sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
+ <sidebar-participant
+ :user="unselectedUser"
+ :issuable-type="issuableType"
+ class="gl-pl-6!"
+ />
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
{{ __('No matching results') }}
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 82022d1f4d6..199516b3eb3 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -8,6 +8,7 @@ import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue';
const KEY_EDIT = 'edit';
const KEY_WEB_IDE = 'webide';
const KEY_GITPOD = 'gitpod';
+const KEY_PIPELINE_EDITOR = 'pipeline_editor';
export default {
components: {
@@ -64,6 +65,11 @@ export default {
required: false,
default: false,
},
+ showPipelineEditorButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
userPreferencesGitpodPath: {
type: String,
required: false,
@@ -79,6 +85,11 @@ export default {
required: false,
default: '',
},
+ pipelineEditorUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
webIdeUrl: {
type: String,
required: false,
@@ -117,14 +128,19 @@ export default {
},
data() {
return {
- selection: KEY_WEB_IDE,
+ selection: this.showPipelineEditorButton ? KEY_PIPELINE_EDITOR : KEY_WEB_IDE,
showEnableGitpodModal: false,
showForkModal: false,
};
},
computed: {
actions() {
- return [this.webIdeAction, this.editAction, this.gitpodAction].filter((action) => action);
+ return [
+ this.pipelineEditorAction,
+ this.webIdeAction,
+ this.editAction,
+ this.gitpodAction,
+ ].filter((action) => action);
},
editAction() {
if (!this.showEditButton) {
@@ -162,7 +178,7 @@ export default {
if (this.webIdeText) {
return this.webIdeText;
} else if (this.isBlob) {
- return __('Edit in Web IDE');
+ return __('Open in Web IDE');
} else if (this.isFork) {
return __('Edit fork in Web IDE');
}
@@ -202,6 +218,9 @@ export default {
};
},
gitpodActionText() {
+ if (this.isBlob) {
+ return __('Open in Gitpod');
+ }
return this.gitpodText || __('Gitpod');
},
computedShowGitpodButton() {
@@ -209,11 +228,28 @@ export default {
this.showGitpodButton && this.userPreferencesGitpodPath && this.userProfileEnableGitpodPath
);
},
+ pipelineEditorAction() {
+ if (!this.showPipelineEditorButton) {
+ return null;
+ }
+
+ const secondaryText = __('Edit, lint, and visualize your pipeline.');
+
+ return {
+ key: KEY_PIPELINE_EDITOR,
+ text: __('Edit in pipeline editor'),
+ secondaryText,
+ tooltip: secondaryText,
+ attrs: {
+ 'data-qa-selector': 'pipeline_editor_button',
+ },
+ href: this.pipelineEditorUrl,
+ };
+ },
gitpodAction() {
if (!this.computedShowGitpodButton) {
return null;
}
-
const handleOptions = this.gitpodEnabled
? { href: this.gitpodUrl }
: {