summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 11:18:50 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 11:18:50 +0000
commit8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch)
treea77e7fe7a93de11213032ed4ab1f33a3db51b738 /app/assets/javascripts/vue_shared
parent00b35af3db1abfe813a778f643dad221aad51fca (diff)
downloadgitlab-ce-8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781.tar.gz
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'app/assets/javascripts/vue_shared')
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.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/date_time_picker/date_time_picker.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js65
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue107
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue253
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue114
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue84
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue119
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue72
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js42
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/url_sync.vue25
-rw-r--r--app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js14
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js4
34 files changed, 1175 insertions, 252 deletions
diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
index 60e41a16854..7431b7e9ed4 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -1,7 +1,7 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
-import { getCommitIconMap } from '~/ide/utils';
+import getCommitIconMap from '~/ide/commit_icon';
import { __ } from '~/locale';
export default {
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 e80cb06edfb..47231c4ad39 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
@@ -52,7 +52,7 @@ export default {
:download="fileName"
target="_blank"
>
- <icon :size="16" name="download" class="float-left append-right-8" />
+ <icon :size="16" name="download" class="float-left gl-mr-3" />
{{ __('Download') }}
</gl-link>
</div>
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 07748482204..ddbb474bab6 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,20 +1,17 @@
<script>
-import { GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
+import { GlIcon, GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
-import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
defaultTimeRanges,
defaultTimeRange,
- isValidDate,
- stringToISODate,
- ISODateToString,
- truncateZerosInDateTime,
- isDateTimePickerInputValid,
+ isValidInputString,
+ inputStringToIsoDate,
+ isoDateToInputString,
} from './date_time_picker_lib';
const events = {
@@ -24,13 +21,13 @@ const events = {
export default {
components: {
- Icon,
- TooltipOnTruncate,
- DateTimePickerInput,
- GlFormGroup,
+ GlIcon,
GlDeprecatedButton,
GlDropdown,
GlDropdownItem,
+ GlFormGroup,
+ TooltipOnTruncate,
+ DateTimePickerInput,
},
props: {
value: {
@@ -48,20 +45,41 @@ export default {
required: false,
default: true,
},
+ utc: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
timeRange: this.value,
- startDate: '',
- endDate: '',
+
+ /**
+ * Valid start iso date string, null if not valid value
+ */
+ startDate: null,
+ /**
+ * Invalid start date string as input by the user
+ */
+ startFallbackVal: '',
+
+ /**
+ * Valid end iso date string, null if not valid value
+ */
+ endDate: null,
+ /**
+ * Invalid end date string as input by the user
+ */
+ endFallbackVal: '',
};
},
computed: {
startInputValid() {
- return isValidDate(this.startDate);
+ return isValidInputString(this.startDate);
},
endInputValid() {
- return isValidDate(this.endDate);
+ return isValidInputString(this.endDate);
},
isValid() {
return this.startInputValid && this.endInputValid;
@@ -69,21 +87,31 @@ export default {
startInput: {
get() {
- return this.startInputValid ? this.formatDate(this.startDate) : this.startDate;
+ return this.dateToInput(this.startDate) || this.startFallbackVal;
},
set(val) {
- // Attempt to set a formatted date if possible
- this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
+ try {
+ this.startDate = this.inputToDate(val);
+ this.startFallbackVal = null;
+ } catch (e) {
+ this.startDate = null;
+ this.startFallbackVal = val;
+ }
this.timeRange = null;
},
},
endInput: {
get() {
- return this.endInputValid ? this.formatDate(this.endDate) : this.endDate;
+ return this.dateToInput(this.endDate) || this.endFallbackVal;
},
set(val) {
- // Attempt to set a formatted date if possible
- this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
+ try {
+ this.endDate = this.inputToDate(val);
+ this.endFallbackVal = null;
+ } catch (e) {
+ this.endDate = null;
+ this.endFallbackVal = val;
+ }
this.timeRange = null;
},
},
@@ -96,10 +124,10 @@ export default {
}
const { start, end } = convertToFixedRange(this.value);
- if (isValidDate(start) && isValidDate(end)) {
+ if (isValidInputString(start) && isValidInputString(end)) {
return sprintf(__('%{start} to %{end}'), {
- start: this.formatDate(start),
- end: this.formatDate(end),
+ start: this.stripZerosInDateTime(this.dateToInput(start)),
+ end: this.stripZerosInDateTime(this.dateToInput(end)),
});
}
} catch {
@@ -107,6 +135,13 @@ export default {
}
return '';
},
+
+ customLabel() {
+ if (this.utc) {
+ return __('Custom range (UTC)');
+ }
+ return __('Custom range');
+ },
},
watch: {
value(newValue) {
@@ -132,8 +167,17 @@ export default {
}
},
methods: {
- formatDate(date) {
- return truncateZerosInDateTime(ISODateToString(date));
+ dateToInput(date) {
+ if (date === null) {
+ return null;
+ }
+ return isoDateToInputString(date, this.utc);
+ },
+ inputToDate(value) {
+ return inputStringToIsoDate(value, this.utc);
+ },
+ stripZerosInDateTime(str = '') {
+ return str.replace(' 00:00:00', '');
},
closeDropdown() {
this.$refs.dropdown.hide();
@@ -169,10 +213,16 @@ export default {
menu-class="date-time-picker-menu"
toggle-class="date-time-picker-toggle text-truncate"
>
+ <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>
+ <gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" />
+ </template>
+
<div class="d-flex justify-content-between gl-p-2-deprecated-no-really-do-not-use-me">
<gl-form-group
v-if="customEnabled"
- :label="__('Custom range')"
+ :label="customLabel"
label-for="custom-from-time"
label-class="gl-pb-1-deprecated-no-really-do-not-use-me"
class="custom-time-range-form-group col-md-7 gl-pl-1-deprecated-no-really-do-not-use-me gl-pr-0 m-0"
@@ -214,7 +264,7 @@ export default {
active-class="active"
@click="setQuickRange(option)"
>
- <icon
+ <gl-icon
name="mobile-issue-close"
class="align-bottom"
:class="{ invisible: !isOptionActive(option) }"
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue
index f19f8bd46b3..32a24844d71 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue
@@ -6,9 +6,9 @@ import { dateFormats } from './date_time_picker_lib';
const inputGroupText = {
invalidFeedback: sprintf(__('Format: %{dateFormat}'), {
- dateFormat: dateFormats.stringDate,
+ dateFormat: dateFormats.inputFormat,
}),
- placeholder: dateFormats.stringDate,
+ placeholder: dateFormats.inputFormat,
};
export default {
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
index 673d981cf07..40708453d79 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
@@ -2,12 +2,6 @@ import dateformat from 'dateformat';
import { __ } from '~/locale';
/**
- * Valid strings for this regex are
- * 2019-10-01 and 2019-10-01 01:02:03
- */
-const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/;
-
-/**
* Default time ranges for the date picker.
* @see app/assets/javascripts/lib/utils/datetime_range.js
*/
@@ -34,23 +28,33 @@ export const defaultTimeRanges = [
export const defaultTimeRange = defaultTimeRanges.find(tr => tr.default);
export const dateFormats = {
- ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
- stringDate: 'yyyy-mm-dd HH:MM:ss',
+ /**
+ * Format used by users to input dates
+ *
+ * Note: Should be a format that can be parsed by Date.parse.
+ */
+ inputFormat: 'yyyy-mm-dd HH:MM:ss',
+ /**
+ * Format used to strip timezone from inputs
+ */
+ stripTimezoneFormat: "yyyy-mm-dd'T'HH:MM:ss'Z'",
};
/**
- * The URL params start and end need to be validated
- * before passing them down to other components.
+ * Returns true if the date can be parsed succesfully after
+ * being typed by a user.
*
- * @param {string} dateString
- * @returns true if the string is a valid date, false otherwise
+ * It allows some ambiguity so validation is not strict.
+ *
+ * @param {string} value - Value as typed by the user
+ * @returns true if the value can be parsed as a valid date, false otherwise
*/
-export const isValidDate = dateString => {
+export const isValidInputString = value => {
try {
// dateformat throws error that can be caught.
// This is better than using `new Date()`
- if (dateString && dateString.trim()) {
- dateformat(dateString, 'isoDateTime');
+ if (value && value.trim()) {
+ dateformat(value, 'isoDateTime');
return true;
}
return false;
@@ -60,25 +64,30 @@ export const isValidDate = dateString => {
};
/**
- * Convert the input in Time picker component to ISO date.
+ * Convert the input in time picker component to an ISO date.
*
- * @param {string} val
- * @returns {string}
+ * @param {string} value
+ * @param {Boolean} utc - If true, it forces the date to by
+ * formatted using UTC format, ignoring the local time.
+ * @returns {Date}
*/
-export const stringToISODate = val =>
- dateformat(new Date(val.replace(/-/g, '/')), dateFormats.ISODate, true);
+export const inputStringToIsoDate = (value, utc = false) => {
+ let date = new Date(value);
+ if (utc) {
+ // Forces date to be interpreted as UTC by stripping the timezone
+ // by formatting to a string with 'Z' and skipping timezone
+ date = dateformat(date, dateFormats.stripTimezoneFormat);
+ }
+ return dateformat(date, 'isoUtcDateTime');
+};
/**
- * Convert the ISO date received from the URL to string
- * for the Time picker component.
+ * Converts a iso date string to a formatted string for the Time picker component.
*
- * @param {Date} date
+ * @param {String} ISO Formatted date
* @returns {string}
*/
-export const ISODateToString = date => dateformat(date, dateFormats.stringDate);
-
-export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', '');
-
-export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val);
+export const isoDateToInputString = (date, utc = false) =>
+ dateformat(date, dateFormats.inputFormat, utc);
export default {};
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
index bf3c3666300..a2fe19f9672 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
@@ -7,6 +7,10 @@ import ModeChanged from './viewers/mode_changed.vue';
export default {
props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
diffMode: {
type: String,
required: true,
@@ -92,6 +96,7 @@ export default {
<div v-if="viewer" class="diff-file preview-container">
<component
:is="viewer"
+ :diff-file="diffFile"
:diff-mode="diffMode"
:new-path="fullNewPath"
:old-path="fullOldPath"
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
index 5c1ea59b471..eba6dd4d14c 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue
@@ -1,3 +1,108 @@
+<script>
+import { mapActions } from 'vuex';
+import { GlAlert, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import {
+ TRANSITION_LOAD_START,
+ TRANSITION_LOAD_ERROR,
+ TRANSITION_LOAD_SUCCEED,
+ TRANSITION_ACKNOWLEDGE_ERROR,
+ STATE_IDLING,
+ STATE_LOADING,
+ STATE_ERRORED,
+ RENAMED_DIFF_TRANSITIONS,
+} from '~/diffs/constants';
+import { truncateSha } from '~/lib/utils/text_utility';
+
+export default {
+ STATE_LOADING,
+ STATE_ERRORED,
+ TRANSITIONS: RENAMED_DIFF_TRANSITIONS,
+ uiText: {
+ showLink: __('Show file contents'),
+ commitLink: __('View file @ %{commitSha}'),
+ description: __('File renamed with no changes.'),
+ loadError: __('Unable to load file contents. Try again later.'),
+ },
+ components: {
+ GlAlert,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ },
+ props: {
+ diffFile: {
+ type: Object,
+ required: true,
+ },
+ },
+ data: () => ({
+ state: STATE_IDLING,
+ }),
+ computed: {
+ shortSha() {
+ return truncateSha(this.diffFile.content_sha);
+ },
+ canLoadFullDiff() {
+ return this.diffFile.alternate_viewer.name === 'text';
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['switchToFullDiffFromRenamedFile']),
+ transition(transitionEvent) {
+ const key = `${this.state}:${transitionEvent}`;
+
+ if (this.$options.TRANSITIONS[key]) {
+ this.state = this.$options.TRANSITIONS[key];
+ }
+ },
+ is(state) {
+ return this.state === state;
+ },
+ switchToFull() {
+ this.transition(TRANSITION_LOAD_START);
+
+ this.switchToFullDiffFromRenamedFile({ diffFile: this.diffFile })
+ .then(() => {
+ this.transition(TRANSITION_LOAD_SUCCEED);
+ })
+ .catch(() => {
+ this.transition(TRANSITION_LOAD_ERROR);
+ });
+ },
+ clickLink(event) {
+ if (this.canLoadFullDiff) {
+ event.preventDefault();
+
+ this.switchToFull();
+ }
+ },
+ dismissError() {
+ this.transition(TRANSITION_ACKNOWLEDGE_ERROR);
+ },
+ },
+};
+</script>
+
<template>
- <div class="nothing-here-block">{{ __('File moved') }}</div>
+ <div class="nothing-here-block">
+ <gl-loading-icon v-if="is($options.STATE_LOADING)" />
+ <template v-else>
+ <gl-alert
+ v-show="is($options.STATE_ERRORED)"
+ class="gl-mb-5 gl-text-left"
+ variant="danger"
+ @dismiss="dismissError"
+ >{{ $options.uiText.loadError }}</gl-alert
+ >
+ <span test-id="plaintext">{{ $options.uiText.description }}</span>
+ <gl-link :href="diffFile.view_path" @click="clickLink">
+ <span v-if="canLoadFullDiff">{{ $options.uiText.showLink }}</span>
+ <gl-sprintf v-else :message="$options.uiText.commitLink">
+ <template #commitSha>{{ shortSha }}</template>
+ </gl-sprintf>
+ </gl-link>
+ </template>
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index b57455adaad..9f6f3d2d63a 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -261,7 +261,7 @@ export default {
</li>
</template>
<li v-else class="dropdown-menu-empty-item">
- <div class="append-right-default prepend-left-default prepend-top-8 append-bottom-8">
+ <div class="append-right-default prepend-left-default gl-mt-3 gl-mb-3">
<template v-if="loading">
{{ __('Loading...') }}
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
index 018e3a84c39..590501a975a 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -75,12 +75,8 @@ export default {
@mouseover="mouseOverRow"
@mousemove="mouseMove"
>
- <file-icon
- :file-name="file.name"
- :size="16"
- css-classes="diff-file-changed-icon append-right-8"
- />
- <span class="diff-changed-file-content append-right-8">
+ <file-icon :file-name="file.name" :size="16" css-classes="diff-file-changed-icon gl-mr-3" />
+ <span class="diff-changed-file-content gl-mr-3">
<strong class="diff-changed-file-name">
<span
v-for="(char, charIndex) in file.name.split('')"
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
new file mode 100644
index 00000000000..6665a5754b3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -0,0 +1,8 @@
+export const ANY_AUTHOR = 'Any';
+
+export const DEBOUNCE_DELAY = 200;
+
+export const SortDirection = {
+ descending: 'descending',
+ ascending: 'ascending',
+};
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
new file mode 100644
index 00000000000..a858ffdbed5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -0,0 +1,253 @@
+<script>
+import {
+ GlFilteredSearch,
+ GlButtonGroup,
+ GlButton,
+ GlNewDropdown as GlDropdown,
+ GlNewDropdownItem as GlDropdownItem,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import 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 { SortDirection } from './constants';
+
+export default {
+ components: {
+ GlFilteredSearch,
+ GlButtonGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ namespace: {
+ type: String,
+ required: true,
+ },
+ recentSearchesStorageKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tokens: {
+ type: Array,
+ required: true,
+ },
+ sortOptions: {
+ type: Array,
+ required: true,
+ },
+ initialFilterValue: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ initialSortBy: {
+ type: String,
+ required: false,
+ default: '',
+ validator: value => value === '' || /(_desc)|(_asc)/g.test(value),
+ },
+ searchInputPlaceholder: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ let selectedSortOption = this.sortOptions[0].sortDirection.descending;
+ let selectedSortDirection = SortDirection.descending;
+
+ // Extract correct sortBy value based on initialSortBy
+ if (this.initialSortBy) {
+ selectedSortOption = this.sortOptions
+ .filter(
+ sortBy =>
+ sortBy.sortDirection.ascending === this.initialSortBy ||
+ sortBy.sortDirection.descending === this.initialSortBy,
+ )
+ .pop();
+ selectedSortDirection = this.initialSortBy.endsWith('_desc')
+ ? SortDirection.descending
+ : SortDirection.ascending;
+ }
+
+ return {
+ initialRender: true,
+ recentSearchesPromise: null,
+ filterValue: this.initialFilterValue,
+ selectedSortOption,
+ selectedSortDirection,
+ };
+ },
+ computed: {
+ tokenSymbols() {
+ return this.tokens.reduce(
+ (tokenSymbols, token) => ({
+ ...tokenSymbols,
+ [token.type]: token.symbol,
+ }),
+ {},
+ );
+ },
+ sortDirectionIcon() {
+ return this.selectedSortDirection === SortDirection.ascending
+ ? 'sort-lowest'
+ : 'sort-highest';
+ },
+ sortDirectionTooltip() {
+ return this.selectedSortDirection === SortDirection.ascending
+ ? __('Sort direction: Ascending')
+ : __('Sort direction: Descending');
+ },
+ },
+ watch: {
+ /**
+ * GlFilteredSearch currently doesn't emit any event when
+ * search field is cleared, but we still want our parent
+ * component to know that filters were cleared and do
+ * necessary data refetch, so this watcher is basically
+ * a dirty hack/workaround to identify if filter input
+ * was cleared. :(
+ */
+ filterValue(value) {
+ const [firstVal] = value;
+ if (
+ !this.initialRender &&
+ value.length === 1 &&
+ firstVal.type === 'filtered-search-term' &&
+ !firstVal.value.data
+ ) {
+ this.$emit('onFilter', []);
+ }
+
+ // Set initial render flag to false
+ // as we don't want to emit event
+ // on initial load when value is empty already.
+ this.initialRender = false;
+ },
+ },
+ created() {
+ if (this.recentSearchesStorageKey) this.setupRecentSearch();
+ },
+ methods: {
+ /**
+ * Initialize service and store instances for
+ * getting Recent Search functional.
+ */
+ setupRecentSearch() {
+ this.recentSearchesService = new RecentSearchesService(
+ `${this.namespace}-${RecentSearchesStorageKeys[this.recentSearchesStorageKey]}`,
+ );
+
+ this.recentSearchesStore = new RecentSearchesStore({
+ isLocalStorageAvailable: RecentSearchesService.isAvailable(),
+ allowedKeys: this.tokens.map(token => token.type),
+ });
+
+ this.recentSearchesPromise = this.recentSearchesService
+ .fetch()
+ .catch(error => {
+ if (error.name === 'RecentSearchesServiceError') return undefined;
+
+ createFlash(__('An error occurred while parsing recent searches'));
+
+ // Gracefully fail to empty array
+ return [];
+ })
+ .then(searches => {
+ if (!searches) return;
+
+ // Put any searches that may have come in before
+ // we fetched the saved searches ahead of the already saved ones
+ const resultantSearches = this.recentSearchesStore.setRecentSearches(
+ this.recentSearchesStore.state.recentSearches.concat(searches),
+ );
+ this.recentSearchesService.save(resultantSearches);
+ });
+ },
+ getRecentSearches() {
+ return this.recentSearchesStore?.state.recentSearches;
+ },
+ handleSortOptionClick(sortBy) {
+ this.selectedSortOption = sortBy;
+ this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]);
+ },
+ handleSortDirectionClick() {
+ this.selectedSortDirection =
+ this.selectedSortDirection === SortDirection.ascending
+ ? SortDirection.descending
+ : SortDirection.ascending;
+ this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]);
+ },
+ handleFilterSubmit(filters) {
+ if (this.recentSearchesStorageKey) {
+ this.recentSearchesPromise
+ .then(() => {
+ if (filters.length) {
+ const searchTokens = filters.map(filter => {
+ // check filter was plain text search
+ if (typeof filter === 'string') {
+ return filter;
+ }
+ // filter was a token.
+ return `${filter.type}:${filter.value.operator}${this.tokenSymbols[filter.type]}${
+ filter.value.data
+ }`;
+ });
+
+ const resultantSearches = this.recentSearchesStore.addRecentSearch(
+ searchTokens.join(' '),
+ );
+ this.recentSearchesService.save(resultantSearches);
+ }
+ })
+ .catch(() => {
+ // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
+ });
+ }
+ this.$emit('onFilter', filters);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="vue-filtered-search-bar-container d-md-flex">
+ <gl-filtered-search
+ v-model="filterValue"
+ :placeholder="searchInputPlaceholder"
+ :available-tokens="tokens"
+ :history-items="getRecentSearches()"
+ class="flex-grow-1"
+ @submit="handleFilterSubmit"
+ />
+ <gl-button-group class="sort-dropdown-container d-flex">
+ <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100">
+ <gl-dropdown-item
+ v-for="sortBy in sortOptions"
+ :key="sortBy.id"
+ :is-check-item="true"
+ :is-checked="sortBy.id === selectedSortOption.id"
+ @click="handleSortOptionClick(sortBy)"
+ >{{ sortBy.title }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ <gl-button
+ v-gl-tooltip
+ :title="sortDirectionTooltip"
+ :icon="sortDirectionIcon"
+ class="flex-shrink-1"
+ @click="handleSortDirectionClick"
+ />
+ </gl-button-group>
+ </div>
+</template>
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
new file mode 100644
index 00000000000..412bfa5aa7f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -0,0 +1,114 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants';
+
+export default {
+ anyAuthor: ANY_AUTHOR,
+ components: {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ authors: this.config.initialAuthors || [],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeAuthor() {
+ return this.authors.find(author => author.username.toLowerCase() === this.currentValue);
+ },
+ },
+ methods: {
+ fetchAuthorBySearchTerm(searchTerm) {
+ const fetchPromise = this.config.fetchPath
+ ? this.config.fetchAuthors(this.config.fetchPath, searchTerm)
+ : this.config.fetchAuthors(searchTerm);
+
+ fetchPromise
+ .then(res => {
+ // We'd want to avoid doing this check but
+ // users.json and /groups/:id/members & /projects/:id/users
+ // return response differently.
+ this.authors = Array.isArray(res) ? res : res.data;
+ })
+ .catch(() => createFlash(__('There was a problem fetching users.')))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchAuthors: debounce(function debouncedSearch({ data }) {
+ this.fetchAuthorBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchAuthors"
+ >
+ <template #view="{ inputValue }">
+ <gl-avatar
+ v-if="activeAuthor"
+ :size="16"
+ :src="activeAuthor.avatar_url"
+ shape="circle"
+ class="gl-mr-2"
+ />
+ <span>{{ activeAuthor ? activeAuthor.name : inputValue }}</span>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion :value="$options.anyAuthor">{{
+ __('Any')
+ }}</gl-filtered-search-suggestion>
+ <gl-dropdown-divider />
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="author in authors"
+ :key="author.username"
+ :value="author.username"
+ >
+ <div class="d-flex">
+ <gl-avatar :size="32" :src="author.avatar_url" />
+ <div>
+ <div>{{ author.name }}</div>
+ <div>@{{ author.username }}</div>
+ </div>
+ </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 508f43afe61..a7fba5e760b 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -1,6 +1,5 @@
<script>
-import escape from 'lodash/escape';
-import sanitize from 'sanitize-html';
+import { escape } from 'lodash';
import Tribute from 'tributejs';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
@@ -11,11 +10,11 @@ import { spriteIcon } from '~/lib/utils/common_utils';
* @param original An object from the array returned from the `autocomplete_sources/members` API
* @returns {string} An HTML template
*/
-function createMenuItemTemplate({ original }) {
+function menuItemTemplate({ original }) {
const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
- align-items-center d-inline-flex justify-content-center`;
+ gl-display-inline-flex gl-align-items-center gl-justify-content-center`;
const avatarTag = original.avatar_url
? `<img
@@ -24,42 +23,20 @@ function createMenuItemTemplate({ original }) {
class="${avatarClasses}"/>`
: `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
- const name = escape(sanitize(original.name));
+ const name = escape(original.name);
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
const icon = original.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 vertical-align-middle prepend-left-5')
+ ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
: '';
return `${avatarTag}
${original.username}
- <small class="small font-weight-normal gl-reset-color">${name}${count}</small>
+ <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
${icon}`;
}
-/**
- * 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
- */
-function getMembers(inputText, processValues) {
- if (this.members) {
- processValues(this.members);
- } else if (this.dataSources.members) {
- axios
- .get(this.dataSources.members)
- .then(response => {
- this.members = response.data;
- processValues(response.data);
- })
- .catch(() => {});
- } else {
- processValues([]);
- }
-}
-
export default {
name: 'GlMentions',
props: {
@@ -72,30 +49,49 @@ export default {
data() {
return {
members: undefined,
- options: {
- trigger: '@',
- fillAttr: 'username',
- lookup(value) {
- return value.name + value.username;
- },
- menuItemTemplate: createMenuItemTemplate.bind(this),
- values: getMembers.bind(this),
- },
};
},
mounted() {
+ this.tribute = new Tribute({
+ trigger: '@',
+ fillAttr: 'username',
+ lookup: value => value.name + value.username,
+ menuItemTemplate,
+ values: this.getMembers,
+ });
+
const input = this.$slots.default[0].elm;
- this.tribute = new Tribute(this.options);
this.tribute.attach(input);
},
beforeDestroy() {
const input = this.$slots.default[0].elm;
- if (this.tribute) {
- this.tribute.detach(input);
- }
+ 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.members);
+ } else if (this.dataSources.members) {
+ axios
+ .get(this.dataSources.members)
+ .then(response => {
+ this.members = response.data;
+ processValues(response.data);
+ })
+ .catch(() => {});
+ } else {
+ processValues([]);
+ }
+ },
},
- render(h) {
- return h('div', this.$slots.default);
+ render(createElement) {
+ return createElement('div', this.$slots.default);
},
};
</script>
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 4f1b1c758b2..63de1e009fd 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
@@ -85,7 +85,7 @@ export default {
class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
/>
- <a :href="computedPath" class="sortable-link">{{ title }}</a>
+ <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a>
</div>
<!-- Info area: meta, path, and assignees -->
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 8007ccb91d5..0e05f4a4622 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -134,7 +134,7 @@ export default {
addMultipleToDiscussionWarning() {
return sprintf(
__(
- '%{icon}You are about to add %{usersTag} people to the discussion. Proceed with caution.',
+ '%{icon}You are about to add %{usersTag} people to the discussion. They will all receive a notification.',
),
{
icon: '<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>',
@@ -245,11 +245,11 @@ export default {
<div class="zen-backdrop">
<slot name="textarea"></slot>
<a
- class="zen-control zen-control-leave js-zen-leave"
+ class="zen-control zen-control-leave js-zen-leave gl-text-gray-700"
href="#"
- :aria-label="__('Enter zen mode')"
+ :aria-label="__('Leave zen mode')"
>
- <icon :size="32" name="screen-normal" />
+ <icon :size="16" name="screen-normal" />
</a>
<markdown-toolbar
:markdown-docs-path="markdownDocsPath"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 665637f3b9e..aa1abb5adb6 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -158,7 +158,7 @@ export default {
<div class="d-inline-block ml-md-2 ml-0">
<toolbar-button
:prepend="true"
- tag="* "
+ tag="- "
:button-title="__('Add a bullet list')"
icon="list-bulleted"
/>
@@ -170,7 +170,7 @@ export default {
/>
<toolbar-button
:prepend="true"
- tag="* [ ] "
+ tag="- [ ] "
:button-title="__('Add a task list')"
icon="list-task"
/>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index a7cd292e01d..6dac448d5de 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -13,6 +13,11 @@ export default {
type: Object,
required: true,
},
+ batchSuggestionsInfo: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
disabled: {
type: Boolean,
required: false,
@@ -24,6 +29,14 @@ export default {
},
},
computed: {
+ batchSuggestionsCount() {
+ return this.batchSuggestionsInfo.length;
+ },
+ isBatched() {
+ return Boolean(
+ this.batchSuggestionsInfo.find(({ suggestionId }) => suggestionId === this.suggestion.id),
+ );
+ },
lines() {
return selectDiffLines(this.suggestion.diff_lines);
},
@@ -32,6 +45,15 @@ export default {
applySuggestion(callback) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
},
+ applySuggestionBatch() {
+ this.$emit('applyBatch');
+ },
+ addSuggestionToBatch() {
+ this.$emit('addToBatch', this.suggestion.id);
+ },
+ removeSuggestionFromBatch() {
+ this.$emit('removeFromBatch', this.suggestion.id);
+ },
},
};
</script>
@@ -42,8 +64,14 @@ export default {
class="qa-suggestion-diff-header js-suggestion-diff-header"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
+ :is-batched="isBatched"
+ :is-applying-batch="suggestion.is_applying_batch"
+ :batch-suggestions-count="batchSuggestionsCount"
:help-page-path="helpPagePath"
@apply="applySuggestion"
+ @applyBatch="applySuggestionBatch"
+ @addToBatch="addSuggestionToBatch"
+ @removeFromBatch="removeSuggestionFromBatch"
/>
<table class="mb-3 md-suggestion-diff js-syntax-highlight code">
<tbody>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index af438ce5619..e26ff51e01e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -1,11 +1,19 @@
<script>
import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
+import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: { Icon, GlDeprecatedButton, GlLoadingIcon },
directives: { 'gl-tooltip': GlTooltipDirective },
+ mixins: [glFeatureFlagsMixin()],
props: {
+ batchSuggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
canApply: {
type: Boolean,
required: false,
@@ -16,6 +24,16 @@ export default {
required: true,
default: false,
},
+ isBatched: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isApplyingBatch: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
helpPagePath: {
type: String,
required: true,
@@ -23,17 +41,54 @@ export default {
},
data() {
return {
- isApplying: false,
+ isApplyingSingle: false,
};
},
+ computed: {
+ canBeBatched() {
+ return Boolean(this.glFeatures.batchSuggestions);
+ },
+ isApplying() {
+ return this.isApplyingSingle || this.isApplyingBatch;
+ },
+ tooltipMessage() {
+ return this.canApply
+ ? __('This also resolves the discussion')
+ : __("Can't apply as this line has changed or the suggestion already matches its content.");
+ },
+ tooltipMessageBatch() {
+ return !this.canBeBatched
+ ? __("Suggestions that change line count can't be added to batches, yet.")
+ : this.tooltipMessage;
+ },
+ isDisableButton() {
+ return this.isApplying || !this.canApply;
+ },
+ applyingSuggestionsMessage() {
+ if (this.isApplyingSingle || this.batchSuggestionsCount < 2) {
+ return __('Applying suggestion...');
+ }
+ return __('Applying suggestions...');
+ },
+ },
methods: {
applySuggestion() {
if (!this.canApply) return;
- this.isApplying = true;
+ this.isApplyingSingle = true;
this.$emit('apply', this.applySuggestionCallback);
},
applySuggestionCallback() {
- this.isApplying = false;
+ this.isApplyingSingle = false;
+ },
+ applySuggestionBatch() {
+ if (!this.canApply) return;
+ this.$emit('applyBatch');
+ },
+ addSuggestionToBatch() {
+ this.$emit('addToBatch');
+ },
+ removeSuggestionFromBatch() {
+ this.$emit('removeFromBatch');
},
},
};
@@ -47,20 +102,52 @@ export default {
<icon name="question-o" css-classes="link-highlight" />
</a>
</div>
- <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
- <div v-if="isApplying" class="d-flex align-items-center text-secondary">
+ <div v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</div>
+ <div v-else-if="isApplying" class="d-flex align-items-center text-secondary">
<gl-loading-icon class="d-flex-center mr-2" />
- <span>{{ __('Applying suggestion') }}</span>
+ <span>{{ applyingSuggestionsMessage }}</span>
+ </div>
+ <div v-else-if="canApply && canBeBatched && isBatched" class="d-flex align-items-center">
+ <gl-deprecated-button
+ class="btn-inverted js-remove-from-batch-btn btn-grouped"
+ :disabled="isApplying"
+ @click="removeSuggestionFromBatch"
+ >
+ {{ __('Remove from batch') }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ v-gl-tooltip.viewport="__('This also resolves all related threads')"
+ class="btn-inverted js-apply-batch-btn btn-grouped"
+ :disabled="isApplying"
+ variant="success"
+ @click="applySuggestionBatch"
+ >
+ {{ __('Apply suggestions') }}
+ <span class="badge badge-pill badge-pill-success">
+ {{ batchSuggestionsCount }}
+ </span>
+ </gl-deprecated-button>
+ </div>
+ <div v-else class="d-flex align-items-center">
+ <span v-if="canBeBatched" v-gl-tooltip.viewport="tooltipMessageBatch" tabindex="0">
+ <gl-deprecated-button
+ class="btn-inverted js-add-to-batch-btn btn-grouped"
+ :disabled="isDisableButton"
+ @click="addSuggestionToBatch"
+ >
+ {{ __('Add suggestion to batch') }}
+ </gl-deprecated-button>
+ </span>
+ <span v-gl-tooltip.viewport="tooltipMessage" tabindex="0">
+ <gl-deprecated-button
+ class="btn-inverted js-apply-btn btn-grouped"
+ :disabled="isDisableButton"
+ variant="success"
+ @click="applySuggestion"
+ >
+ {{ __('Apply suggestion') }}
+ </gl-deprecated-button>
+ </span>
</div>
- <gl-deprecated-button
- v-else-if="canApply"
- v-gl-tooltip.viewport="__('This also resolves the discussion')"
- class="btn-inverted js-apply-btn"
- :disabled="isApplying"
- variant="success"
- @click="applySuggestion"
- >
- {{ __('Apply suggestion') }}
- </gl-deprecated-button>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 20a14d78f9b..9527c5114f2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -16,6 +16,11 @@ export default {
required: false,
default: () => [],
},
+ batchSuggestionsInfo: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
noteHtml: {
type: String,
required: true,
@@ -68,18 +73,30 @@ export default {
this.isRendered = true;
},
generateDiff(suggestionIndex) {
- const { suggestions, disabled, helpPagePath } = this;
+ const { suggestions, disabled, batchSuggestionsInfo, helpPagePath } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
- propsData: { disabled, suggestion, helpPagePath },
+ propsData: { disabled, suggestion, batchSuggestionsInfo, helpPagePath },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el });
});
+ suggestionDiff.$on('applyBatch', () => {
+ this.$emit('applyBatch', { flashContainer: this.$el });
+ });
+
+ suggestionDiff.$on('addToBatch', suggestionId => {
+ this.$emit('addToBatch', suggestionId);
+ });
+
+ suggestionDiff.$on('removeFromBatch', suggestionId => {
+ this.$emit('removeFromBatch', suggestionId);
+ });
+
return suggestionDiff;
},
reset() {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 486d4f6b609..330785c9319 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,11 +1,13 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
export default {
components: {
+ GlButton,
GlLink,
GlLoadingIcon,
+ GlSprintf,
+ GlIcon,
},
props: {
markdownDocsPath: {
@@ -35,45 +37,69 @@ export default {
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
+ <gl-link :href="markdownDocsPath" target="_blank">{{
__('Markdown is supported')
}}</gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
- __('Markdown')
- }}</gl-link>
- and
- <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">{{
- __('quick actions')
- }}</gl-link>
- are supported
+ <gl-sprintf
+ :message="
+ __(
+ '%{markdownDocsLinkStart}Markdown%{markdownDocsLinkEnd} and %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd} are supported',
+ )
+ "
+ >
+ <template #markdownDocsLink="{content}">
+ <gl-link :href="markdownDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #quickActionsDocsLink="{content}">
+ <gl-link :href="quickActionsDocsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
- <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i>
+ <template>
+ <gl-icon name="media" :size="16" />
+ </template>
<span class="attaching-file-message"></span>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<span class="uploading-progress">0%</span>
<gl-loading-icon inline class="align-text-bottom" />
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
- <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i>
+ <template>
+ <gl-icon name="media" :size="16" />
+ </template>
</span>
<span class="uploading-error-message"></span>
- <button class="retry-uploading-link" type="button">{{ __('Try again') }}</button> or
- <button class="attach-new-file markdown-selector" type="button">
- {{ __('attach a new file') }}
- </button>
+
+ <gl-sprintf
+ :message="
+ __(
+ '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}',
+ )
+ "
+ >
+ <template #retryButton="{content}">
+ <button class="retry-uploading-link" type="button">{{ content }}</button>
+ </template>
+ <template #newFileButton="{content}">
+ <button class="attach-new-file markdown-selector" type="button">{{ content }}</button>
+ </template>
+ </gl-sprintf>
</span>
- <button class="markdown-selector button-attach-file btn-link" tabindex="-1" type="button">
- <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i
- ><span class="text-attach-file">{{ __('Attach a file') }}</span>
- </button>
- <button class="btn btn-default btn-sm hide button-cancel-uploading-files" type="button">
+ <gl-button class="markdown-selector button-attach-file" variant="link">
+ <template>
+ <gl-icon name="media" :size="16" />
+ </template>
+ <span class="text-attach-file">{{ __('Attach a file') }}</span>
+ </gl-button>
+ <gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link">
{{ __('Cancel') }}
- </button>
+ </gl-button>
</span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index ec7d7e94e5c..b6271a95008 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -132,7 +132,7 @@ export default {
</pre>
<pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre>
<gl-deprecated-button
- v-if="canDeleteDescriptionVersion"
+ v-if="displayDeleteButton"
ref="deleteDescriptionVersionButton"
v-gl-tooltip
:title="__('Remove description history')"
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
index fd45ac52647..15a5ce85046 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -1,6 +1,7 @@
<script>
import { debounce } from 'lodash';
import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
+import { __, n__, sprintf } from '~/locale';
import ProjectListItem from './project_list_item.vue';
const SEARCH_INPUT_TIMEOUT_MS = 500;
@@ -24,28 +25,23 @@ export default {
},
showNoResultsMessage: {
type: Boolean,
- required: false,
- default: false,
+ required: true,
},
showMinimumSearchQueryMessage: {
type: Boolean,
- required: false,
- default: false,
+ required: true,
},
showLoadingIndicator: {
type: Boolean,
- required: false,
- default: false,
+ required: true,
},
showSearchErrorMessage: {
type: Boolean,
- required: false,
- default: false,
+ required: true,
},
totalResults: {
type: Number,
- required: false,
- default: 0,
+ required: true,
},
},
data() {
@@ -53,6 +49,20 @@ export default {
searchQuery: '',
};
},
+ computed: {
+ legendText() {
+ const count = this.projectSearchResults.length;
+ const total = this.totalResults;
+
+ if (total > 0) {
+ return sprintf(__('Showing %{count} of %{total} projects'), { count, total });
+ }
+
+ return sprintf(n__('Showing %{count} project', 'Showing %{count} projects', count), {
+ count,
+ });
+ },
+ },
methods: {
projectClicked(project) {
this.$emit('projectClicked', project);
@@ -87,17 +97,23 @@ export default {
:total-items="totalResults"
@bottomReached="bottomReached"
>
- <div v-if="!showLoadingIndicator" slot="items" class="d-flex flex-column">
- <project-list-item
- v-for="project in projectSearchResults"
- :key="project.id"
- :selected="isSelected(project)"
- :project="project"
- :matcher="searchQuery"
- class="js-project-list-item"
- @click="projectClicked(project)"
- />
- </div>
+ <template v-if="!showLoadingIndicator" #items>
+ <div class="d-flex flex-column">
+ <project-list-item
+ v-for="project in projectSearchResults"
+ :key="project.id"
+ :selected="isSelected(project)"
+ :project="project"
+ :matcher="searchQuery"
+ class="js-project-list-item"
+ @click="projectClicked(project)"
+ />
+ </div>
+ </template>
+
+ <template #default>
+ {{ legendText }}
+ </template>
</gl-infinite-scroll>
<div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
{{ __('Sorry, no projects matched your search') }}
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 457f1806452..1566c2c784b 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,5 +1,9 @@
import { __ } from '~/locale';
-import { generateToolbarItem } from './toolbar_service';
+import { generateToolbarItem } from './editor_service';
+
+export const CUSTOM_EVENTS = {
+ openAddImageModal: 'gl_openAddImageModal',
+};
/* eslint-disable @gitlab/require-i18n-strings */
const TOOLBAR_ITEM_CONFIGS = [
@@ -10,7 +14,6 @@ const TOOLBAR_ITEM_CONFIGS = [
{ isDivider: true },
{ icon: 'quote', command: 'Blockquote', tooltip: __('Insert a quote') },
{ icon: 'link', event: 'openPopupAddLink', tooltip: __('Add a link') },
- { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
{ isDivider: true },
{ icon: 'list-bulleted', command: 'UL', tooltip: __('Add a bullet list') },
{ icon: 'list-numbered', command: 'OL', tooltip: __('Add a numbered list') },
@@ -20,8 +23,10 @@ const TOOLBAR_ITEM_CONFIGS = [
{ isDivider: true },
{ icon: 'dash', command: 'HR', tooltip: __('Add a line') },
{ icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
+ { icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
{ isDivider: true },
{ icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
+ { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
];
export const EDITOR_OPTIONS = {
@@ -29,6 +34,7 @@ export const EDITOR_OPTIONS = {
};
export const EDITOR_TYPES = {
+ markdown: 'markdown',
wysiwyg: 'wysiwyg',
};
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js
new file mode 100644
index 00000000000..278cd50a947
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/editor_service.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import ToolbarItem from './toolbar_item.vue';
+
+const buildWrapper = propsData => {
+ const instance = new Vue({
+ render(createElement) {
+ return createElement(ToolbarItem, propsData);
+ },
+ });
+
+ instance.$mount();
+ return instance.$el;
+};
+
+export const generateToolbarItem = config => {
+ const { icon, classes, event, command, tooltip, isDivider } = config;
+
+ if (isDivider) {
+ return 'divider';
+ }
+
+ return {
+ type: 'button',
+ options: {
+ el: buildWrapper({ props: { icon, tooltip }, class: classes }),
+ event,
+ command,
+ },
+ };
+};
+
+export const addCustomEventListener = (editorApi, event, handler) => {
+ editorApi.eventManager.addEventType(event);
+ editorApi.eventManager.listen(event, handler);
+};
+
+export const removeCustomEventListener = (editorApi, event, handler) =>
+ editorApi.eventManager.removeEventHandler(event, handler);
+
+export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
+
+export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown');
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
new file mode 100644
index 00000000000..40063065926
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image_modal.vue
@@ -0,0 +1,74 @@
+<script>
+import { isSafeURL } from '~/lib/utils/url_utility';
+import { GlModal, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ },
+ data() {
+ return {
+ error: null,
+ imageUrl: null,
+ altText: null,
+ modalTitle: __('Image Details'),
+ okTitle: __('Insert'),
+ urlLabel: __('Image URL'),
+ descriptionLabel: __('Description'),
+ };
+ },
+ methods: {
+ show() {
+ this.error = null;
+ this.imageUrl = null;
+ this.altText = null;
+
+ this.$refs.modal.show();
+ },
+ onOk(event) {
+ if (!this.isValid()) {
+ event.preventDefault();
+ return;
+ }
+
+ const { imageUrl, altText } = this;
+
+ this.$emit('addImage', { imageUrl, altText: altText || __('image') });
+ },
+ isValid() {
+ if (!isSafeURL(this.imageUrl)) {
+ this.error = __('Please provide a valid URL');
+ this.$refs.urlInput.$el.focus();
+ return false;
+ }
+
+ return true;
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="add-image-modal"
+ :title="modalTitle"
+ :ok-title="okTitle"
+ @ok="onOk"
+ >
+ <gl-form-group
+ :label="urlLabel"
+ label-for="url-input"
+ :state="!Boolean(error)"
+ :invalid-feedback="error"
+ >
+ <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
+ </gl-form-group>
+
+ <gl-form-group :label="descriptionLabel" label-for="description-input">
+ <gl-form-input id="description-input" ref="descriptionInput" v-model="altText" />
+ </gl-form-group>
+ </gl-modal>
+</template>
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 ba3696c8ad1..5c310fc059b 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
@@ -2,7 +2,21 @@
import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
-import { EDITOR_OPTIONS, EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE } from './constants';
+import AddImageModal from './modals/add_image_modal.vue';
+import {
+ EDITOR_OPTIONS,
+ EDITOR_TYPES,
+ EDITOR_HEIGHT,
+ EDITOR_PREVIEW_STYLE,
+ CUSTOM_EVENTS,
+} from './constants';
+
+import {
+ addCustomEventListener,
+ removeCustomEventListener,
+ addImage,
+ getMarkdown,
+} from './editor_service';
export default {
components: {
@@ -10,6 +24,7 @@ export default {
import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
toast => toast.Editor,
),
+ AddImageModal,
},
props: {
value: {
@@ -37,29 +52,85 @@ export default {
default: EDITOR_PREVIEW_STYLE,
},
},
+ data() {
+ return {
+ editorApi: null,
+ previousMode: null,
+ };
+ },
computed: {
editorOptions() {
return { ...EDITOR_OPTIONS, ...this.options };
},
+ editorInstance() {
+ return this.$refs.editor;
+ },
+ },
+ watch: {
+ value(newVal) {
+ const isSameMode = this.previousMode === this.editorApi.currentMode;
+ if (!isSameMode) {
+ /*
+ The ToastUI Editor consumes its content via the `initial-value` prop and then internally
+ manages changes. If we desire the `v-model` to work as expected, we need to manually call
+ `setMarkdown`. However, if we do this in each v-model change we'll continually prevent
+ the editor from internally managing changes. Thus we use the `previousMode` flag as
+ confirmation to actually update its internals. This is initially designed so that front
+ matter is excluded from editing in wysiwyg mode, but included in markdown mode.
+ */
+ this.editorInstance.invoke('setMarkdown', newVal);
+ this.previousMode = this.editorApi.currentMode;
+ }
+ },
+ },
+ beforeDestroy() {
+ removeCustomEventListener(
+ this.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ this.onOpenAddImageModal,
+ );
+
+ this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
},
methods: {
onContentChanged() {
- this.$emit('input', this.getMarkdown());
+ this.$emit('input', getMarkdown(this.editorInstance));
+ },
+ onLoad(editorApi) {
+ this.editorApi = editorApi;
+
+ addCustomEventListener(
+ this.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ this.onOpenAddImageModal,
+ );
+
+ this.editorApi.eventManager.listen('changeMode', this.onChangeMode);
+ },
+ onOpenAddImageModal() {
+ this.$refs.addImageModal.show();
+ },
+ onAddImage(image) {
+ addImage(this.editorInstance, image);
},
- getMarkdown() {
- return this.$refs.editor.invoke('getMarkdown');
+ onChangeMode(newMode) {
+ this.$emit('modeChange', newMode);
},
},
};
</script>
<template>
- <toast-editor
- ref="editor"
- :initial-value="value"
- :options="editorOptions"
- :preview-style="previewStyle"
- :initial-edit-type="initialEditType"
- :height="height"
- @change="onContentChanged"
- />
+ <div>
+ <toast-editor
+ ref="editor"
+ :initial-value="value"
+ :options="editorOptions"
+ :preview-style="previewStyle"
+ :initial-edit-type="initialEditType"
+ :height="height"
+ @change="onContentChanged"
+ @load="onLoad"
+ />
+ <add-image-modal ref="addImageModal" @addImage="onAddImage" />
+ </div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
index 58aaeef45f2..4271f6053ed 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
@@ -1,20 +1,27 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
icon: {
type: String,
required: true,
},
+ tooltip: {
+ type: String,
+ required: true,
+ },
},
};
</script>
<template>
- <button class="p-0 gl-display-flex toolbar-button">
- <gl-icon class="gl-mx-auto" :name="icon" />
+ <button v-gl-tooltip="{ title: tooltip }" class="p-0 gl-display-flex toolbar-button">
+ <gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js
deleted file mode 100644
index fff90f3e3fb..00000000000
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import ToolbarItem from './toolbar_item.vue';
-
-const buildWrapper = propsData => {
- const instance = new Vue({
- render(createElement) {
- return createElement(ToolbarItem, propsData);
- },
- });
-
- instance.$mount();
- return instance.$el;
-};
-
-// eslint-disable-next-line import/prefer-default-export
-export const generateToolbarItem = config => {
- const { icon, classes, event, command, tooltip, isDivider } = config;
-
- if (isDivider) {
- return 'divider';
- }
-
- return {
- type: 'button',
- options: {
- el: buildWrapper({ props: { icon }, class: classes }),
- event,
- command,
- tooltip,
- },
- };
-};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
index ab652c9356a..e94e7d46f85 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
@@ -1,5 +1,6 @@
-// eslint-disable-next-line import/prefer-default-export
export const DropdownVariant = {
Sidebar: 'sidebar',
Standalone: 'standalone',
};
+
+export const LIST_BUFFER_SIZE = 5;
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 1ef2e8b3bed..af16088b6b9 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
@@ -3,15 +3,20 @@ import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import LabelItem from './label_item.vue';
+import { LIST_BUFFER_SIZE } from './constants';
+
export default {
+ LIST_BUFFER_SIZE,
components: {
GlLoadingIcon,
GlButton,
GlSearchBoxByType,
GlLink,
+ SmartVirtualList,
LabelItem,
},
data() {
@@ -139,10 +144,18 @@ export default {
<gl-search-box-by-type v-model="searchKey" :autofocus="true" />
</div>
<div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
- <ul class="list-unstyled mb-0">
+ <smart-virtual-list
+ :length="visibleLabels.length"
+ :remain="$options.LIST_BUFFER_SIZE"
+ :size="$options.LIST_BUFFER_SIZE"
+ wclass="list-unstyled mb-0"
+ wtag="ul"
+ class="h-100"
+ >
<li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left">
<label-item
:label="label"
+ :is-label-set="label.set"
:highlight="index === currentHighlightItem"
@clickLabel="handleLabelClick(label)"
/>
@@ -150,7 +163,7 @@ export default {
<li v-show="!visibleLabels.length" class="p-2 text-center">
{{ __('No matching results') }}
</li>
- </ul>
+ </smart-virtual-list>
</div>
<div v-if="isDropdownVariantSidebar" class="dropdown-footer">
<ul class="list-unstyled">
@@ -162,9 +175,9 @@ export default {
>
</li>
<li>
- <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">{{
- footerManageLabelTitle
- }}</gl-link>
+ <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">
+ {{ footerManageLabelTitle }}
+ </gl-link>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
index c95221d71b5..002e741ab96 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
@@ -11,6 +11,10 @@ export default {
type: Object,
required: true,
},
+ isLabelSet: {
+ type: Boolean,
+ required: true,
+ },
highlight: {
type: Boolean,
required: false,
@@ -19,7 +23,7 @@ export default {
},
data() {
return {
- isSet: this.label.set,
+ isSet: this.isLabelSet,
};
},
computed: {
@@ -29,6 +33,16 @@ export default {
};
},
},
+ watch: {
+ /**
+ * This watcher assures that if user used
+ * `Enter` key to set/unset label, changes
+ * are reflected here too.
+ */
+ isLabelSet(value) {
+ this.isSet = value;
+ },
+ },
methods: {
handleClick() {
this.isSet = !this.isSet;
diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue
new file mode 100644
index 00000000000..389d42f0829
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/url_sync.vue
@@ -0,0 +1,25 @@
+<script>
+import { historyPushState } from '~/lib/utils/common_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
+
+export default {
+ props: {
+ query: {
+ type: Object,
+ required: true,
+ },
+ },
+ watch: {
+ query: {
+ immediate: true,
+ deep: true,
+ handler(newQuery) {
+ historyPushState(setUrlParams(newQuery, window.location.href, true));
+ },
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
index c93b3d37a63..a740a3fa6b9 100644
--- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
@@ -5,6 +5,7 @@
* Components need to have `scope`, `page` and `requestData`
*/
import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils';
+import { validateParams } from '~/pipelines/utils';
export default {
methods: {
@@ -35,18 +36,7 @@ export default {
},
onChangeWithFilter(params) {
- const { username, ref } = this.requestData;
- const paramsData = params;
-
- if (username) {
- paramsData.username = username;
- }
-
- if (ref) {
- paramsData.ref = ref;
- }
-
- return paramsData;
+ return { ...params, ...validateParams(this.requestData) };
},
updateInternalState(parameters) {
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index 4fad34d22d8..c628a67f7f5 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -144,7 +144,9 @@ const mixins = {
return 'merge-request-status closed issue-token-state-icon-closed';
}
- return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
+ return this.isOpen
+ ? 'issue-token-state-icon-open gl-text-green-500'
+ : 'issue-token-state-icon-closed gl-text-blue-500';
},
computedLinkElementType() {
return this.path.length > 0 ? 'a' : 'span';