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