diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-14 18:08:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-14 18:08:42 +0300 |
commit | 99670fc6a027caee34a6537c8def2e998d1ac5c2 (patch) | |
tree | a2ea3ec131d3cb155e13140c8486f1be2a5822b4 /app/assets | |
parent | c9ca178ba4c9a3e48d9d069f7d7486a29827cc61 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets')
7 files changed, 263 insertions, 49 deletions
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index 48bcba7bcca..a492b95d1d9 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -1,19 +1,24 @@ import $ from 'jquery'; import Clipboard from 'clipboard'; import { sprintf, __ } from '~/locale'; +import { fixTitle, show } from '~/tooltips'; function showTooltip(target, title) { - const $target = $(target); - const originalTitle = $target.data('originalTitle'); + const { originalTitle } = target.dataset; + const hideTooltip = () => { + target.removeEventListener('mouseout', hideTooltip); + setTimeout(() => { + target.setAttribute('title', originalTitle); + fixTitle(target); + }, 300); + }; - if (!$target.data('hideTooltip')) { - $target - .attr('title', title) - .tooltip('_fixTitle') - .tooltip('show') - .attr('title', originalTitle) - .tooltip('_fixTitle'); - } + target.setAttribute('title', title); + + fixTitle(target); + show(target); + + target.addEventListener('mouseout', hideTooltip); } function genericSuccess(e) { diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index b03da311c43..c06ecb3a8d9 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -66,12 +66,8 @@ export function isEmojiNameValid(name) { return validEmojiNames.indexOf(name) >= 0; } -export function getValidEmojiUnicodeValues() { - return Object.values(emojiMap).map(({ e }) => e); -} - -export function getValidEmojiDescriptions() { - return Object.values(emojiMap).map(({ d }) => d); +export function getAllEmoji() { + return emojiMap; } /** @@ -106,16 +102,43 @@ export function getEmoji(query, fallback = false) { } const searchMatchers = { - fuzzy: (value, query) => fuzzaldrinPlus.score(value, query) > 0, // Fuzzy matching compares using a fuzzy matching library - contains: (value, query) => value.indexOf(query.toLowerCase()) >= 0, // Contains matching compares by indexOf - exact: (value, query) => value === query.toLowerCase(), // Exact matching compares by equality + // Fuzzy matching compares using a fuzzy matching library + fuzzy: (value, query) => { + const score = fuzzaldrinPlus.score(value, query) > 0; + return { score, success: score > 0 }; + }, + // Contains matching compares by indexOf + contains: (value, query) => { + const index = value.indexOf(query.toLowerCase()); + return { index, success: index >= 0 }; + }, + // Exact matching compares by equality + exact: (value, query) => { + return { success: value === query.toLowerCase() }; + }, }; const searchPredicates = { - name: (matcher, query) => emoji => matcher(emoji.name, query), // Search by name - alias: (matcher, query) => emoji => emoji.aliases.some(v => matcher(v, query)), // Search by alias - description: (matcher, query) => emoji => matcher(emoji.d, query), // Search by description - unicode: (matcher, query) => emoji => emoji.e === query, // Search by unicode value (always exact) + // Search by name + name: (matcher, query) => emoji => { + const m = matcher(emoji.name, query); + return [{ ...m, emoji, field: emoji.name }]; + }, + // Search by alias + alias: (matcher, query) => emoji => + emoji.aliases.map(alias => { + const m = matcher(alias, query); + return { ...m, emoji, field: alias }; + }), + // Search by description + description: (matcher, query) => emoji => { + const m = matcher(emoji.d, query); + return [{ ...m, emoji, field: emoji.d }]; + }, + // Search by unicode value (always exact) + unicode: (matcher, query) => emoji => { + return [{ emoji, field: emoji.e, success: emoji.e === query }]; + }, }; /** @@ -138,6 +161,8 @@ const searchPredicates = { * matching compares using a fuzzy matching library. * @param {Boolean} opts.fallback If true, a fallback emoji will be returned if * the result set is empty. Defaults to false. + * @param {Boolean} opts.raw Returns the raw match data instead of just the + * matching emoji. * @returns {Object[]} A list of emoji that match the query. */ export function searchEmoji(query, opts) { @@ -150,6 +175,7 @@ export function searchEmoji(query, opts) { fields = ['name', 'alias', 'description', 'unicode'], match = 'exact', fallback = false, + raw = false, } = opts || {}; // optimization for an exact match in name and alias @@ -161,16 +187,22 @@ export function searchEmoji(query, opts) { const matcher = searchMatchers[match] || searchMatchers.exact; const predicates = fields.map(f => searchPredicates[f](matcher, query)); - const results = Object.values(emojiMap).filter(emoji => - predicates.some(predicate => predicate(emoji)), - ); + const results = Object.values(emojiMap) + .flatMap(emoji => predicates.flatMap(predicate => predicate(emoji))) + .filter(r => r.success); // Fallback to question mark for unknown emojis if (fallback && results.length === 0) { + if (raw) { + return [{ emoji: emojiMap.grey_question }]; + } return [emojiMap.grey_question]; } - return results; + if (raw) { + return results; + } + return results.map(r => r.emoji); } let emojiCategoryMap; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 5b604cc2a05..4c92aa41c41 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -181,6 +181,9 @@ class GfmAutoComplete { } setupEmoji($input) { + const self = this; + const { filter, ...defaults } = this.getDefaultCallbacks(); + // Emoji $input.atwho({ at: ':', @@ -195,13 +198,43 @@ class GfmAutoComplete { skipSpecialCharacterTest: true, data: GfmAutoComplete.defaultLoadingData, callbacks: { - ...this.getDefaultCallbacks(), + ...defaults, matcher(flag, subtext) { const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi'); const match = regexp.exec(subtext); return match && match.length ? match[1] : null; }, + filter(query, items, searchKey) { + const filtered = filter.call(this, query, items, searchKey); + if (query.length === 0 || GfmAutoComplete.isLoading(items)) { + return filtered; + } + + // map from value to "<value> is <field> of <emoji>", arranged by emoji + const emojis = {}; + filtered.forEach(({ name: value }) => { + self.emojiLookup[value].forEach(({ emoji: { name }, kind }) => { + let entry = emojis[name]; + if (!entry) { + entry = {}; + emojis[name] = entry; + } + if (!(kind in entry) || value.localeCompare(entry[kind]) < 0) { + entry[kind] = value; + } + }); + }); + + // collate results to list, prefering name > unicode > alias > description + const results = []; + Object.values(emojis).forEach(({ name, unicode, alias, description }) => { + results.push(name || unicode || alias || description); + }); + + // return to the form atwho wants + return results.map(name => ({ name })); + }, }, }); } @@ -637,12 +670,33 @@ class GfmAutoComplete { async loadEmojiData($input, at) { await Emoji.initEmojiMap(); + // All the emoji + const emojis = Emoji.getAllEmoji(); + + // Add all of the fields to atwho's database this.loadData($input, at, [ - ...Emoji.getValidEmojiNames(), - ...Emoji.getValidEmojiDescriptions(), - ...Emoji.getValidEmojiUnicodeValues(), + ...Object.keys(emojis), // Names + ...Object.values(emojis).flatMap(({ aliases }) => aliases), // Aliases + ...Object.values(emojis).map(({ e }) => e), // Unicode values + ...Object.values(emojis).map(({ d }) => d), // Descriptions ]); + // Construct a lookup that can correlate a value to "<value> is the <field> of <emoji>" + const lookup = {}; + const add = (key, kind, emoji) => { + if (!(key in lookup)) { + lookup[key] = []; + } + lookup[key].push({ kind, emoji }); + }; + Object.values(emojis).forEach(emoji => { + add(emoji.name, 'name', emoji); + add(emoji.d, 'description', emoji); + add(emoji.e, 'unicode', emoji); + emoji.aliases.forEach(a => add(a, 'alias', emoji)); + }); + this.emojiLookup = lookup; + GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag; } @@ -711,19 +765,36 @@ GfmAutoComplete.atTypeMap = { GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; +function findEmoji(name) { + return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => { + if (a.index !== b.index) { + return a.index - b.index; + } + return a.field.localeCompare(b.field); + }); +} + // Emoji GfmAutoComplete.glEmojiTag = null; GfmAutoComplete.Emoji = { insertTemplateFunction(value) { - const { name = value.name } = Emoji.searchEmoji(value.name, { match: 'contains' })[0] || {}; - return `:${name}:`; + const results = findEmoji(value.name); + if (results.length) { + return `:${results[0].emoji.name}:`; + } + return `:${value.name}:`; }, templateFunction(name) { // glEmojiTag helper is loaded on-demand in fetchData() if (!GfmAutoComplete.glEmojiTag) return `<li>${name}</li>`; - const emoji = Emoji.searchEmoji(name, { match: 'contains' })[0]; - return `<li>${name} ${GfmAutoComplete.glEmojiTag(emoji?.name || name)}</li>`; + const results = findEmoji(name); + if (!results.length) { + return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`; + } + + const { field, emoji } = results[0]; + return `<li>${field} ${GfmAutoComplete.glEmojiTag(emoji.name)}</li>`; }, }; // Team Members diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue new file mode 100644 index 00000000000..e6a05c1ab8b --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue @@ -0,0 +1,103 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import IssuableTitle from './issuable_title.vue'; +import IssuableDescription from './issuable_description.vue'; +import IssuableEditForm from './issuable_edit_form.vue'; + +export default { + components: { + GlLink, + TimeAgoTooltip, + IssuableTitle, + IssuableDescription, + IssuableEditForm, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: true, + }, + statusIcon: { + type: String, + required: true, + }, + enableEdit: { + type: Boolean, + required: true, + }, + enableAutocomplete: { + type: Boolean, + required: true, + }, + editFormVisible: { + type: Boolean, + required: true, + }, + descriptionPreviewPath: { + type: String, + required: true, + }, + descriptionHelpPath: { + type: String, + required: true, + }, + }, + computed: { + isUpdated() { + return Boolean(this.issuable.updatedAt); + }, + updatedBy() { + return this.issuable.updatedBy; + }, + }, +}; +</script> + +<template> + <div class="issue-details issuable-details"> + <div class="detail-page-description content-block"> + <issuable-edit-form + v-if="editFormVisible" + :issuable="issuable" + :enable-autocomplete="enableAutocomplete" + :description-preview-path="descriptionPreviewPath" + :description-help-path="descriptionHelpPath" + > + <template #edit-form-actions="issuableMeta"> + <slot name="edit-form-actions" v-bind="issuableMeta"></slot> + </template> + </issuable-edit-form> + <template v-else> + <issuable-title + :issuable="issuable" + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :enable-edit="enableEdit" + @edit-issuable="$emit('edit-issuable', $event)" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + </issuable-title> + <issuable-description v-if="issuable.descriptionHtml" :issuable="issuable" /> + <small v-if="isUpdated" class="edited-text gl-font-sm!"> + {{ __('Edited') }} + <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" /> + <span v-if="updatedBy"> + {{ __('by') }} + <gl-link :href="updatedBy.webUrl" class="author-link gl-font-sm!"> + <span>{{ updatedBy.name }}</span> + </gl-link> + </span> + </small> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js index debb36dc53f..9f5dce4183c 100644 --- a/app/assets/javascripts/tooltips/index.js +++ b/app/assets/javascripts/tooltips/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import jQuery from 'jquery'; -import { toArray, isFunction } from 'lodash'; +import { toArray, isFunction, isElement } from 'lodash'; import Tooltips from './components/tooltips.vue'; let app; @@ -54,7 +54,11 @@ const handleTooltipEvent = (rootTarget, e, selector, config = {}) => { } }; -const applyToElements = (elements, handler) => toArray(elements).forEach(handler); +const applyToElements = (elements, handler) => { + const iterable = isElement(elements) ? [elements] : toArray(elements); + + toArray(iterable).forEach(handler); +}; const invokeBootstrapApi = (elements, method) => { if (isFunction(elements.tooltip)) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 543d70cbdbe..17cd740ddd9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -1,8 +1,7 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlLoadingIcon, GlButton } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { deprecatedCreateFlash as Flash } from '~/flash'; -import tooltip from '~/vue_shared/directives/tooltip'; import { s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; @@ -12,7 +11,7 @@ import eventHub from '../../event_hub'; export default { name: 'MRWidgetMerged', directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { MrWidgetAuthorTime, @@ -115,7 +114,7 @@ export default { /> <gl-button v-if="mr.canRevertInCurrentMR" - v-tooltip + v-gl-tooltip.hover :title="revertTitle" size="small" category="secondary" @@ -128,7 +127,7 @@ export default { </gl-button> <gl-button v-else-if="mr.revertInForkPath" - v-tooltip + v-gl-tooltip.hover :href="mr.revertInForkPath" :title="revertTitle" size="small" @@ -140,7 +139,7 @@ export default { </gl-button> <gl-button v-if="mr.canCherryPickInCurrentMR" - v-tooltip + v-gl-tooltip.hover :title="cherryPickTitle" size="small" href="#modal-cherry-pick-commit" @@ -151,7 +150,7 @@ export default { </gl-button> <gl-button v-else-if="mr.cherryPickInForkPath" - v-tooltip + v-gl-tooltip.hover :href="mr.cherryPickInForkPath" :title="cherryPickTitle" size="small" diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue index a0c161a335a..f2e9c4a4fbb 100644 --- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue +++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue @@ -1,11 +1,11 @@ <script> +import { GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import { roundOffFloat } from '~/lib/utils/common_utils'; -import tooltip from '~/vue_shared/directives/tooltip'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { cssClass: { @@ -112,7 +112,7 @@ export default { <span v-if="!totalCount" class="status-unavailable">{{ unavailableLabel }}</span> <span v-if="successPercent" - v-tooltip + v-gl-tooltip :title="successTooltip" :style="successBarStyle" class="status-green" @@ -122,7 +122,7 @@ export default { </span> <span v-if="neutralPercent" - v-tooltip + v-gl-tooltip :title="neutralTooltip" :style="neutralBarStyle" class="status-neutral" @@ -132,7 +132,7 @@ export default { </span> <span v-if="failurePercent" - v-tooltip + v-gl-tooltip :title="failureTooltip" :style="failureBarStyle" class="status-red" |