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:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-08-20 21:42:06 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-08-20 21:42:06 +0300
commit6e4e1050d9dba2b7b2523fdd1768823ab85feef4 (patch)
tree78be5963ec075d80116a932011d695dd33910b4e /app/assets/javascripts/vue_shared/components
parent1ce776de4ae122aba3f349c02c17cebeaa8ecf07 (diff)
Add latest changes from gitlab-org/gitlab@13-3-stable-ee
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_modal.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_container.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue66
-rw-r--r--app/assets/javascripts/vue_shared/components/expand_button.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue62
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue126
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue110
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue224
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue43
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js61
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js39
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue26
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/split_button.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown.vue102
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_button.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue14
59 files changed, 1097 insertions, 296 deletions
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 27f1a4f75d5..9e2b3097499 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -1,3 +1,10 @@
+import {
+ SNIPPET_MARK_VIEW_APP_START,
+ SNIPPET_MARK_BLOBS_CONTENT,
+ SNIPPET_MEASURE_BLOBS_CONTENT,
+ SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP,
+} from '~/performance_constants';
+
export default {
props: {
content: {
@@ -9,4 +16,13 @@ export default {
required: true,
},
},
+ mounted() {
+ window.requestAnimationFrame(() => {
+ if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) {
+ performance.mark(SNIPPET_MARK_BLOBS_CONTENT);
+ performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT);
+ performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, SNIPPET_MARK_VIEW_APP_START);
+ }
+ });
+ },
};
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 1eb05780206..55a6267f9ff 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,6 +1,6 @@
<script>
-import ViewerMixin from './mixins';
import { GlIcon } from '@gitlab/ui';
+import ViewerMixin from './mixins';
import { HIGHLIGHT_CLASS_NAME } from './constants';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
index ac95c88225e..6f5ea8dcbee 100644
--- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
@@ -45,7 +45,7 @@ export default {
};
</script>
<template>
- <gl-new-dropdown :text="$options.labels.defaultLabel" category="primary" variant="info">
+ <gl-new-dropdown right :text="$options.labels.defaultLabel" category="primary" variant="info">
<div class="pb-2 mx-1">
<template v-if="sshLink">
<gl-new-dropdown-header>{{ $options.labels.ssh }}</gl-new-dropdown-header>
diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
index 52ff906ccec..e7f6cc1abc0 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue
@@ -1,7 +1,7 @@
<script>
import { GlModal } from '@gitlab/ui';
-import csrf from '~/lib/utils/csrf';
import { uniqueId } from 'lodash';
+import csrf from '~/lib/utils/csrf';
export default {
components: {
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
index 47231c4ad39..3bf629d4acb 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -39,7 +39,7 @@ export default {
<template>
<div class="file-container">
<div class="file-content">
- <p class="prepend-top-10 file-info">
+ <p class="gl-mt-3 file-info">
{{ fileName }}
<template v-if="fileSize > 0">
({{ fileSizeReadable }})
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index 1344c766e0e..f9b678e33cd 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -3,9 +3,9 @@ import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { GlSkeletonLoading } from '@gitlab/ui';
+import { forEach, escape } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-import { forEach, escape } from 'lodash';
const { CancelToken } = axios;
let axiosSource;
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
index ddbb474bab6..3b6b0a91e97 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
@@ -1,5 +1,11 @@
<script>
-import { GlIcon, GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlButton,
+ GlDeprecatedDropdown,
+ GlDeprecatedDropdownItem,
+ GlFormGroup,
+} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
@@ -22,9 +28,9 @@ const events = {
export default {
components: {
GlIcon,
- GlDeprecatedButton,
- GlDropdown,
- GlDropdownItem,
+ GlButton,
+ GlDeprecatedDropdown,
+ GlDeprecatedDropdownItem,
GlFormGroup,
TooltipOnTruncate,
DateTimePickerInput,
@@ -206,7 +212,8 @@ export default {
placement="top"
class="d-inline-block"
>
- <gl-dropdown
+ <gl-deprecated-dropdown
+ ref="dropdown"
:text="timeWindowText"
v-bind="$attrs"
class="date-time-picker w-100"
@@ -215,7 +222,9 @@ export default {
>
<template #button-content>
<span class="gl-flex-grow-1 text-truncate">{{ timeWindowText }}</span>
- <span v-if="utc" class="text-muted gl-font-weight-bold gl-font-sm">{{ __('UTC') }}</span>
+ <span v-if="utc" class="gl-text-gray-500 gl-font-weight-bold gl-font-sm">{{
+ __('UTC')
+ }}</span>
<gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" />
</template>
@@ -242,10 +251,17 @@ export default {
/>
</div>
<gl-form-group>
- <gl-deprecated-button @click="closeDropdown">{{ __('Cancel') }}</gl-deprecated-button>
- <gl-deprecated-button variant="success" :disabled="!isValid" @click="setFixedRange()">
+ <gl-button data-testid="cancelButton" @click="closeDropdown">{{
+ __('Cancel')
+ }}</gl-button>
+ <gl-button
+ variant="success"
+ category="primary"
+ :disabled="!isValid"
+ @click="setFixedRange()"
+ >
{{ __('Apply') }}
- </gl-deprecated-button>
+ </gl-button>
</gl-form-group>
</gl-form-group>
<gl-form-group
@@ -256,7 +272,7 @@ export default {
<span class="gl-pl-5-deprecated-no-really-do-not-use-me">{{ __('Quick range') }}</span>
</template>
- <gl-dropdown-item
+ <gl-deprecated-dropdown-item
v-for="(option, index) in options"
:key="index"
data-qa-selector="quick_range_item"
@@ -270,9 +286,9 @@ export default {
:class="{ invisible: !isOptionActive(option) }"
/>
{{ option.label }}
- </gl-dropdown-item>
+ </gl-deprecated-dropdown-item>
</gl-form-group>
</div>
- </gl-dropdown>
+ </gl-deprecated-dropdown>
</tooltip-on-truncate>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_container.vue b/app/assets/javascripts/vue_shared/components/dismissible_container.vue
new file mode 100644
index 00000000000..b4227bae09e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/dismissible_container.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ featureId: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ dismiss() {
+ axios
+ .post(this.path, {
+ feature_name: this.featureId,
+ })
+ .catch(e => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings, no-console
+ console.error('Failed to dismiss message.', e);
+ });
+
+ this.$emit('dismiss');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-flex gl-align-items-center">
+ <slot name="title"></slot>
+ <div class="ml-auto">
+ <button
+ :aria-label="__('Close')"
+ class="btn-blank"
+ type="button"
+ data-testid="close"
+ @click="dismiss"
+ >
+ <gl-icon name="close" aria-hidden="true" class="gl-text-gray-500" />
+ </button>
+ </div>
+ </div>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
new file mode 100644
index 00000000000..c7d7c3a1d24
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { slugifyWithUnderscore } from '~/lib/utils/text_utility';
+
+export default {
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ LocalStorageSync,
+ },
+ props: {
+ featureName: {
+ type: String,
+ required: true,
+ },
+ feedbackLink: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDismissed: 'false',
+ };
+ },
+ computed: {
+ storageKey() {
+ return `${slugifyWithUnderscore(this.featureName)}_feedback_dismissed`;
+ },
+ showAlert() {
+ return this.isDismissed === 'false';
+ },
+ },
+ methods: {
+ dismissFeedbackAlert() {
+ this.isDismissed = 'true';
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-show="showAlert">
+ <local-storage-sync
+ :value="isDismissed"
+ :storage-key="storageKey"
+ @input="dismissFeedbackAlert"
+ />
+ <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert">
+ <gl-sprintf
+ :message="
+ __(
+ 'We’ve been making changes to %{featureName} and we’d love your feedback %{linkStart}in this issue%{linkEnd} to help us improve the experience.',
+ )
+ "
+ >
+ <template #featureName>{{ featureName }}</template>
+ <template #link="{ content }">
+ <gl-link :href="feedbackLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue
index 1f904cd3c6c..546ee56355f 100644
--- a/app/assets/javascripts/vue_shared/components/expand_button.vue
+++ b/app/assets/javascripts/vue_shared/components/expand_button.vue
@@ -1,7 +1,6 @@
<script>
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
-import Icon from '~/vue_shared/components/icon.vue';
/**
* Port of detail_behavior expand button.
@@ -16,8 +15,7 @@ import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'ExpandButton',
components: {
- GlDeprecatedButton,
- Icon,
+ GlButton,
},
data() {
return {
@@ -41,25 +39,23 @@ export default {
</script>
<template>
<span>
- <gl-deprecated-button
+ <gl-button
v-show="isCollapsed"
:aria-label="ariaLabel"
type="button"
class="js-text-expander-prepend text-expander btn-blank"
+ icon="ellipsis_h"
@click="onClick"
- >
- <icon :size="12" name="ellipsis_h" />
- </gl-deprecated-button>
+ />
<span v-if="isCollapsed"> <slot name="short"></slot> </span>
<span v-if="!isCollapsed"> <slot name="expanded"></slot> </span>
- <gl-deprecated-button
+ <gl-button
v-show="!isCollapsed"
:aria-label="ariaLabel"
type="button"
class="js-text-expander-append text-expander btn-blank"
+ icon="ellipsis_h"
@click="onClick"
- >
- <icon :size="12" name="ellipsis_h" />
- </gl-deprecated-button>
+ />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index 7484486d6b4..6190b07962d 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -87,7 +87,7 @@ export default {
<span>
<gl-loading-icon v-if="loading" :inline="true" />
<gl-icon v-else-if="isSymlink" name="symlink" :size="size" />
- <svg v-else-if="!folder" :class="[iconSizeClass, cssClasses]">
+ <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]">
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
<gl-icon v-else :name="folderIconName" :size="size" class="folder-icon" />
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 9ecae87c1a9..b70f093e930 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
@@ -586,5 +586,16 @@ const fileNameIcons = {
};
export default function getIconForFile(name) {
- return fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop() : ''] || '';
+ return (
+ fileNameIcons[name] ||
+ fileExtensionIcons[
+ name
+ ? name
+ .split('.')
+ .pop()
+ .toLowerCase()
+ : ''
+ ] ||
+ ''
+ );
}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 6665a5754b3..7b3d1d0afd6 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -1,8 +1,23 @@
+import { __ } from '~/locale';
+
export const ANY_AUTHOR = 'Any';
+export const NO_LABEL = 'No label';
+
export const DEBOUNCE_DELAY = 200;
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
};
+
+export const defaultMilestones = [
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ { value: 'None', text: __('None') },
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ { value: 'Any', text: __('Any') },
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ { value: 'Upcoming', text: __('Upcoming') },
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ { value: 'Started', text: __('Started') },
+];
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 04090213218..ee293d37b66 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
@@ -8,13 +8,14 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
+import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import { __ } from '~/locale';
-import createFlash from '~/flash';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
-import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
+import { stripQuotes } from './filtered_search_utils';
import { SortDirection } from './constants';
export default {
@@ -44,7 +45,8 @@ export default {
},
sortOptions: {
type: Array,
- required: true,
+ default: () => [],
+ required: false,
},
initialFilterValue: {
type: Array,
@@ -63,7 +65,7 @@ export default {
},
},
data() {
- let selectedSortOption = this.sortOptions[0].sortDirection.descending;
+ let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending;
let selectedSortDirection = SortDirection.descending;
// Extract correct sortBy value based on initialSortBy
@@ -118,6 +120,11 @@ export default {
? __('Sort direction: Ascending')
: __('Sort direction: Descending');
},
+ filteredRecentSearches() {
+ return this.recentSearchesStorageKey
+ ? this.recentSearches.filter(item => typeof item !== 'string')
+ : undefined;
+ },
},
watch: {
/**
@@ -184,6 +191,41 @@ export default {
this.recentSearches = resultantSearches;
});
},
+ /**
+ * When user hits Enter/Return key while typing tokens, we emit `onFilter`
+ * event immediately so at that time, we don't want to keep tokens dropdown
+ * visible on UI so this is essentially a hack which allows us to do that
+ * until `GlFilteredSearch` natively supports this.
+ * See this discussion https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36421#note_385729546
+ */
+ blurSearchInput() {
+ const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector(
+ '.gl-filtered-search-token-segment-input',
+ );
+ if (searchInputEl) {
+ searchInputEl.blur();
+ }
+ },
+ /**
+ * This method removes quotes enclosure from filter values which are
+ * done by `GlFilteredSearch` internally when filter value contains
+ * spaces.
+ */
+ removeQuotesEnclosure(filters = []) {
+ return filters.map(filter => {
+ if (typeof filter === 'object') {
+ const valueString = filter.value.data;
+ return {
+ ...filter,
+ value: {
+ data: stripQuotes(valueString),
+ operator: filter.value.operator,
+ },
+ };
+ }
+ return filter;
+ });
+ },
handleSortOptionClick(sortBy) {
this.selectedSortOption = sortBy;
this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
@@ -196,7 +238,7 @@ export default {
this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
},
handleHistoryItemSelected(filters) {
- this.$emit('onFilter', filters);
+ this.$emit('onFilter', this.removeQuotesEnclosure(filters));
},
handleClearHistory() {
const resultantSearches = this.recentSearchesStore.setRecentSearches([]);
@@ -217,7 +259,8 @@ export default {
// https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
});
}
- this.$emit('onFilter', filters);
+ this.blurSearchInput();
+ this.$emit('onFilter', this.removeQuotesEnclosure(filters));
},
},
};
@@ -226,10 +269,11 @@ export default {
<template>
<div class="vue-filtered-search-bar-container d-md-flex">
<gl-filtered-search
+ ref="filteredSearchInput"
v-model="filterValue"
:placeholder="searchInputPlaceholder"
:available-tokens="tokens"
- :history-items="recentSearches"
+ :history-items="filteredRecentSearches"
class="flex-grow-1"
@history-item-selected="handleHistoryItemSelected"
@clear-history="handleClearHistory"
@@ -238,7 +282,7 @@ export default {
<template #history-item="{ historyItem }">
<template v-for="(token, index) in historyItem">
<span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span>
- <span v-else :key="`${token.type}-${token.value.data}`" class="gl-px-1">
+ <span v-else :key="`${index}-${token.type}-${token.value.data}`" class="gl-px-1">
<span v-if="tokenTitles[token.type]"
>{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span
>
@@ -247,7 +291,7 @@ export default {
</template>
</template>
</gl-filtered-search>
- <gl-button-group class="sort-dropdown-container d-flex">
+ <gl-button-group v-if="selectedSortOption" class="sort-dropdown-container d-flex">
<gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
<gl-dropdown-item
v-for="sortBy in sortOptions"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
new file mode 100644
index 00000000000..85f7f746b49
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
@@ -0,0 +1,4 @@
+// eslint-disable-next-line import/prefer-default-export
+export const stripQuotes = value => {
+ return value.includes(' ') ? value.slice(1, -1) : value;
+};
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 d50649d2581..969e914ef0c 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
@@ -3,12 +3,12 @@ import {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
- GlDropdownDivider,
+ GlDeprecatedDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
-import createFlash from '~/flash';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale';
import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants';
@@ -19,7 +19,7 @@ export default {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
- GlDropdownDivider,
+ GlDeprecatedDropdownDivider,
GlLoadingIcon,
},
props: {
@@ -102,7 +102,7 @@ export default {
<gl-filtered-search-suggestion :value="$options.anyAuthor">
{{ __('Any') }}
</gl-filtered-search-suggestion>
- <gl-dropdown-divider />
+ <gl-deprecated-dropdown-divider />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
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
new file mode 100644
index 00000000000..726a1c49993
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -0,0 +1,126 @@
+<script>
+import {
+ GlToken,
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlNewDropdownDivider as GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { __ } from '~/locale';
+
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+import { stripQuotes } from '../filtered_search_utils';
+import { NO_LABEL, DEBOUNCE_DELAY } from '../constants';
+
+export default {
+ noLabel: NO_LABEL,
+ components: {
+ GlToken,
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ labels: this.config.initialLabels || [],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeLabel() {
+ return this.labels.find(
+ label => label.title.toLowerCase() === stripQuotes(this.currentValue),
+ );
+ },
+ containerStyle() {
+ if (this.activeLabel) {
+ const { color, textColor } = convertObjectPropsToCamelCase(this.activeLabel);
+
+ return { backgroundColor: color, color: textColor };
+ }
+ return {};
+ },
+ },
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.labels.length) {
+ this.fetchLabelBySearchTerm(this.value.data);
+ }
+ },
+ },
+ },
+ methods: {
+ fetchLabelBySearchTerm(searchTerm) {
+ this.loading = true;
+ this.config
+ .fetchLabels(searchTerm)
+ .then(res => {
+ // We'd want to avoid doing this check but
+ // labels.json and /groups/:id/labels & /projects/:id/labels
+ // return response differently.
+ this.labels = Array.isArray(res) ? res : res.data;
+ })
+ .catch(() => createFlash(__('There was a problem fetching labels.')))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchLabels: debounce(function debouncedSearch({ data }) {
+ this.fetchLabelBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchLabels"
+ >
+ <template #view-token="{ inputValue, cssClasses, listeners }">
+ <gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners"
+ >~{{ activeLabel ? activeLabel.title : inputValue }}</gl-token
+ >
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion :value="$options.noLabel">{{
+ __('No label')
+ }}</gl-filtered-search-suggestion>
+ <gl-dropdown-divider />
+ <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">
+ <span
+ :style="{ backgroundColor: label.color }"
+ class="gl-display-inline-block mr-2 p-2"
+ ></span>
+ <div>{{ label.title }}</div>
+ </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/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
new file mode 100644
index 00000000000..cf1ac4e718b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -0,0 +1,110 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlNewDropdownDivider as GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+import { stripQuotes } from '../filtered_search_utils';
+import { defaultMilestones, DEBOUNCE_DELAY } from '../constants';
+
+export default {
+ defaultMilestones,
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ milestones: this.config.initialMilestones || [],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeMilestone() {
+ return this.milestones.find(
+ milestone => milestone.title.toLowerCase() === stripQuotes(this.currentValue),
+ );
+ },
+ },
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.milestones.length) {
+ this.fetchMilestoneBySearchTerm(this.value.data);
+ }
+ },
+ },
+ },
+ methods: {
+ fetchMilestoneBySearchTerm(searchTerm = '') {
+ this.loading = true;
+ this.config
+ .fetchMilestones(searchTerm)
+ .then(({ data }) => {
+ this.milestones = data;
+ })
+ .catch(() => createFlash(__('There was a problem fetching milestones.')))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchMilestones: debounce(function debouncedSearch({ data }) {
+ this.fetchMilestoneBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchMilestones"
+ >
+ <template #view="{ inputValue }">
+ <span>%{{ activeMilestone ? activeMilestone.title : inputValue }}</span>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="milestone in $options.defaultMilestones"
+ :key="milestone.value"
+ :value="milestone.value"
+ >{{ milestone.text }}</gl-filtered-search-suggestion
+ >
+ <gl-dropdown-divider />
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="milestone in milestones"
+ :key="milestone.id"
+ :value="milestone.title"
+ >
+ <div>{{ milestone.title }}</div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index 0ef4f1eda27..00bc46257ed 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -5,39 +5,102 @@ import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
-/**
- * Creates the HTML template for each row of the mentions dropdown.
- *
- * @param original - An object from the array returned from the `autocomplete_sources/members` API
- * @returns {string} - An HTML template
- */
-function menuItemTemplate({ original }) {
- const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
-
- const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
- gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
-
- const avatarTag = original.avatar_url
- ? `<img
- src="${original.avatar_url}"
- alt="${original.username}'s avatar"
- class="${avatarClasses}"/>`
- : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
-
- const name = escape(original.name);
-
- const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
-
- const icon = original.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
- : '';
-
- return `${avatarTag}
- ${original.username}
- <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
- ${icon}`;
+const AutoComplete = {
+ Issues: 'issues',
+ Labels: 'labels',
+ Members: 'members',
+};
+
+function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
+ const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
+ const currentLine = fullText.split('\n')[currentLineNumber - 1];
+ return currentLine.startsWith(searchString);
}
+const autoCompleteMap = {
+ [AutoComplete.Issues]: {
+ filterValues() {
+ return this[AutoComplete.Issues];
+ },
+ menuItemTemplate({ original }) {
+ return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`;
+ },
+ },
+ [AutoComplete.Labels]: {
+ filterValues() {
+ const fullText = this.$slots.default?.[0]?.elm?.value;
+ const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart;
+
+ if (doesCurrentLineStartWith('/label', fullText, selectionStart)) {
+ return this.labels.filter(label => !label.set);
+ }
+
+ if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) {
+ return this.labels.filter(label => label.set);
+ }
+
+ return this.labels;
+ },
+ menuItemTemplate({ original }) {
+ return `
+ <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span>
+ ${escape(original.title)}`;
+ },
+ },
+ [AutoComplete.Members]: {
+ filterValues() {
+ const fullText = this.$slots.default?.[0]?.elm?.value;
+ const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart;
+
+ // Need to check whether sidebar store assignees has been updated
+ // in the case where the assignees AJAX response comes after the user does @ autocomplete
+ const isAssigneesLengthSame =
+ this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length;
+
+ if (!this.assignees || !isAssigneesLengthSame) {
+ this.assignees =
+ SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
+ }
+
+ if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) {
+ return this.members.filter(member => !this.assignees.includes(member.username));
+ }
+
+ if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) {
+ return this.members.filter(member => this.assignees.includes(member.username));
+ }
+
+ return this.members;
+ },
+ menuItemTemplate({ original }) {
+ const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
+
+ const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
+ gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
+
+ const avatarTag = original.avatar_url
+ ? `<img
+ src="${original.avatar_url}"
+ alt="${original.username}'s avatar"
+ class="${avatarClasses}"/>`
+ : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
+
+ const name = escape(original.name);
+
+ const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
+
+ const icon = original.mentionsDisabled
+ ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
+ : '';
+
+ return `${avatarTag}
+ ${original.username}
+ <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
+ ${icon}`;
+ },
+ },
+};
+
export default {
name: 'GlMentions',
props: {
@@ -47,67 +110,64 @@ export default {
default: () => gl.GfmAutoComplete?.dataSources || {},
},
},
- data() {
- return {
- assignees: undefined,
- members: undefined,
- };
- },
mounted() {
+ const NON_WORD_OR_INTEGER = /\W|^\d+$/;
+
this.tribute = new Tribute({
- trigger: '@',
- fillAttr: 'username',
- lookup: value => value.name + value.username,
- menuItemTemplate,
- values: this.getMembers,
+ collection: [
+ {
+ trigger: '#',
+ lookup: value => value.iid + value.title,
+ menuItemTemplate: autoCompleteMap[AutoComplete.Issues].menuItemTemplate,
+ selectTemplate: ({ original }) => original.reference || `#${original.iid}`,
+ values: this.getValues(AutoComplete.Issues),
+ },
+ {
+ trigger: '@',
+ fillAttr: 'username',
+ lookup: value => value.name + value.username,
+ menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate,
+ values: this.getValues(AutoComplete.Members),
+ },
+ {
+ trigger: '~',
+ lookup: 'title',
+ menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate,
+ selectTemplate: ({ original }) =>
+ NON_WORD_OR_INTEGER.test(original.title)
+ ? `~"${original.title}"`
+ : `~${original.title}`,
+ values: this.getValues(AutoComplete.Labels),
+ },
+ ],
});
- const input = this.$slots.default[0].elm;
+ const input = this.$slots.default?.[0]?.elm;
this.tribute.attach(input);
},
beforeDestroy() {
- const input = this.$slots.default[0].elm;
+ const input = this.$slots.default?.[0]?.elm;
this.tribute.detach(input);
},
methods: {
- /**
- * Creates the list of users to show in the mentions dropdown.
- *
- * @param inputText - The text entered by the user in the mentions input field
- * @param processValues - Callback function to set the list of users to show in the mentions dropdown
- */
- getMembers(inputText, processValues) {
- if (this.members) {
- processValues(this.getFilteredMembers());
- } else if (this.dataSources.members) {
- axios
- .get(this.dataSources.members)
- .then(response => {
- this.members = response.data;
- processValues(this.getFilteredMembers());
- })
- .catch(() => {});
- } else {
- processValues([]);
- }
- },
- getFilteredMembers() {
- const fullText = this.$slots.default[0].elm.value;
-
- if (!this.assignees) {
- this.assignees =
- SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
- }
-
- if (fullText.startsWith('/assign @')) {
- return this.members.filter(member => !this.assignees.includes(member.username));
- }
-
- if (fullText.startsWith('/unassign @')) {
- return this.members.filter(member => this.assignees.includes(member.username));
- }
-
- return this.members;
+ getValues(autoCompleteType) {
+ return (inputText, processValues) => {
+ if (this[autoCompleteType]) {
+ const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
+ processValues(filteredValues);
+ } else if (this.dataSources[autoCompleteType]) {
+ axios
+ .get(this.dataSources[autoCompleteType])
+ .then(response => {
+ this[autoCompleteType] = response.data;
+ const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
+ processValues(filteredValues);
+ })
+ .catch(() => {});
+ } else {
+ processValues([]);
+ }
+ };
},
},
render(createElement) {
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 2665bb4aa92..2625fcc9d09 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -105,7 +105,7 @@ export default {
</template>
</section>
- <section v-if="$slots.default" class="header-action-buttons">
+ <section v-if="$slots.default" data-testid="headerButtons" class="gl-display-flex">
<slot></slot>
</section>
<gl-deprecated-button
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
index 80908cbbc9c..68eeadf0f25 100644
--- a/app/assets/javascripts/vue_shared/components/icon.vue
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -61,7 +61,12 @@ export default {
</script>
<template>
- <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true" v-on="$listeners">
+ <svg
+ :key="spriteHref"
+ :class="[iconSizeClass, iconTestClass]"
+ aria-hidden="true"
+ v-on="$listeners"
+ >
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js
new file mode 100644
index 00000000000..18bfcc268dc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import IssuableHeaderWarnings from './issuable_header_warnings.vue';
+
+export default function issuableHeaderWarnings(store) {
+ return new Vue({
+ el: document.getElementById('js-issuable-header-warnings'),
+ store,
+ render(createElement) {
+ return createElement(IssuableHeaderWarnings);
+ },
+ });
+}
diff --git a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue
new file mode 100644
index 00000000000..37995b434c4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue
@@ -0,0 +1,43 @@
+<script>
+import { mapGetters } from 'vuex';
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ computed: {
+ ...mapGetters(['getNoteableData']),
+ isLocked() {
+ return this.getNoteableData.discussion_locked;
+ },
+ isConfidential() {
+ return this.getNoteableData.confidential;
+ },
+ warningIconsMeta() {
+ return [
+ {
+ iconName: 'lock',
+ visible: this.isLocked,
+ dataTestId: 'locked',
+ },
+ {
+ iconName: 'eye-slash',
+ visible: this.isConfidential,
+ dataTestId: 'confidential',
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-inline-block">
+ <template v-for="meta in warningIconsMeta">
+ <div v-if="meta.visible" :key="meta.iconName" class="issuable-warning-icon inline">
+ <gl-icon :name="meta.iconName" :data-testid="meta.dataTestId" class="icon" />
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
index 1524b313f9f..3006ba83f98 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
@@ -86,12 +86,13 @@ export default {
:img-css-classes="imgCssClasses"
:img-src="avatarUrl(assignee)"
:img-size="iconSize"
- class="js-no-trigger"
+ class="js-no-trigger author-link"
tooltip-placement="bottom"
+ data-qa-selector="assignee_link"
>
<span class="js-assignee-tooltip">
<span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }}
- <span class="text-white-50">@{{ assignee.username }}</span>
+ <span v-if="assignee.username" class="text-white-50">@{{ assignee.username }}</span>
</span>
</user-avatar-link>
<span
@@ -100,6 +101,7 @@ export default {
:title="assigneesCounterTooltip"
class="avatar-counter"
data-placement="bottom"
+ data-qa-selector="avatar_counter_content"
>{{ assigneeCounterLabel }}</span
>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index caf13bc898b..1662e7923b7 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -4,6 +4,7 @@ import { GlIcon, GlTooltip, GlTooltipDirective } from '@gitlab/ui';
import { sprintf } from '~/locale';
import IssueMilestone from './issue_milestone.vue';
import IssueAssignees from './issue_assignees.vue';
+import IssueDueDate from '~/boards/components/issue_due_date.vue';
import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
import CiIcon from '../ci_icon.vue';
@@ -15,6 +16,8 @@ export default {
CiIcon,
GlIcon,
GlTooltip,
+ IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
+ IssueDueDate,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -38,13 +41,6 @@ export default {
},
);
},
- heightStyle() {
- return {
- minHeight: '32px',
- width: '0px',
- visibility: 'hidden',
- };
- },
iconClasses() {
return `${this.iconClass} ic-${this.iconName}`;
},
@@ -60,7 +56,9 @@ export default {
}"
class="item-body d-flex align-items-center py-2 px-3"
>
- <div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
+ <div
+ class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap gl-min-h-7"
+ >
<!-- Title area: Status icon (XL) and title -->
<div class="item-title d-flex align-items-xl-center mb-xl-0">
<div ref="iconElementXL">
@@ -125,8 +123,21 @@ export default {
/>
<!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue -->
- <slot name="dueDate"></slot>
- <slot name="weight"></slot>
+ <span v-if="weight > 0" class="order-md-1">
+ <issue-weight
+ :weight="weight"
+ class="item-weight gl-display-flex gl-align-items-center"
+ tag-name="span"
+ />
+ </span>
+
+ <span v-if="dueDate" class="order-md-1">
+ <issue-due-date
+ :date="dueDate"
+ tooltip-placement="top"
+ css-class="item-due-date gl-display-flex gl-align-items-center"
+ />
+ </span>
<issue-assignees
v-if="hasAssignees"
@@ -159,9 +170,5 @@ export default {
>
<icon :size="16" class="btn-item-remove-icon" name="close" />
</button>
-
- <!-- This element serves to set the issue card's height at a minimum of 32 px. -->
- <!-- It fixes #59594: when the remove button is missing, issues have inconsistent heights. -->
- <span :style="heightStyle"></span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index f954b8eb4f4..6df0119c3db 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -4,7 +4,7 @@ import '~/behaviors/markdown/render_gfm';
import { unescape } from 'lodash';
import { __, sprintf } from '~/locale';
import { stripHtml } from '~/lib/utils/text_utility';
-import Flash from '~/flash';
+import { deprecatedCreateFlash as Flash } from '~/flash';
import GLForm from '~/gl_form';
import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.vue';
@@ -167,11 +167,11 @@ export default {
return new GLForm($(this.$refs['gl-form']), {
emojis: this.enableAutocomplete,
members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- issues: this.enableAutocomplete,
+ issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
mergeRequests: this.enableAutocomplete,
epics: this.enableAutocomplete,
milestones: this.enableAutocomplete,
- labels: this.enableAutocomplete,
+ labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
snippets: this.enableAutocomplete,
});
},
@@ -250,7 +250,7 @@ export default {
</gl-mentions>
<slot v-else name="textarea"></slot>
<a
- class="zen-control zen-control-leave js-zen-leave gl-text-gray-700"
+ class="zen-control zen-control-leave js-zen-leave gl-text-gray-500"
href="#"
:aria-label="__('Leave zen mode')"
>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 049f5e71849..7e6edcfbd25 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,6 +1,8 @@
<script>
import $ from 'jquery';
-import { GlPopover, GlDeprecatedButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { getSelectedFragment } from '~/lib/utils/common_utils';
+import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
import Icon from '../icon.vue';
@@ -9,7 +11,7 @@ export default {
ToolbarButton,
Icon,
GlPopover,
- GlDeprecatedButton,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -35,6 +37,11 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ tag: '> ',
+ };
+ },
computed: {
mdTable() {
return [
@@ -81,6 +88,24 @@ export default {
handleSuggestDismissed() {
this.$emit('handleSuggestDismissed');
},
+ handleQuote() {
+ const documentFragment = getSelectedFragment();
+
+ if (!documentFragment || !documentFragment.textContent) {
+ this.tag = '> ';
+ return;
+ }
+ this.tag = '';
+
+ const transformed = CopyAsGFM.transformGFMSelection(documentFragment);
+ const area = this.$el.parentNode.querySelector('textarea');
+
+ CopyAsGFM.nodeToGFM(transformed)
+ .then(gfm => {
+ CopyAsGFM.insertPastedText(area, documentFragment.textContent, CopyAsGFM.quoted(gfm));
+ })
+ .catch(() => {});
+ },
},
};
</script>
@@ -108,9 +133,10 @@ export default {
<toolbar-button tag="*" :button-title="__('Add italic text')" icon="italic" />
<toolbar-button
:prepend="true"
- tag="> "
+ :tag="tag"
:button-title="__('Insert a quote')"
icon="quote"
+ @click="handleQuote"
/>
</div>
<div class="d-inline-block ml-md-2 ml-0">
@@ -141,9 +167,14 @@ export default {
)
}}
</p>
- <gl-deprecated-button variant="primary" size="sm" @click="handleSuggestDismissed">
+ <gl-button
+ variant="info"
+ category="primary"
+ size="sm"
+ @click="handleSuggestDismissed"
+ >
{{ __('Got it') }}
- </gl-deprecated-button>
+ </gl-button>
</gl-popover>
</template>
<toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 9527c5114f2..1216484b35f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -2,7 +2,7 @@
import Vue from 'vue';
import { __ } from '~/locale';
import SuggestionDiff from './suggestion_diff.vue';
-import Flash from '~/flash';
+import { deprecatedCreateFlash as Flash } from '~/flash';
export default {
props: {
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
index dd1da847001..c08659919fa 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -1,13 +1,11 @@
import { __ } from '~/locale';
-import { generateToolbarItem } from './services/editor_service';
-import buildCustomHTMLRenderer from './services/build_custom_renderer';
export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal',
};
/* eslint-disable @gitlab/require-i18n-strings */
-const TOOLBAR_ITEM_CONFIGS = [
+export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
{ icon: 'bold', command: 'Bold', tooltip: __('Add bold text') },
{ icon: 'italic', command: 'Italic', tooltip: __('Add italic text') },
@@ -30,11 +28,6 @@ const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
];
-export const EDITOR_OPTIONS = {
- toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)),
- customHTMLRenderer: buildCustomHTMLRenderer(),
-};
-
export const EDITOR_TYPES = {
markdown: 'markdown',
wysiwyg: 'wysiwyg',
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
index 0a444b2295d..429a4e04110 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue
@@ -1,6 +1,6 @@
<script>
-import { isSafeURL } from '~/lib/utils/url_utility';
import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
+import { isSafeURL } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IMAGE_TABS } from '../../constants';
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue
index 739f8b502c9..9baa7f286d7 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue
@@ -1,6 +1,6 @@
<script>
-import { __ } from '~/locale';
import { GlFormGroup } from '@gitlab/ui';
+import { __ } from '~/locale';
import { MAX_FILE_SIZE } from '../../constants';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
index baeb98bec75..d96fe46522e 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
@@ -3,16 +3,11 @@ import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
import AddImageModal from './modals/add_image/add_image_modal.vue';
-import {
- EDITOR_OPTIONS,
- EDITOR_TYPES,
- EDITOR_HEIGHT,
- EDITOR_PREVIEW_STYLE,
- CUSTOM_EVENTS,
-} from './constants';
+import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants';
import {
registerHTMLToMarkdownRenderer,
+ getEditorOptions,
addCustomEventListener,
removeCustomEventListener,
addImage,
@@ -35,7 +30,7 @@ export default {
options: {
type: Object,
required: false,
- default: () => EDITOR_OPTIONS,
+ default: () => null,
},
initialEditType: {
type: String,
@@ -65,13 +60,13 @@ export default {
};
},
computed: {
- editorOptions() {
- return { ...EDITOR_OPTIONS, ...this.options };
- },
editorInstance() {
return this.$refs.editor;
},
},
+ created() {
+ this.editorOptions = getEditorOptions(this.options);
+ },
beforeDestroy() {
this.removeListeners();
},
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
index 70d29b5b3df..a9c5d442f62 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js
@@ -1,16 +1,18 @@
+import { union, mapValues } from 'lodash';
import renderBlockHtml from './renderers/render_html_block';
import renderKramdownList from './renderers/render_kramdown_list';
import renderKramdownText from './renderers/render_kramdown_text';
import renderIdentifierInstanceText from './renderers/render_identifier_instance_text';
import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
-import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text';
import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
+import renderSoftbreak from './renderers/render_softbreak';
const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
const htmlBlockRenderers = [renderBlockHtml];
const listRenderers = [renderKramdownList];
const paragraphRenderers = [renderIdentifierParagraph];
-const textRenderers = [renderKramdownText, renderEmbeddedRubyText, renderIdentifierInstanceText];
+const textRenderers = [renderKramdownText, renderIdentifierInstanceText];
+const softbreakRenderers = [renderSoftbreak];
const executeRenderer = (renderers, node, context) => {
const availableRenderer = renderers.find(renderer => renderer.canRender(node, context));
@@ -18,51 +20,20 @@ const executeRenderer = (renderers, node, context) => {
return availableRenderer ? availableRenderer.render(node, context) : context.origin();
};
-const buildCustomRendererFunctions = (customRenderers, defaults) => {
- const customTypes = Object.keys(customRenderers).filter(type => !defaults[type]);
- const customEntries = customTypes.map(type => {
- const fn = (node, context) => executeRenderer(customRenderers[type], node, context);
- return [type, fn];
- });
-
- return Object.fromEntries(customEntries);
-};
-
-const buildCustomHTMLRenderer = (
- customRenderers = { htmlBlock: [], htmlInline: [], list: [], paragraph: [], text: [] },
-) => {
- const defaults = {
- htmlBlock(node, context) {
- const allHtmlBlockRenderers = [...customRenderers.htmlBlock, ...htmlBlockRenderers];
-
- return executeRenderer(allHtmlBlockRenderers, node, context);
- },
- htmlInline(node, context) {
- const allHtmlInlineRenderers = [...customRenderers.htmlInline, ...htmlInlineRenderers];
-
- return executeRenderer(allHtmlInlineRenderers, node, context);
- },
- list(node, context) {
- const allListRenderers = [...customRenderers.list, ...listRenderers];
-
- return executeRenderer(allListRenderers, node, context);
- },
- paragraph(node, context) {
- const allParagraphRenderers = [...customRenderers.paragraph, ...paragraphRenderers];
-
- return executeRenderer(allParagraphRenderers, node, context);
- },
- text(node, context) {
- const allTextRenderers = [...customRenderers.text, ...textRenderers];
-
- return executeRenderer(allTextRenderers, node, context);
- },
+const buildCustomHTMLRenderer = customRenderers => {
+ const renderersByType = {
+ ...customRenderers,
+ htmlBlock: union(htmlBlockRenderers, customRenderers?.htmlBlock),
+ htmlInline: union(htmlInlineRenderers, customRenderers?.htmlInline),
+ list: union(listRenderers, customRenderers?.list),
+ paragraph: union(paragraphRenderers, customRenderers?.paragraph),
+ text: union(textRenderers, customRenderers?.text),
+ softbreak: union(softbreakRenderers, customRenderers?.softbreak),
};
- return {
- ...buildCustomRendererFunctions(customRenderers, defaults),
- ...defaults,
- };
+ return mapValues(renderersByType, renderers => {
+ return (node, context) => executeRenderer(renderers, node, context);
+ });
};
export default buildCustomHTMLRenderer;
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
index ed04765c871..868ede9426e 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js
@@ -1,7 +1,12 @@
+/* eslint-disable @gitlab/require-i18n-strings */
import { defaults, repeat } from 'lodash';
const DEFAULTS = {
subListIndentSpaces: 4,
+ unorderedListBulletChar: '-',
+ incrementListMarker: false,
+ strong: '*',
+ emphasis: '_',
};
const countIndentSpaces = text => {
@@ -11,9 +16,18 @@ const countIndentSpaces = text => {
};
const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => {
- const { subListIndentSpaces } = defaults(formattingPreferences, DEFAULTS);
- // eslint-disable-next-line @gitlab/require-i18n-strings
+ const {
+ subListIndentSpaces,
+ unorderedListBulletChar,
+ incrementListMarker,
+ strong,
+ emphasis,
+ } = defaults(formattingPreferences, DEFAULTS);
const sublistNode = 'LI OL, LI UL';
+ const unorderedListItemNode = 'UL LI';
+ const orderedListItemNode = 'OL LI';
+ const emphasisNode = 'EM, I';
+ const strongNode = 'STRONG, B';
return {
TEXT_NODE(node) {
@@ -47,6 +61,27 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) =>
return reindentedList;
},
+ [unorderedListItemNode](node, subContent) {
+ const baseResult = baseRenderer.convert(node, subContent);
+
+ return baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`);
+ },
+ [orderedListItemNode](node, subContent) {
+ const baseResult = baseRenderer.convert(node, subContent);
+
+ return incrementListMarker ? baseResult : baseResult.replace(/^(\s*)\d+?\./, '$11.');
+ },
+ [emphasisNode](node, subContent) {
+ const result = baseRenderer.convert(node, subContent);
+
+ return result.replace(/(^[*_]{1}|[*_]{1}$)/g, emphasis);
+ },
+ [strongNode](node, subContent) {
+ const result = baseRenderer.convert(node, subContent);
+ const strongSyntax = repeat(strong, 2);
+
+ return result.replace(/^[*_]{2}/, strongSyntax).replace(/[*_]{2}$/, strongSyntax);
+ },
};
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
index 6436dcaae64..51ba033dff0 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js
@@ -1,6 +1,9 @@
import Vue from 'vue';
+import { defaults } from 'lodash';
import ToolbarItem from '../toolbar_item.vue';
import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
+import buildCustomHTMLRenderer from './build_custom_renderer';
+import { TOOLBAR_ITEM_CONFIGS } from '../constants';
const buildWrapper = propsData => {
const instance = new Vue({
@@ -54,3 +57,10 @@ export const registerHTMLToMarkdownRenderer = editorApi => {
renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)),
});
};
+
+export const getEditorOptions = externalOptions => {
+ return defaults({
+ customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers),
+ toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)),
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
index d96cadafdbb..1dcecd5fb8c 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js
@@ -34,7 +34,7 @@ export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) =>
export const buildTextToken = content => buildToken('text', null, { content });
-export const buildUneditableTokens = token => {
+export const buildUneditableBlockTokens = token => {
return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()];
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js
index 494057fc75b..0e122f598e5 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js
@@ -1,4 +1,4 @@
-import { buildUneditableTokens } from './build_uneditable_token';
+import { renderUneditableLeaf as render } from './render_utils';
const embeddedRubyRegex = /(^<%.+%>$)/;
@@ -6,8 +6,4 @@ const canRender = ({ literal }) => {
return embeddedRubyRegex.test(literal);
};
-const render = (_, { origin }) => {
- return buildUneditableTokens(origin());
-};
-
export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
index f5b4502ea3c..4ec45ecd3a7 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js
@@ -1,4 +1,4 @@
-import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token';
+import { renderUneditableBranch as render } from './render_utils';
const identifierRegex = /(^\[.+\]: .+)/;
@@ -10,7 +10,4 @@ const canRender = (node, context) => {
return isIdentifier(context.getChildrenText(node));
};
-const render = (_, { entering, origin }) =>
- entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
-
export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js
index 491a26c81d0..949ca0e5c2a 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js
@@ -1,4 +1,4 @@
-import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token';
+import { renderUneditableBranch as render } from './render_utils';
const isKramdownTOC = ({ type, literal }) => type === 'text' && literal === 'TOC';
@@ -21,7 +21,4 @@ const canRender = node => {
return false;
};
-const render = (_, { entering, origin }) =>
- entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
-
export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js
index 01384699e4f..0551894918c 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js
@@ -1,4 +1,4 @@
-import { buildUneditableTokens } from './build_uneditable_token';
+import { renderUneditableLeaf as render } from './render_utils';
const kramdownRegex = /(^{:.+}$)/;
@@ -6,8 +6,4 @@ const canRender = ({ literal }) => {
return kramdownRegex.test(literal);
};
-const render = (_, { origin }) => {
- return buildUneditableTokens(origin());
-};
-
export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js
new file mode 100644
index 00000000000..389ade5f27a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js
@@ -0,0 +1,7 @@
+const canRender = node => ['emph', 'strong'].includes(node.parent?.type);
+const render = () => ({
+ type: 'text',
+ content: ' ',
+});
+
+export default { canRender, render };
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js
new file mode 100644
index 00000000000..cec6491557b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js
@@ -0,0 +1,10 @@
+import {
+ buildUneditableBlockTokens,
+ buildUneditableOpenTokens,
+ buildUneditableCloseToken,
+} from './build_uneditable_token';
+
+export const renderUneditableLeaf = (_, { origin }) => buildUneditableBlockTokens(origin());
+
+export const renderUneditableBranch = (_, { entering, origin }) =>
+ entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
index 1be5284fa9c..9b28ce0d881 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -1,6 +1,6 @@
<script>
-import { __, s__, sprintf } from '~/locale';
import { GlIcon } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
export default {
components: {
@@ -78,7 +78,7 @@ export default {
<span class="dropdown-toggle-text"> {{ dropdownToggleText }} </span>
<gl-icon
name="chevron-down"
- class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-700"
+ class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
:size="16"
/>
</button>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
index f0a846c4924..6222dfc5853 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
@@ -18,11 +18,11 @@ export default {
/>
<gl-icon
name="search"
- class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-500 gl-pointer-events-none"
+ class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-300 gl-pointer-events-none"
/>
<gl-icon
name="close"
- class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-700"
+ class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-500"
/>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
index 69fb2bb4524..91cf5d6bef5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
@@ -15,7 +15,7 @@ export default {
</script>
<template>
- <div class="title hide-collapsed append-bottom-10">
+ <div class="title hide-collapsed gl-mb-3">
{{ __('Labels') }}
<template v-if="canEdit">
<gl-loading-icon inline class="align-text-top block-loading" />
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
index cf77aa37d14..c65266fce5a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
@@ -19,6 +19,9 @@ export default {
handleButtonClick(e) {
if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
this.toggleDropdownContents();
+ }
+
+ if (this.isDropdownVariantStandalone) {
e.stopPropagation();
}
},
@@ -31,9 +34,9 @@ export default {
class="labels-select-dropdown-button js-dropdown-button w-100 text-left"
@click="handleButtonClick"
>
- <span class="dropdown-toggle-text flex-fill">
+ <span class="dropdown-toggle-text gl-pointer-events-none flex-fill">
{{ dropdownButtonText }}
</span>
- <gl-icon name="chevron-down" class="pull-right" />
+ <gl-icon name="chevron-down" class="gl-pointer-events-none float-right" />
</gl-button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
index ef8218b5135..6839354fb3a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
@@ -9,6 +9,13 @@ export default {
DropdownContentsLabelsView,
DropdownContentsCreateView,
},
+ props: {
+ renderOnTop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
computed: {
...mapState(['showDropdownContentsCreateView']),
dropdownContentsView() {
@@ -17,6 +24,13 @@ export default {
}
return 'dropdown-contents-labels-view';
},
+ directionStyle() {
+ if (this.renderOnTop) {
+ return { bottom: '100%' };
+ }
+
+ return {};
+ },
},
};
</script>
@@ -24,6 +38,7 @@ export default {
<template>
<div
class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute"
+ :style="directionStyle"
>
<component :is="dropdownContentsView" />
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
index 94671f8a109..55e2fb68275 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
@@ -105,13 +105,13 @@ export default {
:disabled="disableCreate"
category="primary"
variant="success"
- class="pull-left d-flex align-items-center"
+ class="float-left d-flex align-items-center"
@click="handleCreateClick"
>
<gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
{{ __('Create') }}
</gl-button>
- <gl-button class="pull-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
+ <gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index ef506d00d9a..0b763aa4b72 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -45,6 +45,16 @@ export default {
}
return this.labels;
},
+ showListContainer() {
+ if (this.isDropdownVariantSidebar) {
+ return !this.labelsFetchInProgress;
+ }
+
+ return true;
+ },
+ showNoMatchingResultsMessage() {
+ return !this.labelsFetchInProgress && !this.visibleLabels.length;
+ },
},
watch: {
searchKey(value) {
@@ -132,6 +142,7 @@ export default {
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-title"
>
<span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
@@ -146,7 +157,12 @@ export default {
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type v-model="searchKey" :autofocus="true" />
</div>
- <div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
+ <div
+ v-show="showListContainer"
+ ref="labelsListContainer"
+ class="dropdown-content"
+ data-testid="dropdown-content"
+ >
<smart-virtual-list
:length="visibleLabels.length"
:remain="$options.LIST_BUFFER_SIZE"
@@ -163,12 +179,16 @@ export default {
@clickLabel="handleLabelClick(label)"
/>
</li>
- <li v-show="!visibleLabels.length" class="p-2 text-center">
+ <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center">
{{ __('No matching results') }}
</li>
</smart-virtual-list>
</div>
- <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer">
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-footer"
+ data-testid="dropdown-footer"
+ >
<ul class="list-unstyled">
<li v-if="allowLabelCreate">
<gl-link
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
index 081c892e09f..2d6a4a9758c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
@@ -1,10 +1,10 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
GlLoadingIcon,
},
props: {
@@ -23,16 +23,16 @@ export default {
</script>
<template>
- <div class="title hide-collapsed append-bottom-10">
+ <div class="title hide-collapsed gl-mb-3">
{{ __('Labels') }}
<template v-if="allowLabelEdit">
<gl-loading-icon v-show="labelsSelectInProgress" inline />
- <gl-deprecated-button
+ <gl-button
variant="link"
- class="pull-right js-sidebar-dropdown-toggle"
+ class="float-right js-sidebar-dropdown-toggle"
data-qa-selector="labels_edit_button"
@click="toggleDropdownContents"
- >{{ __('Edit') }}</gl-deprecated-button
+ >{{ __('Edit') }}</gl-button
>
</template>
</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 258a87e62b9..248e9929833 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
@@ -2,6 +2,7 @@
import $ from 'jquery';
import Vue from 'vue';
import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
+import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
@@ -100,6 +101,11 @@ export default {
default: __('Manage group labels'),
},
},
+ data() {
+ return {
+ contentIsOnViewport: true,
+ };
+ },
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
...mapGetters([
@@ -117,6 +123,9 @@ export default {
selectedLabels,
});
},
+ showDropdownContents(showDropdownContents) {
+ this.setContentIsOnViewport(showDropdownContents);
+ },
},
mounted() {
this.setInitialState({
@@ -203,6 +212,20 @@ export default {
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
},
+ setContentIsOnViewport(showDropdownContents) {
+ if (!this.isDropdownVariantEmbedded || !showDropdownContents) {
+ this.contentIsOnViewport = true;
+
+ return;
+ }
+
+ this.$nextTick(() => {
+ if (this.$refs.dropdownContents) {
+ const offset = { top: 100 };
+ this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el, offset);
+ }
+ });
+ },
},
};
</script>
@@ -239,6 +262,7 @@ export default {
<dropdown-contents
v-if="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents"
+ :render-on-top="!contentIsOnViewport"
/>
</template>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
index e6053628eca..e624bd1eaee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js
@@ -1,4 +1,4 @@
-import flash from '~/flash';
+import { deprecatedCreateFlash as flash } from '~/flash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
@@ -56,6 +56,3 @@ export const createLabel = ({ state, dispatch }, label) => {
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
index e035a866048..5a30e29cad3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
@@ -50,6 +50,3 @@ export const isDropdownVariantStandalone = state => state.variant === DropdownVa
* @param {object} state
*/
export const isDropdownVariantEmbedded = state => state.variant === DropdownVariant.Embedded;
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue
index b11ec8b8838..e9b99c6ea78 100644
--- a/app/assets/javascripts/vue_shared/components/split_button.vue
+++ b/app/assets/javascripts/vue_shared/components/split_button.vue
@@ -1,15 +1,19 @@
<script>
import { isString } from 'lodash';
-import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui';
+import {
+ GlDeprecatedDropdown,
+ GlDeprecatedDropdownDivider,
+ GlDeprecatedDropdownItem,
+} from '@gitlab/ui';
const isValidItem = item =>
isString(item.eventName) && isString(item.title) && isString(item.description);
export default {
components: {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
+ GlDeprecatedDropdown,
+ GlDeprecatedDropdownDivider,
+ GlDeprecatedDropdownItem,
},
props: {
@@ -57,7 +61,7 @@ export default {
</script>
<template>
- <gl-dropdown
+ <gl-deprecated-dropdown
:menu-class="`dropdown-menu-selectable ${menuClass}`"
split
:text="dropdownToggleText"
@@ -66,7 +70,7 @@ export default {
@click="triggerEvent"
>
<template v-for="(item, itemIndex) in actionItems">
- <gl-dropdown-item
+ <gl-deprecated-dropdown-item
:key="item.eventName"
:active="selectedItem === item"
active-class="is-active"
@@ -74,12 +78,12 @@ export default {
>
<strong>{{ item.title }}</strong>
<div>{{ item.description }}</div>
- </gl-dropdown-item>
+ </gl-deprecated-dropdown-item>
- <gl-dropdown-divider
+ <gl-deprecated-dropdown-divider
v-if="itemIndex < actionItems.length - 1"
:key="`${item.eventName}-divider`"
/>
</template>
- </gl-dropdown>
+ </gl-deprecated-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index b1a4f3dccaf..4447a87777a 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
+
import timeagoMixin from '../mixins/timeago';
import '../../lib/utils/datetime_utility';
@@ -28,6 +29,11 @@ export default {
default: '',
},
},
+ computed: {
+ timeAgo() {
+ return this.timeFormatted(this.time);
+ },
+ },
};
</script>
<template>
@@ -35,7 +41,7 @@ export default {
v-gl-tooltip.viewport="{ placement: tooltipPlacement }"
:class="cssClass"
:title="tooltipTitle(time)"
- v-text="timeFormatted(time)"
+ :datetime="time"
+ ><slot :timeAgo="timeAgo">{{ timeAgo }}</slot></time
>
- </time>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
new file mode 100644
index 00000000000..148bd501a8e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
@@ -0,0 +1,102 @@
+<script>
+import { GlNewDropdown, GlDeprecatedDropdownItem, GlSearchBoxByType, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+
+export default {
+ name: 'TimezoneDropdown',
+ components: {
+ GlNewDropdown,
+ GlDeprecatedDropdownItem,
+ GlSearchBoxByType,
+ GlIcon,
+ },
+ directives: {
+ autofocusonshow,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ default: '',
+ },
+ timezoneData: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ };
+ },
+ tranlations: {
+ noResultsText: __('No matching results'),
+ },
+ computed: {
+ timezones() {
+ return this.timezoneData.map(timezone => ({
+ formattedTimezone: this.formatTimezone(timezone),
+ identifier: timezone.identifier,
+ }));
+ },
+ filteredResults() {
+ const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.timezones.filter(timezone =>
+ timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm),
+ );
+ },
+ selectedTimezoneLabel() {
+ return this.value || __('Select timezone');
+ },
+ },
+ methods: {
+ selectTimezone(selectedTimezone) {
+ this.$emit('input', selectedTimezone);
+ this.searchTerm = '';
+ },
+ isSelected(timezone) {
+ return this.value === timezone.formattedTimezone;
+ },
+ formatUtcOffset(offset) {
+ const parsed = parseInt(offset, 10);
+ if (Number.isNaN(parsed) || parsed === 0) {
+ return `0`;
+ }
+ const prefix = offset > 0 ? '+' : '-';
+ return `${prefix}${Math.abs(offset / 3600)}`;
+ },
+ formatTimezone(item) {
+ return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
+ },
+ },
+};
+</script>
+<template>
+ <gl-new-dropdown :text="value" block lazy menu-class="gl-w-full!">
+ <template #button-content>
+ <span class="gl-flex-grow-1" :class="{ 'gl-text-gray-300': !value }">
+ {{ selectedTimezoneLabel }}
+ </span>
+ <gl-icon name="chevron-down" />
+ </template>
+
+ <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus class="gl-m-3" />
+ <gl-deprecated-dropdown-item
+ v-for="timezone in filteredResults"
+ :key="timezone.formattedTimezone"
+ @click="selectTimezone(timezone)"
+ >
+ <gl-icon
+ :class="{ invisible: !isSelected(timezone) }"
+ name="mobile-issue-close"
+ class="gl-vertical-align-middle"
+ />
+ {{ timezone.formattedTimezone }}
+ </gl-deprecated-dropdown-item>
+ <gl-deprecated-dropdown-item v-if="!filteredResults.length" data-testid="noMatchingResults">
+ {{ $options.tranlations.noResultsText }}
+ </gl-deprecated-dropdown-item>
+ </gl-new-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue
index 1de866bed37..540edc9f61c 100644
--- a/app/assets/javascripts/vue_shared/components/toggle_button.vue
+++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue
@@ -1,7 +1,6 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { s__ } from '../../locale';
-import icon from './icon.vue';
const ICON_ON = 'status_success_borderless';
const ICON_OFF = 'status_failed_borderless';
@@ -10,7 +9,7 @@ const LABEL_OFF = s__('ToggleButton|Toggle Status: OFF');
export default {
components: {
- icon,
+ GlIcon,
GlLoadingIcon,
},
@@ -63,18 +62,27 @@ export default {
<label class="toggle-wrapper">
<input v-if="name" :name="name" :value="value" type="hidden" />
<button
+ type="button"
+ role="switch"
+ class="project-feature-toggle"
:aria-label="ariaLabel"
+ :aria-checked="value"
:class="{
'is-checked': value,
+ 'gl-blue-500': value,
'is-disabled': disabledInput,
'is-loading': isLoading,
}"
- type="button"
- class="project-feature-toggle"
@click="toggleFeature"
>
<gl-loading-icon class="loading-icon" />
- <span class="toggle-icon"> <icon :name="toggleIcon" class="toggle-icon-svg" /> </span>
+ <span class="toggle-icon">
+ <gl-icon
+ :size="18"
+ :name="toggleIcon"
+ :class="value ? 'gl-text-blue-500' : 'gl-text-gray-400'"
+ />
+ </span>
</button>
</label>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
index db378d6f977..e19d659c179 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -1,12 +1,12 @@
<script>
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
import UserAvatarLink from './user_avatar_link.vue';
export default {
components: {
UserAvatarLink,
- GlDeprecatedButton,
+ GlButton,
},
props: {
items: {
@@ -82,12 +82,12 @@ export default {
:img-size="imgSize"
/>
<template v-if="hasBreakpoint">
- <gl-deprecated-button v-if="hasHiddenItems" variant="link" @click="expand">
+ <gl-button v-if="hasHiddenItems" variant="link" @click="expand">
{{ expandText }}
- </gl-deprecated-button>
- <gl-deprecated-button v-else variant="link" @click="collapse">
+ </gl-button>
+ <gl-button v-else variant="link" @click="collapse">
{{ __('show less') }}
- </gl-deprecated-button>
+ </gl-button>
</template>
</div>
</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 bd35d3fead9..699e466e848 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
@@ -70,20 +70,20 @@ export default {
<h5 class="gl-m-0">
{{ user.name }}
</h5>
- <span class="gl-text-gray-700">@{{ user.username }}</span>
+ <span class="gl-text-gray-500">@{{ user.username }}</span>
</div>
- <div class="gl-text-gray-700">
+ <div class="gl-text-gray-500">
<div v-if="user.bio" class="gl-display-flex gl-mb-2">
- <icon name="profile" class="gl-text-gray-600 gl-flex-shrink-0" />
- <span ref="bio" class="ml-1" v-html="user.bioHtml"></span>
+ <icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" />
+ <span ref="bio" class="gl-ml-2" v-html="user.bioHtml"></span>
</div>
<div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
- <icon name="work" class="gl-text-gray-600 gl-flex-shrink-0" />
+ <icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" />
<span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
</div>
</div>
- <div v-if="user.location" class="js-location gl-text-gray-700 gl-display-flex">
- <icon name="location" class="gl-text-gray-600 flex-shrink-0" />
+ <div v-if="user.location" class="js-location gl-text-gray-500 gl-display-flex">
+ <icon name="location" class="gl-text-gray-400 flex-shrink-0" />
<span class="gl-ml-2">{{ user.location }}</span>
</div>
<div v-if="statusHtml" class="js-user-status gl-mt-3">