diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-18 11:18:50 +0000 |
commit | 8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch) | |
tree | a77e7fe7a93de11213032ed4ab1f33a3db51b738 /app/assets/javascripts/vue_shared | |
parent | 00b35af3db1abfe813a778f643dad221aad51fca (diff) | |
download | gitlab-ce-8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781.tar.gz |
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'app/assets/javascripts/vue_shared')
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'; |