Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
b4d79e4b91
commit
19cd21a9c1
23 changed files with 980 additions and 512 deletions
|
@ -560,7 +560,7 @@ export class AwardsHandler {
|
|||
}
|
||||
|
||||
findMatchingEmojiElements(query) {
|
||||
const emojiMatches = this.emoji.searchEmoji(query, { match: 'fuzzy' }).map(({ name }) => name);
|
||||
const emojiMatches = this.emoji.searchEmoji(query).map((x) => x.emoji.name);
|
||||
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
|
||||
const $matchingElements = $emojiElements.filter(
|
||||
(i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
import 'document-register-element';
|
||||
import isEmojiUnicodeSupported from '../emoji/support';
|
||||
import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
|
||||
import {
|
||||
initEmojiMap,
|
||||
getEmojiInfo,
|
||||
emojiFallbackImageSrc,
|
||||
emojiImageTag,
|
||||
FALLBACK_EMOJI_KEY,
|
||||
} from '../emoji';
|
||||
|
||||
class GlEmoji extends HTMLElement {
|
||||
connectedCallback() {
|
||||
|
@ -17,7 +23,7 @@ class GlEmoji extends HTMLElement {
|
|||
|
||||
if (emojiInfo) {
|
||||
if (name !== emojiInfo.name) {
|
||||
if (emojiInfo.fallback && this.innerHTML) {
|
||||
if (emojiInfo.name === FALLBACK_EMOJI_KEY && this.innerHTML) {
|
||||
return; // When fallback emoji is used, but there is a <img> provided, use the <img> instead
|
||||
}
|
||||
|
||||
|
|
110
app/assets/javascripts/captcha/captcha_modal.vue
Normal file
110
app/assets/javascripts/captcha/captcha_modal.vue
Normal file
|
@ -0,0 +1,110 @@
|
|||
<script>
|
||||
// NOTE 1: This is similar to recaptcha_modal.vue, but it directly uses the reCAPTCHA Javascript API
|
||||
// (https://developers.google.com/recaptcha/docs/display#js_api) and gl-modal, rather than relying
|
||||
// on the form-based ReCAPTCHA HTML being pre-rendered by the backend and using deprecated-modal.
|
||||
|
||||
// NOTE 2: Even though this modal currently only supports reCAPTCHA, we use 'captcha' instead
|
||||
// of 'recaptcha' throughout the code, so that we can easily add support for future alternative
|
||||
// captcha implementations other than reCAPTCHA (e.g. FriendlyCaptcha) without having to
|
||||
// change the references in the code or API.
|
||||
|
||||
import { uniqueId } from 'lodash';
|
||||
import { GlModal } from '@gitlab/ui';
|
||||
import { initRecaptchaScript } from '~/captcha/init_recaptcha_script';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlModal,
|
||||
},
|
||||
props: {
|
||||
needsCaptchaResponse: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
captchaSiteKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
modalId: uniqueId('captcha-modal-'),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
needsCaptchaResponse(newNeedsCaptchaResponse) {
|
||||
// If this is true, we need to present the captcha modal to the user.
|
||||
// When the modal is shown we will also initialize and render the form.
|
||||
if (newNeedsCaptchaResponse) {
|
||||
this.$refs.modal.show();
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
emitReceivedCaptchaResponse(captchaResponse) {
|
||||
this.$emit('receivedCaptchaResponse', captchaResponse);
|
||||
this.$refs.modal.hide();
|
||||
},
|
||||
emitNullReceivedCaptchaResponse() {
|
||||
this.emitReceivedCaptchaResponse(null);
|
||||
},
|
||||
/**
|
||||
* handler for when modal is shown
|
||||
*/
|
||||
shown() {
|
||||
const containerRef = this.$refs.captcha;
|
||||
|
||||
// NOTE: This is the only bit that is specific to Google's reCAPTCHA captcha implementation.
|
||||
initRecaptchaScript()
|
||||
.then((grecaptcha) => {
|
||||
grecaptcha.render(containerRef, {
|
||||
sitekey: this.captchaSiteKey,
|
||||
// This callback will emit and let the parent handle the response
|
||||
callback: this.emitReceivedCaptchaResponse,
|
||||
// TODO: Also need to handle expired-callback and error-callback
|
||||
// See https://gitlab.com/gitlab-org/gitlab/-/issues/217722#future-follow-on-issuesmrs
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
// TODO: flash the error or notify the user some other way
|
||||
// See https://gitlab.com/gitlab-org/gitlab/-/issues/217722#future-follow-on-issuesmrs
|
||||
this.emitNullReceivedCaptchaResponse();
|
||||
this.$refs.modal.hide();
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
'[gitlab] an unexpected exception was caught while initializing captcha',
|
||||
e,
|
||||
);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* handler for when modal is about to hide
|
||||
*/
|
||||
hide(bvModalEvent) {
|
||||
// If hide() was called without any argument, the value of trigger will be null.
|
||||
// See https://bootstrap-vue.org/docs/components/modal#prevent-closing
|
||||
if (bvModalEvent.trigger) {
|
||||
this.emitNullReceivedCaptchaResponse();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<!-- Note: The action-cancel button isn't necessary for the functionality of the modal, but -->
|
||||
<!-- there must be at least one button or focusable element, or the gl-modal fails to render. -->
|
||||
<!-- We could modify gl-model to remove this requirement. -->
|
||||
<gl-modal
|
||||
ref="modal"
|
||||
:modal-id="modalId"
|
||||
:title="__('Please solve the captcha')"
|
||||
:action-cancel="{ text: __('Cancel') }"
|
||||
@shown="shown"
|
||||
@hide="hide"
|
||||
>
|
||||
<div ref="captcha"></div>
|
||||
<p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p>
|
||||
</gl-modal>
|
||||
</template>
|
|
@ -28,11 +28,11 @@ export const initRecaptchaScript = memoize(() => {
|
|||
|
||||
return new Promise((resolve) => {
|
||||
// This global callback resolves the Promise and is passed by name to the reCAPTCHA script.
|
||||
window[RECAPTCHA_ONLOAD_CALLBACK_NAME] = (val) => {
|
||||
window[RECAPTCHA_ONLOAD_CALLBACK_NAME] = () => {
|
||||
// Let's clean up after ourselves. This is also important for testing, because `window` is NOT cleared between tests.
|
||||
// https://github.com/facebook/jest/issues/1224#issuecomment-444586798.
|
||||
delete window[RECAPTCHA_ONLOAD_CALLBACK_NAME];
|
||||
resolve(val);
|
||||
resolve(window.grecaptcha);
|
||||
};
|
||||
appendRecaptchaScript();
|
||||
});
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import fuzzaldrinPlus from 'fuzzaldrin-plus';
|
||||
import { escape, minBy } from 'lodash';
|
||||
import emojiAliases from 'emojis/aliases.json';
|
||||
import axios from '../lib/utils/axios_utils';
|
||||
import AccessorUtilities from '../lib/utils/accessor';
|
||||
|
||||
let emojiMap = null;
|
||||
let validEmojiNames = null;
|
||||
export const FALLBACK_EMOJI_KEY = 'grey_question';
|
||||
|
||||
export const EMOJI_VERSION = '1';
|
||||
|
||||
|
@ -30,23 +31,17 @@ async function loadEmoji() {
|
|||
return data;
|
||||
}
|
||||
|
||||
async function loadEmojiWithNames() {
|
||||
return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => {
|
||||
acc[key] = { ...value, name: key };
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async function prepareEmojiMap() {
|
||||
emojiMap = await loadEmoji();
|
||||
|
||||
emojiMap = await loadEmojiWithNames();
|
||||
validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)];
|
||||
|
||||
Object.keys(emojiMap).forEach((name) => {
|
||||
emojiMap[name].aliases = [];
|
||||
emojiMap[name].name = name;
|
||||
});
|
||||
Object.entries(emojiAliases).forEach(([alias, name]) => {
|
||||
// This check, `if (name in emojiMap)` is necessary during testing. In
|
||||
// production, it shouldn't be necessary, because at no point should there
|
||||
// be an entry in aliases.json with no corresponding entry in emojis.json.
|
||||
// However, during testing, the endpoint for emojis.json is mocked with a
|
||||
// small dataset, whereas aliases.json is always `import`ed directly.
|
||||
if (name in emojiMap) emojiMap[name].aliases.push(alias);
|
||||
});
|
||||
}
|
||||
|
||||
export function initEmojiMap() {
|
||||
|
@ -63,156 +58,101 @@ export function getValidEmojiNames() {
|
|||
}
|
||||
|
||||
export function isEmojiNameValid(name) {
|
||||
return validEmojiNames.indexOf(name) >= 0;
|
||||
if (!emojiMap) {
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
throw new Error('The emoji map is uninitialized or initialization has not completed');
|
||||
}
|
||||
|
||||
return name in emojiMap || name in emojiAliases;
|
||||
}
|
||||
|
||||
export function getAllEmoji() {
|
||||
return emojiMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an emoji by name or alias.
|
||||
*
|
||||
* Note: `initEmojiMap` must have been called and completed before this method
|
||||
* can safely be called.
|
||||
*
|
||||
* @param {String} query The emoji name
|
||||
* @param {Boolean} fallback If true, a fallback emoji will be returned if the
|
||||
* named emoji does not exist. Defaults to false.
|
||||
* @returns {Object} The matching emoji.
|
||||
*/
|
||||
export function getEmoji(query, fallback = false) {
|
||||
// TODO https://gitlab.com/gitlab-org/gitlab/-/issues/268208
|
||||
const fallbackEmoji = emojiMap.grey_question;
|
||||
if (!query) {
|
||||
return fallback ? fallbackEmoji : null;
|
||||
}
|
||||
function getAliasesMatchingQuery(query) {
|
||||
return Object.keys(emojiAliases)
|
||||
.filter((alias) => alias.includes(query))
|
||||
.reduce((map, alias) => {
|
||||
const emojiName = emojiAliases[alias];
|
||||
const score = alias.indexOf(query);
|
||||
|
||||
if (!emojiMap) {
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
throw new Error('The emoji map is uninitialized or initialization has not completed');
|
||||
}
|
||||
const prev = map.get(emojiName);
|
||||
// overwrite if we beat the previous score or we're more alphabetical
|
||||
const shouldSet =
|
||||
!prev ||
|
||||
prev.score > score ||
|
||||
(prev.score === score && prev.alias.localeCompare(alias) > 0);
|
||||
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
const name = normalizeEmojiName(lowercaseQuery);
|
||||
if (shouldSet) {
|
||||
map.set(emojiName, { score, alias });
|
||||
}
|
||||
|
||||
if (name in emojiMap) {
|
||||
return emojiMap[name];
|
||||
}
|
||||
|
||||
return fallback ? fallbackEmoji : null;
|
||||
return map;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
const searchMatchers = {
|
||||
// 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 = {
|
||||
// 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 }];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches emoji by name, aliases, description, and unicode value and returns
|
||||
* an array of matches.
|
||||
*
|
||||
* Behavior is undefined if `opts.fields` is empty or if `opts.match` is fuzzy
|
||||
* and the query is empty.
|
||||
*
|
||||
* Note: `initEmojiMap` must have been called and completed before this method
|
||||
* can safely be called.
|
||||
*
|
||||
* @param {String} query Search query.
|
||||
* @param {Object} opts Search options (optional).
|
||||
* @param {String[]} opts.fields Fields to search. Choices are 'name', 'alias',
|
||||
* 'description', and 'unicode' (value). Default is all (four) fields.
|
||||
* @param {String} opts.match Search method to use. Choices are 'exact',
|
||||
* 'contains', or 'fuzzy'. All methods are case-insensitive. Exact matching (the
|
||||
* default) compares by equality. Contains matching compares by indexOf. Fuzzy
|
||||
* 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) {
|
||||
if (!emojiMap) {
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
throw new Error('The emoji map is uninitialized or initialization has not completed');
|
||||
function getUnicodeMatch(emoji, query) {
|
||||
if (emoji.e === query) {
|
||||
return { score: 0, field: 'e', fieldValue: emoji.name, emoji };
|
||||
}
|
||||
|
||||
const {
|
||||
fields = ['name', 'alias', 'description', 'unicode'],
|
||||
match = 'exact',
|
||||
fallback = false,
|
||||
raw = false,
|
||||
} = opts || {};
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackEmoji = emojiMap.grey_question;
|
||||
|
||||
if (fallbackEmoji) {
|
||||
fallbackEmoji.fallback = true;
|
||||
function getDescriptionMatch(emoji, query) {
|
||||
if (emoji.d.includes(query)) {
|
||||
return { score: emoji.d.indexOf(query), field: 'd', fieldValue: emoji.d, emoji };
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
if (fallback) {
|
||||
return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return [];
|
||||
function getAliasMatch(emoji, matchingAliases) {
|
||||
if (matchingAliases.has(emoji.name)) {
|
||||
const { score, alias } = matchingAliases.get(emoji.name);
|
||||
|
||||
return { score, field: 'alias', fieldValue: alias, emoji };
|
||||
}
|
||||
|
||||
// optimization for an exact match in name and alias
|
||||
if (match === 'exact' && new Set([...fields, 'name', 'alias']).size === 2) {
|
||||
const emoji = getEmoji(query, fallback);
|
||||
return emoji ? [emoji] : [];
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNameMatch(emoji, query) {
|
||||
if (emoji.name.includes(query)) {
|
||||
return {
|
||||
score: emoji.name.indexOf(query),
|
||||
field: 'name',
|
||||
fieldValue: emoji.name,
|
||||
emoji,
|
||||
};
|
||||
}
|
||||
|
||||
const matcher = searchMatchers[match] || searchMatchers.exact;
|
||||
const predicates = fields.map((f) => searchPredicates[f](matcher, query));
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = Object.values(emojiMap)
|
||||
.flatMap((emoji) => predicates.flatMap((predicate) => predicate(emoji)))
|
||||
.filter((r) => r.success);
|
||||
export function searchEmoji(query) {
|
||||
const lowercaseQuery = query ? `${query}`.toLowerCase() : '';
|
||||
|
||||
// Fallback to question mark for unknown emojis
|
||||
if (fallback && results.length === 0) {
|
||||
return raw ? [{ emoji: fallbackEmoji }] : [fallbackEmoji];
|
||||
}
|
||||
const matchingAliases = getAliasesMatchingQuery(lowercaseQuery);
|
||||
|
||||
return raw ? results : results.map((r) => r.emoji);
|
||||
return Object.values(emojiMap)
|
||||
.map((emoji) => {
|
||||
const matches = [
|
||||
getUnicodeMatch(emoji, query),
|
||||
getDescriptionMatch(emoji, lowercaseQuery),
|
||||
getAliasMatch(emoji, matchingAliases),
|
||||
getNameMatch(emoji, lowercaseQuery),
|
||||
].filter(Boolean);
|
||||
|
||||
return minBy(matches, (x) => x.score);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function sortEmoji(items) {
|
||||
// Sort results by index of and string comparison
|
||||
return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue));
|
||||
}
|
||||
|
||||
let emojiCategoryMap;
|
||||
|
@ -238,11 +178,28 @@ export function getEmojiCategoryMap() {
|
|||
return emojiCategoryMap;
|
||||
}
|
||||
|
||||
export function getEmojiInfo(query) {
|
||||
return searchEmoji(query, {
|
||||
fields: ['name', 'alias'],
|
||||
fallback: true,
|
||||
})[0];
|
||||
/**
|
||||
* Retrieves an emoji by name
|
||||
*
|
||||
* @param {String} query The emoji name
|
||||
* @param {Boolean} fallback If true, a fallback emoji will be returned if the
|
||||
* named emoji does not exist.
|
||||
* @returns {Object} The matching emoji.
|
||||
*/
|
||||
export function getEmojiInfo(query, fallback = true) {
|
||||
if (!emojiMap) {
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
throw new Error('The emoji map is uninitialized or initialization has not completed');
|
||||
}
|
||||
|
||||
const lowercaseQuery = query ? `${query}`.toLowerCase() : '';
|
||||
const name = normalizeEmojiName(lowercaseQuery);
|
||||
|
||||
if (name in emojiMap) {
|
||||
return emojiMap[name];
|
||||
}
|
||||
|
||||
return fallback ? emojiMap[FALLBACK_EMOJI_KEY] : null;
|
||||
}
|
||||
|
||||
export function emojiFallbackImageSrc(inputName) {
|
||||
|
@ -262,12 +219,8 @@ export function glEmojiTag(inputName, options) {
|
|||
const fallbackSpriteClass = `emoji-${name}`;
|
||||
|
||||
const fallbackSpriteAttribute = opts.sprite
|
||||
? `data-fallback-sprite-class="${fallbackSpriteClass}"`
|
||||
? `data-fallback-sprite-class="${escape(fallbackSpriteClass)}" `
|
||||
: '';
|
||||
|
||||
return `
|
||||
<gl-emoji
|
||||
${fallbackSpriteAttribute}
|
||||
data-name="${name}"></gl-emoji>
|
||||
`;
|
||||
return `<gl-emoji ${fallbackSpriteAttribute}data-name="${escape(name)}"></gl-emoji>`;
|
||||
}
|
||||
|
|
|
@ -190,59 +190,43 @@ class GfmAutoComplete {
|
|||
}
|
||||
|
||||
setupEmoji($input) {
|
||||
const self = this;
|
||||
const { filter, ...defaults } = this.getDefaultCallbacks();
|
||||
const fetchData = this.fetchData.bind(this);
|
||||
|
||||
// Emoji
|
||||
$input.atwho({
|
||||
at: ':',
|
||||
displayTpl(value) {
|
||||
let tmpl = GfmAutoComplete.Loading.template;
|
||||
if (value && value.name) {
|
||||
tmpl = GfmAutoComplete.Emoji.templateFunction(value.name);
|
||||
}
|
||||
return tmpl;
|
||||
},
|
||||
displayTpl: GfmAutoComplete.Emoji.templateFunction,
|
||||
insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction,
|
||||
skipSpecialCharacterTest: true,
|
||||
data: GfmAutoComplete.defaultLoadingData,
|
||||
callbacks: {
|
||||
...defaults,
|
||||
...this.getDefaultCallbacks(),
|
||||
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;
|
||||
filter(query, items) {
|
||||
if (GfmAutoComplete.isLoading(items)) {
|
||||
fetchData(this.$inputor, this.at);
|
||||
return items;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
return GfmAutoComplete.Emoji.filter(query);
|
||||
},
|
||||
sorter(query, items) {
|
||||
this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
|
||||
if (GfmAutoComplete.isLoading(items)) {
|
||||
this.setting.highlightFirst = false;
|
||||
return items;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
if (query.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// return to the form atwho wants
|
||||
return results.map((name) => ({ name }));
|
||||
return GfmAutoComplete.Emoji.sorter(items);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -674,32 +658,7 @@ 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, [
|
||||
...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;
|
||||
this.loadData($input, at, ['loaded']);
|
||||
|
||||
GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
|
||||
}
|
||||
|
@ -772,36 +731,38 @@ GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
|
|||
GfmAutoComplete.isTypeWithBackendFiltering = (type) =>
|
||||
GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[type]);
|
||||
|
||||
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 results = findEmoji(value.name);
|
||||
if (results.length) {
|
||||
return `:${results[0].emoji.name}:`;
|
||||
}
|
||||
return `:${value.name}:`;
|
||||
return `:${value.emoji.name}:`;
|
||||
},
|
||||
templateFunction(name) {
|
||||
// glEmojiTag helper is loaded on-demand in fetchData()
|
||||
if (!GfmAutoComplete.glEmojiTag) return `<li>${name}</li>`;
|
||||
|
||||
const results = findEmoji(name);
|
||||
if (!results.length) {
|
||||
return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
|
||||
templateFunction(item) {
|
||||
if (GfmAutoComplete.isLoading(item)) {
|
||||
return GfmAutoComplete.Loading.template;
|
||||
}
|
||||
|
||||
const { field, emoji } = results[0];
|
||||
return `<li>${field} ${GfmAutoComplete.glEmojiTag(emoji.name)}</li>`;
|
||||
const escapedFieldValue = escape(item.fieldValue);
|
||||
if (!GfmAutoComplete.glEmojiTag) {
|
||||
return `<li>${escapedFieldValue}</li>`;
|
||||
}
|
||||
|
||||
return `<li>${escapedFieldValue} ${GfmAutoComplete.glEmojiTag(item.emoji.name)}</li>`;
|
||||
},
|
||||
filter(query) {
|
||||
if (query.length === 0) {
|
||||
return Object.values(Emoji.getAllEmoji())
|
||||
.map((emoji) => ({
|
||||
emoji,
|
||||
fieldValue: emoji.name,
|
||||
}))
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
return Emoji.searchEmoji(query);
|
||||
},
|
||||
sorter(items) {
|
||||
return Emoji.sortEmoji(items);
|
||||
},
|
||||
};
|
||||
// Team Members
|
||||
|
|
|
@ -32,6 +32,7 @@ export default {
|
|||
SnippetBlobActionsEdit,
|
||||
TitleField,
|
||||
FormFooterActions,
|
||||
CaptchaModal: () => import('~/captcha/captcha_modal.vue'),
|
||||
GlButton,
|
||||
GlLoadingIcon,
|
||||
},
|
||||
|
@ -66,12 +67,25 @@ export default {
|
|||
description: '',
|
||||
visibilityLevel: this.selectedLevel,
|
||||
},
|
||||
captchaResponse: '',
|
||||
needsCaptchaResponse: false,
|
||||
captchaSiteKey: '',
|
||||
spamLogId: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasBlobChanges() {
|
||||
return this.actions.length > 0;
|
||||
},
|
||||
hasNoChanges() {
|
||||
return (
|
||||
this.actions.every(
|
||||
(action) => !action.content && !action.filePath && !action.previousPath,
|
||||
) &&
|
||||
!this.snippet.title &&
|
||||
!this.snippet.description
|
||||
);
|
||||
},
|
||||
hasValidBlobs() {
|
||||
return this.actions.every((x) => x.content);
|
||||
},
|
||||
|
@ -88,6 +102,8 @@ export default {
|
|||
description: this.snippet.description,
|
||||
visibilityLevel: this.snippet.visibilityLevel,
|
||||
blobActions: this.actions,
|
||||
...(this.spamLogId && { spamLogId: this.spamLogId }),
|
||||
...(this.captchaResponse && { captchaResponse: this.captchaResponse }),
|
||||
};
|
||||
},
|
||||
saveButtonLabel() {
|
||||
|
@ -116,7 +132,7 @@ export default {
|
|||
onBeforeUnload(e = {}) {
|
||||
const returnValue = __('Are you sure you want to lose unsaved changes?');
|
||||
|
||||
if (!this.hasBlobChanges || this.isUpdating) return undefined;
|
||||
if (!this.hasBlobChanges || this.hasNoChanges || this.isUpdating) return undefined;
|
||||
|
||||
Object.assign(e, { returnValue });
|
||||
return returnValue;
|
||||
|
@ -159,6 +175,13 @@ export default {
|
|||
.then(({ data }) => {
|
||||
const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
|
||||
|
||||
if (baseObj.needsCaptchaResponse) {
|
||||
// If we need a captcha response, start process for receiving captcha response.
|
||||
// We will resubmit after the response is obtained.
|
||||
this.requestCaptchaResponse(baseObj.captchaSiteKey, baseObj.spamLogId);
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = baseObj?.errors;
|
||||
if (errors.length) {
|
||||
this.flashAPIFailure(errors[0]);
|
||||
|
@ -173,6 +196,35 @@ export default {
|
|||
updateActions(actions) {
|
||||
this.actions = actions;
|
||||
},
|
||||
/**
|
||||
* Start process for getting captcha response from user
|
||||
*
|
||||
* @param captchaSiteKey Stored in data and used to display the captcha.
|
||||
* @param spamLogId Stored in data and included when the form is re-submitted.
|
||||
*/
|
||||
requestCaptchaResponse(captchaSiteKey, spamLogId) {
|
||||
this.captchaSiteKey = captchaSiteKey;
|
||||
this.spamLogId = spamLogId;
|
||||
this.needsCaptchaResponse = true;
|
||||
},
|
||||
/**
|
||||
* Handle the captcha response from the user
|
||||
*
|
||||
* @param captchaResponse The captchaResponse value emitted from the modal.
|
||||
*/
|
||||
receivedCaptchaResponse(captchaResponse) {
|
||||
this.needsCaptchaResponse = false;
|
||||
this.captchaResponse = captchaResponse;
|
||||
|
||||
if (this.captchaResponse) {
|
||||
// If the user solved the captcha resubmit the form.
|
||||
this.handleFormSubmit();
|
||||
} else {
|
||||
// If the user didn't solve the captcha (e.g. they just closed the modal),
|
||||
// finish the update and allow them to continue editing or manually resubmit the form.
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -190,6 +242,11 @@ export default {
|
|||
class="loading-animation prepend-top-20 gl-mb-6"
|
||||
/>
|
||||
<template v-else>
|
||||
<captcha-modal
|
||||
:captcha-site-key="captchaSiteKey"
|
||||
:needs-captcha-response="needsCaptchaResponse"
|
||||
@receivedCaptchaResponse="receivedCaptchaResponse"
|
||||
/>
|
||||
<title-field
|
||||
id="snippet-title"
|
||||
v-model="snippet.title"
|
||||
|
|
|
@ -4,5 +4,7 @@ mutation CreateSnippet($input: CreateSnippetInput!) {
|
|||
snippet {
|
||||
webUrl
|
||||
}
|
||||
needsCaptchaResponse
|
||||
captchaSiteKey
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,5 +4,8 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) {
|
|||
snippet {
|
||||
webUrl
|
||||
}
|
||||
needsCaptchaResponse
|
||||
captchaSiteKey
|
||||
spamLogId
|
||||
}
|
||||
}
|
||||
|
|
|
@ -215,6 +215,10 @@ ul.related-merge-requests > li {
|
|||
|
||||
.reverse-sort-btn {
|
||||
color: $gl-text-color-secondary;
|
||||
|
||||
&.disabled {
|
||||
color: $gl-text-color-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix "Stay on Page" alert showing in empty snippet
|
||||
merge_request: 50400
|
||||
author: Kev @KevSlashNull
|
||||
type: fixed
|
5
changelogs/unreleased/gfm-emoji-refactor.yml
Normal file
5
changelogs/unreleased/gfm-emoji-refactor.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove fuzzy search for awards emoji and refactor GFM autocomplete emoji support
|
||||
merge_request: 51972
|
||||
author: Ethan Reesor (@firelizzard)
|
||||
type: other
|
|
@ -21934,6 +21934,9 @@ msgstr ""
|
|||
msgid "Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience."
|
||||
msgstr ""
|
||||
|
||||
msgid "Please solve the captcha"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please solve the reCAPTCHA"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -29,10 +29,6 @@ export const emojiFixtureMap = {
|
|||
unicodeVersion: '6.0',
|
||||
description: 'white question mark ornament',
|
||||
},
|
||||
|
||||
// used for regression tests
|
||||
// black_heart MUST come before heart
|
||||
// custard MUST come before star
|
||||
black_heart: {
|
||||
moji: '🖤',
|
||||
unicodeVersion: '1.1',
|
||||
|
@ -55,34 +51,18 @@ export const emojiFixtureMap = {
|
|||
},
|
||||
};
|
||||
|
||||
Object.keys(emojiFixtureMap).forEach((k) => {
|
||||
emojiFixtureMap[k].name = k;
|
||||
if (!emojiFixtureMap[k].aliases) {
|
||||
emojiFixtureMap[k].aliases = [];
|
||||
}
|
||||
});
|
||||
export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => {
|
||||
const { moji: e, unicodeVersion: u, category: c, description: d } = emojiFixtureMap[k];
|
||||
acc[k] = { name: k, e, u, c, d };
|
||||
|
||||
export async function initEmojiMock() {
|
||||
const emojiData = Object.fromEntries(
|
||||
Object.values(emojiFixtureMap).map((m) => {
|
||||
const { name: n, moji: e, unicodeVersion: u, category: c, description: d } = m;
|
||||
return [n, { c, e, d, u }];
|
||||
}),
|
||||
);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
export async function initEmojiMock(mockData = mockEmojiData) {
|
||||
const mock = new MockAdapter(axios);
|
||||
mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData));
|
||||
mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(mockData));
|
||||
|
||||
await initEmojiMap();
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
export function describeEmojiFields(label, tests) {
|
||||
describe.each`
|
||||
field | accessor
|
||||
${'name'} | ${(e) => e.name}
|
||||
${'alias'} | ${(e) => e.aliases[0]}
|
||||
${'description'} | ${(e) => e.description}
|
||||
`(label, tests);
|
||||
}
|
||||
|
|
|
@ -53,6 +53,12 @@ describe('AwardsHandler', () => {
|
|||
d: 'smiling face with sunglasses',
|
||||
u: '6.0',
|
||||
},
|
||||
grey_question: {
|
||||
c: 'symbols',
|
||||
e: '❔',
|
||||
d: 'white question mark ornament',
|
||||
u: '6.0',
|
||||
},
|
||||
};
|
||||
preloadFixtures('snippets/show.html');
|
||||
|
||||
|
@ -285,16 +291,6 @@ describe('AwardsHandler', () => {
|
|||
expect($('.js-emoji-menu-search').val()).toBe('');
|
||||
});
|
||||
|
||||
it('should fuzzy filter the emoji', async () => {
|
||||
await openAndWaitForEmojiMenu();
|
||||
|
||||
awardsHandler.searchEmojis('sgls');
|
||||
|
||||
expect($('[data-name=angel]').is(':visible')).toBe(false);
|
||||
expect($('[data-name=anger]').is(':visible')).toBe(false);
|
||||
expect($('[data-name=sunglasses]').is(':visible')).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by emoji description', async () => {
|
||||
await openAndWaitForEmojiMenu();
|
||||
|
||||
|
|
171
spec/frontend/captcha/captcha_modal_spec.js
Normal file
171
spec/frontend/captcha/captcha_modal_spec.js
Normal file
|
@ -0,0 +1,171 @@
|
|||
import { GlModal } from '@gitlab/ui';
|
||||
import { stubComponent } from 'helpers/stub_component';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import CaptchaModal from '~/captcha/captcha_modal.vue';
|
||||
import { initRecaptchaScript } from '~/captcha/init_recaptcha_script';
|
||||
|
||||
jest.mock('~/captcha/init_recaptcha_script');
|
||||
|
||||
describe('Captcha Modal', () => {
|
||||
let wrapper;
|
||||
let modal;
|
||||
let grecaptcha;
|
||||
|
||||
const captchaSiteKey = 'abc123';
|
||||
|
||||
function createComponent({ props = {} } = {}) {
|
||||
wrapper = shallowMount(CaptchaModal, {
|
||||
propsData: {
|
||||
captchaSiteKey,
|
||||
...props,
|
||||
},
|
||||
stubs: {
|
||||
GlModal: stubComponent(GlModal),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grecaptcha = {
|
||||
render: jest.fn(),
|
||||
};
|
||||
|
||||
initRecaptchaScript.mockResolvedValue(grecaptcha);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
const findGlModal = () => {
|
||||
const glModal = wrapper.find(GlModal);
|
||||
|
||||
jest.spyOn(glModal.vm, 'show').mockImplementation(() => glModal.vm.$emit('shown'));
|
||||
jest
|
||||
.spyOn(glModal.vm, 'hide')
|
||||
.mockImplementation(() => glModal.vm.$emit('hide', { trigger: '' }));
|
||||
|
||||
return glModal;
|
||||
};
|
||||
|
||||
const showModal = () => {
|
||||
wrapper.setProps({ needsCaptchaResponse: true });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
modal = findGlModal();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders', () => {
|
||||
expect(modal.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('assigns the modal a unique ID', () => {
|
||||
const firstInstanceModalId = modal.props('modalId');
|
||||
createComponent();
|
||||
const secondInstanceModalId = findGlModal().props('modalId');
|
||||
expect(firstInstanceModalId).not.toEqual(secondInstanceModalId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('functionality', () => {
|
||||
describe('when modal is shown', () => {
|
||||
describe('when initRecaptchaScript promise resolves successfully', () => {
|
||||
beforeEach(async () => {
|
||||
showModal();
|
||||
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
it('shows modal', async () => {
|
||||
expect(findGlModal().vm.show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders window.grecaptcha', () => {
|
||||
expect(grecaptcha.render).toHaveBeenCalledWith(wrapper.vm.$refs.captcha, {
|
||||
sitekey: captchaSiteKey,
|
||||
callback: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
describe('then the user solves the captcha', () => {
|
||||
const captchaResponse = 'a captcha response';
|
||||
|
||||
beforeEach(() => {
|
||||
// simulate the grecaptcha library invoking the callback
|
||||
const { callback } = grecaptcha.render.mock.calls[0][1];
|
||||
callback(captchaResponse);
|
||||
});
|
||||
|
||||
it('emits receivedCaptchaResponse exactly once with the captcha response', () => {
|
||||
expect(wrapper.emitted('receivedCaptchaResponse')).toEqual([[captchaResponse]]);
|
||||
});
|
||||
|
||||
it('hides modal with null trigger', async () => {
|
||||
// Assert that hide is called with zero args, so that we don't trigger the logic
|
||||
// for hiding the modal via cancel, esc, headerclose, etc, without a captcha response
|
||||
expect(modal.vm.hide).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe('then the user hides the modal without solving the captcha', () => {
|
||||
// Even though we don't explicitly check for these trigger values, these are the
|
||||
// currently supported ones which can be emitted.
|
||||
// See https://bootstrap-vue.org/docs/components/modal#prevent-closing
|
||||
describe.each`
|
||||
trigger | expected
|
||||
${'cancel'} | ${[[null]]}
|
||||
${'esc'} | ${[[null]]}
|
||||
${'backdrop'} | ${[[null]]}
|
||||
${'headerclose'} | ${[[null]]}
|
||||
`('using the $trigger trigger', ({ trigger, expected }) => {
|
||||
beforeEach(() => {
|
||||
const bvModalEvent = {
|
||||
trigger,
|
||||
};
|
||||
modal.vm.$emit('hide', bvModalEvent);
|
||||
});
|
||||
|
||||
it(`emits receivedCaptchaResponse with ${JSON.stringify(expected)}`, () => {
|
||||
expect(wrapper.emitted('receivedCaptchaResponse')).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when initRecaptchaScript promise rejects', () => {
|
||||
const fakeError = {};
|
||||
|
||||
beforeEach(() => {
|
||||
initRecaptchaScript.mockImplementation(() => Promise.reject(fakeError));
|
||||
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
showModal();
|
||||
});
|
||||
|
||||
it('emits receivedCaptchaResponse exactly once with null', () => {
|
||||
expect(wrapper.emitted('receivedCaptchaResponse')).toEqual([[null]]);
|
||||
});
|
||||
|
||||
it('hides modal with null trigger', async () => {
|
||||
// Assert that hide is called with zero args, so that we don't trigger the logic
|
||||
// for hiding the modal via cancel, esc, headerclose, etc, without a captcha response
|
||||
expect(modal.vm.hide).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('calls console.error with a message and the exception', () => {
|
||||
// eslint-disable-next-line no-console
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/exception.*captcha/),
|
||||
fakeError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -12,7 +12,7 @@ describe('initRecaptchaScript', () => {
|
|||
});
|
||||
|
||||
const getScriptOnload = () => window[RECAPTCHA_ONLOAD_CALLBACK_NAME];
|
||||
const triggerScriptOnload = (...args) => window[RECAPTCHA_ONLOAD_CALLBACK_NAME](...args);
|
||||
const triggerScriptOnload = () => window[RECAPTCHA_ONLOAD_CALLBACK_NAME]();
|
||||
|
||||
describe('when called', () => {
|
||||
let result;
|
||||
|
@ -37,13 +37,23 @@ describe('initRecaptchaScript', () => {
|
|||
expect(document.head.querySelectorAll('script').length).toBe(1);
|
||||
});
|
||||
|
||||
it('when onload is triggered, resolves promise', async () => {
|
||||
const instance = {};
|
||||
describe('when onload is triggered', () => {
|
||||
beforeEach(() => {
|
||||
window.grecaptcha = 'fake grecaptcha';
|
||||
triggerScriptOnload();
|
||||
});
|
||||
|
||||
triggerScriptOnload(instance);
|
||||
afterEach(() => {
|
||||
window.grecaptcha = undefined;
|
||||
});
|
||||
|
||||
await expect(result).resolves.toBe(instance);
|
||||
expect(getScriptOnload()).toBeUndefined();
|
||||
it('resolves promise with window.grecaptcha as argument', async () => {
|
||||
await expect(result).resolves.toBe(window.grecaptcha);
|
||||
});
|
||||
|
||||
it('sets window[RECAPTCHA_ONLOAD_CALLBACK_NAME] to undefined', async () => {
|
||||
expect(getScriptOnload()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { trimText } from 'helpers/text_helper';
|
||||
import { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji';
|
||||
import { glEmojiTag, searchEmoji, getEmoji } from '~/emoji';
|
||||
import { emojiFixtureMap, mockEmojiData, initEmojiMock } from 'helpers/emoji';
|
||||
import { glEmojiTag, searchEmoji, getEmojiInfo, sortEmoji } from '~/emoji';
|
||||
import isEmojiUnicodeSupported, {
|
||||
isFlagEmoji,
|
||||
isRainbowFlagEmoji,
|
||||
|
@ -29,7 +29,7 @@ const emptySupportMap = {
|
|||
1.1: false,
|
||||
};
|
||||
|
||||
describe('gl_emoji', () => {
|
||||
describe('emoji', () => {
|
||||
let mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
@ -43,7 +43,7 @@ describe('gl_emoji', () => {
|
|||
describe('glEmojiTag', () => {
|
||||
it('bomb emoji', () => {
|
||||
const emojiKey = 'bomb';
|
||||
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name);
|
||||
const markup = glEmojiTag(emojiKey);
|
||||
|
||||
expect(trimText(markup)).toMatchInlineSnapshot(
|
||||
`"<gl-emoji data-name=\\"bomb\\"></gl-emoji>"`,
|
||||
|
@ -52,7 +52,7 @@ describe('gl_emoji', () => {
|
|||
|
||||
it('bomb emoji with sprite fallback readiness', () => {
|
||||
const emojiKey = 'bomb';
|
||||
const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, {
|
||||
const markup = glEmojiTag(emojiKey, {
|
||||
sprite: true,
|
||||
});
|
||||
expect(trimText(markup)).toMatchInlineSnapshot(
|
||||
|
@ -352,125 +352,272 @@ describe('gl_emoji', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getEmoji', () => {
|
||||
const { grey_question } = emojiFixtureMap;
|
||||
describe('getEmojiInfo', () => {
|
||||
it.each(['atom', 'five', 'black_heart'])("should return a correct emoji for '%s'", (name) => {
|
||||
expect(getEmojiInfo(name)).toEqual(mockEmojiData[name]);
|
||||
});
|
||||
|
||||
it('should return fallback emoji by default', () => {
|
||||
expect(getEmojiInfo('atjs')).toEqual(mockEmojiData.grey_question);
|
||||
});
|
||||
|
||||
it('should return null when fallback is false', () => {
|
||||
expect(getEmojiInfo('atjs', false)).toBe(null);
|
||||
});
|
||||
|
||||
describe('when query is undefined', () => {
|
||||
it('should return null by default', () => {
|
||||
expect(getEmoji()).toBe(null);
|
||||
it('should return fallback emoji by default', () => {
|
||||
expect(getEmojiInfo()).toEqual(mockEmojiData.grey_question);
|
||||
});
|
||||
|
||||
it('should return fallback emoji when fallback is true', () => {
|
||||
expect(getEmoji(undefined, true).name).toEqual(grey_question.name);
|
||||
it('should return null when fallback is false', () => {
|
||||
expect(getEmojiInfo(undefined, false)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEmoji', () => {
|
||||
const { atom, grey_question } = emojiFixtureMap;
|
||||
const search = (query, opts) => searchEmoji(query, opts).map(({ name }) => name);
|
||||
const mangle = (str) => str.slice(0, 1) + str.slice(-1);
|
||||
const partial = (str) => str.slice(0, 2);
|
||||
const emojiFixture = Object.keys(mockEmojiData).reduce((acc, k) => {
|
||||
const { name, e, u, d } = mockEmojiData[k];
|
||||
acc[k] = { name, e, u, d };
|
||||
|
||||
describe('with default options', () => {
|
||||
const subject = (query) => search(query);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
describeEmojiFields('with $field', ({ accessor }) => {
|
||||
it(`should match by lower case: ${accessor(atom)}`, () => {
|
||||
expect(subject(accessor(atom))).toContain(atom.name);
|
||||
});
|
||||
it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => {
|
||||
const search = searchEmoji(input);
|
||||
|
||||
it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => {
|
||||
expect(subject(accessor(atom).toUpperCase())).toContain(atom.name);
|
||||
});
|
||||
|
||||
it(`should not match by partial: ${mangle(accessor(atom))}`, () => {
|
||||
expect(subject(mangle(accessor(atom)))).not.toContain(atom.name);
|
||||
});
|
||||
const expected = [
|
||||
'atom',
|
||||
'bomb',
|
||||
'construction_worker_tone5',
|
||||
'five',
|
||||
'grey_question',
|
||||
'black_heart',
|
||||
'heart',
|
||||
'custard',
|
||||
'star',
|
||||
].map((name) => {
|
||||
return {
|
||||
emoji: emojiFixture[name],
|
||||
field: 'd',
|
||||
fieldValue: emojiFixture[name].d,
|
||||
score: 0,
|
||||
};
|
||||
});
|
||||
|
||||
it(`should match by unicode value: ${atom.moji}`, () => {
|
||||
expect(subject(atom.moji)).toContain(atom.name);
|
||||
});
|
||||
|
||||
it('should not return a fallback value', () => {
|
||||
expect(subject('foo bar baz')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not return a fallback value when query is falsey', () => {
|
||||
expect(subject()).toHaveLength(0);
|
||||
});
|
||||
expect(search).toEqual(expected);
|
||||
});
|
||||
|
||||
describe('with fuzzy match', () => {
|
||||
const subject = (query) => search(query, { match: 'fuzzy' });
|
||||
it.each([
|
||||
[
|
||||
'searching by unicode value',
|
||||
'⚛',
|
||||
[
|
||||
{
|
||||
name: 'atom',
|
||||
field: 'e',
|
||||
fieldValue: 'atom',
|
||||
score: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'searching by partial alias',
|
||||
'_symbol',
|
||||
[
|
||||
{
|
||||
name: 'atom',
|
||||
field: 'alias',
|
||||
fieldValue: 'atom_symbol',
|
||||
score: 4,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'searching by full alias',
|
||||
'atom_symbol',
|
||||
[
|
||||
{
|
||||
name: 'atom',
|
||||
field: 'alias',
|
||||
fieldValue: 'atom_symbol',
|
||||
score: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
])('should return a correct result when %s', (_, query, searchResult) => {
|
||||
const expected = searchResult.map((item) => {
|
||||
const { field, score, fieldValue, name } = item;
|
||||
|
||||
describeEmojiFields('with $field', ({ accessor }) => {
|
||||
it(`should match by lower case: ${accessor(atom)}`, () => {
|
||||
expect(subject(accessor(atom))).toContain(atom.name);
|
||||
});
|
||||
|
||||
it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => {
|
||||
expect(subject(accessor(atom).toUpperCase())).toContain(atom.name);
|
||||
});
|
||||
|
||||
it(`should match by partial: ${mangle(accessor(atom))}`, () => {
|
||||
expect(subject(mangle(accessor(atom)))).toContain(atom.name);
|
||||
});
|
||||
return {
|
||||
emoji: emojiFixture[name],
|
||||
field,
|
||||
fieldValue,
|
||||
score,
|
||||
};
|
||||
});
|
||||
|
||||
expect(searchEmoji(query)).toEqual(expected);
|
||||
});
|
||||
|
||||
describe('with contains match', () => {
|
||||
const subject = (query) => search(query, { match: 'contains' });
|
||||
it.each([
|
||||
['searching with a non-existing emoji name', 'asdf', []],
|
||||
[
|
||||
'searching by full name',
|
||||
'atom',
|
||||
[
|
||||
{
|
||||
name: 'atom',
|
||||
field: 'd',
|
||||
score: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
describeEmojiFields('with $field', ({ accessor }) => {
|
||||
it(`should match by lower case: ${accessor(atom)}`, () => {
|
||||
expect(subject(accessor(atom))).toContain(atom.name);
|
||||
});
|
||||
[
|
||||
'searching by full description',
|
||||
'atom symbol',
|
||||
[
|
||||
{
|
||||
name: 'atom',
|
||||
field: 'd',
|
||||
score: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => {
|
||||
expect(subject(accessor(atom).toUpperCase())).toContain(atom.name);
|
||||
});
|
||||
[
|
||||
'searching by partial name',
|
||||
'question',
|
||||
[
|
||||
{
|
||||
name: 'grey_question',
|
||||
field: 'name',
|
||||
score: 5,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'searching by partial description',
|
||||
'ment',
|
||||
[
|
||||
{
|
||||
name: 'grey_question',
|
||||
field: 'd',
|
||||
score: 24,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'searching with query "heart"',
|
||||
'heart',
|
||||
[
|
||||
{
|
||||
name: 'black_heart',
|
||||
field: 'd',
|
||||
score: 6,
|
||||
},
|
||||
{
|
||||
name: 'heart',
|
||||
field: 'name',
|
||||
score: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'searching with query "HEART"',
|
||||
'HEART',
|
||||
[
|
||||
{
|
||||
name: 'black_heart',
|
||||
field: 'd',
|
||||
score: 6,
|
||||
},
|
||||
{
|
||||
name: 'heart',
|
||||
field: 'name',
|
||||
score: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'searching with query "star"',
|
||||
'star',
|
||||
[
|
||||
{
|
||||
name: 'custard',
|
||||
field: 'd',
|
||||
score: 2,
|
||||
},
|
||||
{
|
||||
name: 'star',
|
||||
field: 'name',
|
||||
score: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
])('should return a correct result when %s', (_, query, searchResult) => {
|
||||
const expected = searchResult.map((item) => {
|
||||
const { field, score, name } = item;
|
||||
|
||||
it(`should match by partial: ${partial(accessor(atom))}`, () => {
|
||||
expect(subject(partial(accessor(atom)))).toContain(atom.name);
|
||||
});
|
||||
|
||||
it(`should not match by mangled: ${mangle(accessor(atom))}`, () => {
|
||||
expect(subject(mangle(accessor(atom)))).not.toContain(atom.name);
|
||||
});
|
||||
return {
|
||||
emoji: emojiFixture[name],
|
||||
field,
|
||||
fieldValue: emojiFixture[name][field],
|
||||
score,
|
||||
};
|
||||
});
|
||||
|
||||
expect(searchEmoji(query)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with fallback', () => {
|
||||
const subject = (query) => search(query, { fallback: true });
|
||||
describe('sortEmoji', () => {
|
||||
const testCases = [
|
||||
[
|
||||
'should correctly sort by score',
|
||||
[
|
||||
{ score: 10, fieldValue: '', emoji: { name: 'a' } },
|
||||
{ score: 5, fieldValue: '', emoji: { name: 'b' } },
|
||||
{ score: 0, fieldValue: '', emoji: { name: 'c' } },
|
||||
],
|
||||
[
|
||||
{ score: 0, fieldValue: '', emoji: { name: 'c' } },
|
||||
{ score: 5, fieldValue: '', emoji: { name: 'b' } },
|
||||
{ score: 10, fieldValue: '', emoji: { name: 'a' } },
|
||||
],
|
||||
],
|
||||
[
|
||||
'should correctly sort by fieldValue',
|
||||
[
|
||||
{ score: 0, fieldValue: 'y', emoji: { name: 'b' } },
|
||||
{ score: 0, fieldValue: 'x', emoji: { name: 'a' } },
|
||||
{ score: 0, fieldValue: 'z', emoji: { name: 'c' } },
|
||||
],
|
||||
[
|
||||
{ score: 0, fieldValue: 'x', emoji: { name: 'a' } },
|
||||
{ score: 0, fieldValue: 'y', emoji: { name: 'b' } },
|
||||
{ score: 0, fieldValue: 'z', emoji: { name: 'c' } },
|
||||
],
|
||||
],
|
||||
[
|
||||
'should correctly sort by score and then by fieldValue (in order)',
|
||||
[
|
||||
{ score: 5, fieldValue: 'y', emoji: { name: 'c' } },
|
||||
{ score: 0, fieldValue: 'z', emoji: { name: 'a' } },
|
||||
{ score: 5, fieldValue: 'x', emoji: { name: 'b' } },
|
||||
],
|
||||
[
|
||||
{ score: 0, fieldValue: 'z', emoji: { name: 'a' } },
|
||||
{ score: 5, fieldValue: 'x', emoji: { name: 'b' } },
|
||||
{ score: 5, fieldValue: 'y', emoji: { name: 'c' } },
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
it.each`
|
||||
query
|
||||
${'foo bar baz'} | ${undefined}
|
||||
`('should return a fallback value when given $query', ({ query }) => {
|
||||
expect(subject(query)).toContain(grey_question.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with name and alias fields', () => {
|
||||
const subject = (query) => search(query, { fields: ['name', 'alias'] });
|
||||
|
||||
it(`should match by name: ${atom.name}`, () => {
|
||||
expect(subject(atom.name)).toContain(atom.name);
|
||||
});
|
||||
|
||||
it(`should match by alias: ${atom.aliases[0]}`, () => {
|
||||
expect(subject(atom.aliases[0])).toContain(atom.name);
|
||||
});
|
||||
|
||||
it(`should not match by description: ${atom.description}`, () => {
|
||||
expect(subject(atom.description)).not.toContain(atom.name);
|
||||
});
|
||||
|
||||
it(`should not match by unicode value: ${atom.moji}`, () => {
|
||||
expect(subject(atom.moji)).not.toContain(atom.name);
|
||||
});
|
||||
it.each(testCases)('%s', (_, scoredItems, expected) => {
|
||||
expect(sortEmoji(scoredItems)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import $ from 'jquery';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji';
|
||||
import { initEmojiMock } from 'helpers/emoji';
|
||||
import '~/lib/utils/jquery_at_who';
|
||||
import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete';
|
||||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
|
@ -714,16 +714,20 @@ describe('GfmAutoComplete', () => {
|
|||
});
|
||||
|
||||
describe('emoji', () => {
|
||||
const { atom, heart, star } = emojiFixtureMap;
|
||||
const assertInserted = ({ input, subject, emoji }) =>
|
||||
expect(subject).toBe(`:${emoji?.name || input}:`);
|
||||
const assertTemplated = ({ input, subject, emoji, field }) =>
|
||||
expect(subject.replace(/\s+/g, ' ')).toBe(
|
||||
`<li>${field || input} <gl-emoji data-name="${emoji?.name || input}"></gl-emoji> </li>`,
|
||||
);
|
||||
|
||||
let mock;
|
||||
|
||||
const mockItem = {
|
||||
'atwho-at': ':',
|
||||
emoji: {
|
||||
c: 'symbols',
|
||||
d: 'negative squared ab',
|
||||
e: '🆎',
|
||||
name: 'ab',
|
||||
u: '6.0',
|
||||
},
|
||||
fieldValue: 'ab',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await initEmojiMock();
|
||||
|
||||
|
@ -735,90 +739,22 @@ describe('GfmAutoComplete', () => {
|
|||
mock.restore();
|
||||
});
|
||||
|
||||
describe.each`
|
||||
name | inputFormat | assert
|
||||
${'insertTemplateFunction'} | ${(name) => ({ name })} | ${assertInserted}
|
||||
${'templateFunction'} | ${(name) => name} | ${assertTemplated}
|
||||
`('Emoji.$name', ({ name, inputFormat, assert }) => {
|
||||
const execute = (accessor, input, emoji) =>
|
||||
assert({
|
||||
input,
|
||||
emoji,
|
||||
field: accessor && accessor(emoji),
|
||||
subject: GfmAutoComplete.Emoji[name](inputFormat(input)),
|
||||
});
|
||||
describe('Emoji.templateFunction', () => {
|
||||
it('should return a correct template', () => {
|
||||
const actual = GfmAutoComplete.Emoji.templateFunction(mockItem);
|
||||
const glEmojiTag = `<gl-emoji data-name="${mockItem.emoji.name}"></gl-emoji>`;
|
||||
const expected = `<li>${mockItem.fieldValue} ${glEmojiTag}</li>`;
|
||||
|
||||
describeEmojiFields('for $field', ({ accessor }) => {
|
||||
it('should work with lowercase', () => {
|
||||
execute(accessor, accessor(atom), atom);
|
||||
});
|
||||
|
||||
it('should work with uppercase', () => {
|
||||
execute(accessor, accessor(atom).toUpperCase(), atom);
|
||||
});
|
||||
|
||||
it('should work with partial value', () => {
|
||||
execute(accessor, accessor(atom).slice(1), atom);
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with unicode value', () => {
|
||||
execute(null, atom.moji, atom);
|
||||
});
|
||||
|
||||
it('should pass through unknown value', () => {
|
||||
execute(null, 'foo bar baz');
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
const expectEmojiOrder = (first, second) => {
|
||||
const keys = Object.keys(emojiFixtureMap);
|
||||
const firstIndex = keys.indexOf(first);
|
||||
const secondIndex = keys.indexOf(second);
|
||||
expect(firstIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(secondIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(firstIndex).toBeLessThan(secondIndex);
|
||||
};
|
||||
|
||||
describe('Emoji.insertTemplateFunction', () => {
|
||||
it('should map ":heart" to :heart: [regression]', () => {
|
||||
// the bug mapped heart to black_heart because the latter sorted first
|
||||
expectEmojiOrder('black_heart', 'heart');
|
||||
it('should return a correct template', () => {
|
||||
const actual = GfmAutoComplete.Emoji.insertTemplateFunction(mockItem);
|
||||
const expected = `:${mockItem.emoji.name}:`;
|
||||
|
||||
const item = GfmAutoComplete.Emoji.insertTemplateFunction({ name: 'heart' });
|
||||
expect(item).toEqual(`:${heart.name}:`);
|
||||
});
|
||||
|
||||
it('should map ":star" to :star: [regression]', () => {
|
||||
// the bug mapped star to custard because the latter sorted first
|
||||
expectEmojiOrder('custard', 'star');
|
||||
|
||||
const item = GfmAutoComplete.Emoji.insertTemplateFunction({ name: 'star' });
|
||||
expect(item).toEqual(`:${star.name}:`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Emoji.templateFunction', () => {
|
||||
it('should map ":heart" to ❤ [regression]', () => {
|
||||
// the bug mapped heart to black_heart because the latter sorted first
|
||||
expectEmojiOrder('black_heart', 'heart');
|
||||
|
||||
const item = GfmAutoComplete.Emoji.templateFunction('heart')
|
||||
.replace(/(<gl-emoji)\s+(data-name)/, '$1 $2')
|
||||
.replace(/>\s+|\s+</g, (s) => s.trim());
|
||||
expect(item).toEqual(
|
||||
`<li>${heart.name}<gl-emoji data-name="${heart.name}"></gl-emoji></li>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map ":star" to ⭐ [regression]', () => {
|
||||
// the bug mapped star to custard because the latter sorted first
|
||||
expectEmojiOrder('custard', 'star');
|
||||
|
||||
const item = GfmAutoComplete.Emoji.templateFunction('star')
|
||||
.replace(/(<gl-emoji)\s+(data-name)/, '$1 $2')
|
||||
.replace(/>\s+|\s+</g, (s) => s.trim());
|
||||
expect(item).toEqual(`<li>${star.name}<gl-emoji data-name="${star.name}"></gl-emoji></li>`);
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import VueApollo, { ApolloMutation } from 'vue-apollo';
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import { stubComponent } from 'helpers/stub_component';
|
||||
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
|
||||
import { deprecatedCreateFlash as Flash } from '~/flash';
|
||||
import * as urlUtils from '~/lib/utils/url_utility';
|
||||
import SnippetEditApp from '~/snippets/components/edit.vue';
|
||||
import CaptchaModal from '~/captcha/captcha_modal.vue';
|
||||
import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
|
||||
import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue';
|
||||
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
|
||||
|
@ -54,6 +57,7 @@ const createTestSnippet = () => ({
|
|||
describe('Snippet Edit app', () => {
|
||||
let wrapper;
|
||||
let fakeApollo;
|
||||
const captchaSiteKey = 'abc123';
|
||||
const relativeUrlRoot = '/foo/';
|
||||
const originalRelativeUrlRoot = gon.relative_url_root;
|
||||
const GetSnippetQuerySpy = jest.fn().mockResolvedValue({
|
||||
|
@ -66,6 +70,8 @@ describe('Snippet Edit app', () => {
|
|||
updateSnippet: {
|
||||
errors: [],
|
||||
snippet: createTestSnippet(),
|
||||
needsCaptchaResponse: null,
|
||||
captchaSiteKey: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
@ -74,13 +80,51 @@ describe('Snippet Edit app', () => {
|
|||
updateSnippet: {
|
||||
errors: [TEST_MUTATION_ERROR],
|
||||
snippet: createTestSnippet(),
|
||||
needsCaptchaResponse: null,
|
||||
captchaSiteKey: null,
|
||||
},
|
||||
createSnippet: {
|
||||
errors: [TEST_MUTATION_ERROR],
|
||||
snippet: null,
|
||||
needsCaptchaResponse: null,
|
||||
captchaSiteKey: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
// TODO: QUESTION - This has to be wrapped in a factory function in order for the mock to have
|
||||
// the `mockResolvedValueOnce` counter properly cleared/reset between test `it` examples, by
|
||||
// ensuring each one gets a fresh mock instance. It's apparently impossible/hard to manually
|
||||
// clear/reset them (see https://github.com/facebook/jest/issues/7136). So, should
|
||||
// we convert all the others to factory functions too, to be consistent? And/or move the whole
|
||||
// `mutationTypes` declaration into a `beforeEach`? (not sure if that will still solve the
|
||||
// mock reset problem though).
|
||||
RESOLVE_WITH_NEEDS_CAPTCHA_RESPONSE: () =>
|
||||
jest
|
||||
.fn()
|
||||
// NOTE: There may be a captcha-related error, but it is not used in the GraphQL/Vue flow,
|
||||
// only a truthy 'needsCaptchaResponse' value is used to trigger the captcha modal showing.
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
createSnippet: {
|
||||
errors: ['ignored captcha error message'],
|
||||
snippet: null,
|
||||
needsCaptchaResponse: true,
|
||||
captchaSiteKey,
|
||||
},
|
||||
},
|
||||
})
|
||||
// After the captcha is solved and the modal is closed, the second form submission should
|
||||
// be successful and return needsCaptchaResponse = false.
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
createSnippet: {
|
||||
errors: ['ignored captcha error message'],
|
||||
snippet: createTestSnippet(),
|
||||
needsCaptchaResponse: false,
|
||||
captchaSiteKey: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
REJECT: jest.fn().mockRejectedValue(TEST_API_ERROR),
|
||||
};
|
||||
|
||||
|
@ -119,6 +163,7 @@ describe('Snippet Edit app', () => {
|
|||
stubs: {
|
||||
ApolloMutation,
|
||||
FormFooterActions,
|
||||
CaptchaModal: stubComponent(CaptchaModal),
|
||||
},
|
||||
provide: {
|
||||
selectedLevel,
|
||||
|
@ -144,6 +189,7 @@ describe('Snippet Edit app', () => {
|
|||
});
|
||||
|
||||
const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit);
|
||||
const findCaptchaModal = () => wrapper.find(CaptchaModal);
|
||||
const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]');
|
||||
const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
|
||||
const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled'));
|
||||
|
@ -167,6 +213,8 @@ describe('Snippet Edit app', () => {
|
|||
visibilityLevel,
|
||||
blobActions: [],
|
||||
});
|
||||
const setTitle = (val) => wrapper.find(TitleField).vm.$emit('input', val);
|
||||
const setDescription = (val) => wrapper.find(SnippetDescriptionEdit).vm.$emit('input', val);
|
||||
|
||||
// Ideally we wouldn't call this method directly, but we don't have a way to trigger
|
||||
// apollo responses yet.
|
||||
|
@ -194,6 +242,7 @@ describe('Snippet Edit app', () => {
|
|||
(props) => {
|
||||
createComponent(props);
|
||||
|
||||
expect(wrapper.find(CaptchaModal).exists()).toBe(true);
|
||||
expect(wrapper.find(TitleField).exists()).toBe(true);
|
||||
expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
|
||||
expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
|
||||
|
@ -218,7 +267,7 @@ describe('Snippet Edit app', () => {
|
|||
loadSnippet({ title });
|
||||
triggerBlobActions(actions);
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(hasDisabledSubmit()).toBe(shouldDisable);
|
||||
},
|
||||
|
@ -239,7 +288,7 @@ describe('Snippet Edit app', () => {
|
|||
|
||||
loadSnippet(...snippetArg);
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(findCancelButton().attributes('href')).toBe(expectation);
|
||||
},
|
||||
|
@ -251,7 +300,7 @@ describe('Snippet Edit app', () => {
|
|||
createComponent({ props: { snippetGid: '' }, withApollo: true });
|
||||
|
||||
jest.runOnlyPendingTimers();
|
||||
await wrapper.vm.$nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(GetSnippetQuerySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
@ -288,7 +337,7 @@ describe('Snippet Edit app', () => {
|
|||
loadSnippet(...snippetArg);
|
||||
setUploadFilesHtml(uploadedFiles);
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await nextTick();
|
||||
|
||||
clickSubmitBtn();
|
||||
|
||||
|
@ -338,14 +387,109 @@ describe('Snippet Edit app', () => {
|
|||
expect(Flash).toHaveBeenCalledWith(expectMessage);
|
||||
},
|
||||
);
|
||||
|
||||
describe('when needsCaptchaResponse is true', () => {
|
||||
let modal;
|
||||
let captchaResponse;
|
||||
let mutationRes;
|
||||
|
||||
beforeEach(async () => {
|
||||
mutationRes = mutationTypes.RESOLVE_WITH_NEEDS_CAPTCHA_RESPONSE();
|
||||
createComponent({
|
||||
props: {
|
||||
snippetGid: '',
|
||||
projectPath: '',
|
||||
},
|
||||
mutationRes,
|
||||
});
|
||||
// await waitForPromises();
|
||||
modal = findCaptchaModal();
|
||||
|
||||
loadSnippet();
|
||||
|
||||
clickSubmitBtn();
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('should display captcha modal', () => {
|
||||
expect(urlUtils.redirectTo).not.toHaveBeenCalled();
|
||||
expect(modal.props('needsCaptchaResponse')).toEqual(true);
|
||||
expect(modal.props('captchaSiteKey')).toEqual(captchaSiteKey);
|
||||
});
|
||||
|
||||
describe('when a non-empty captcha response is received', () => {
|
||||
beforeEach(() => {
|
||||
captchaResponse = 'xyz123';
|
||||
});
|
||||
|
||||
it('sets needsCaptchaResponse to false', async () => {
|
||||
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
|
||||
await nextTick();
|
||||
expect(modal.props('needsCaptchaResponse')).toEqual(false);
|
||||
});
|
||||
|
||||
it('resubmits form with captchaResponse', async () => {
|
||||
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
|
||||
await nextTick();
|
||||
expect(mutationRes.mock.calls[1][0]).toEqual({
|
||||
mutation: CreateSnippetMutation,
|
||||
variables: {
|
||||
input: {
|
||||
...getApiData(),
|
||||
captchaResponse,
|
||||
projectPath: '',
|
||||
uploadedFiles: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when an empty captcha response is received ', () => {
|
||||
beforeEach(() => {
|
||||
captchaResponse = '';
|
||||
});
|
||||
|
||||
it('sets needsCaptchaResponse to false', async () => {
|
||||
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
|
||||
await nextTick();
|
||||
expect(modal.props('needsCaptchaResponse')).toEqual(false);
|
||||
});
|
||||
|
||||
it('does not resubmit form', async () => {
|
||||
modal.vm.$emit('receivedCaptchaResponse', captchaResponse);
|
||||
await nextTick();
|
||||
expect(mutationRes.mock.calls.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('on before unload', () => {
|
||||
const caseNoActions = () => triggerBlobActions([]);
|
||||
const caseEmptyAction = () => triggerBlobActions([testEntries.empty.diff]);
|
||||
const caseSomeActions = () => triggerBlobActions([testEntries.updated.diff]);
|
||||
const caseTitleIsSet = () => {
|
||||
caseEmptyAction();
|
||||
setTitle('test');
|
||||
};
|
||||
const caseDescriptionIsSet = () => {
|
||||
caseEmptyAction();
|
||||
setDescription('test');
|
||||
};
|
||||
const caseClickSubmitBtn = () => {
|
||||
caseSomeActions();
|
||||
clickSubmitBtn();
|
||||
};
|
||||
|
||||
it.each`
|
||||
condition | expectPrevented | action
|
||||
${'there are no actions'} | ${false} | ${() => triggerBlobActions([])}
|
||||
${'there are actions'} | ${true} | ${() => triggerBlobActions([testEntries.updated.diff])}
|
||||
${'the snippet is being saved'} | ${false} | ${() => clickSubmitBtn()}
|
||||
${'there are no actions'} | ${false} | ${caseNoActions}
|
||||
${'there is an empty action'} | ${false} | ${caseEmptyAction}
|
||||
${'there are actions'} | ${true} | ${caseSomeActions}
|
||||
${'the title is set'} | ${true} | ${caseTitleIsSet}
|
||||
${'the description is set'} | ${true} | ${caseDescriptionIsSet}
|
||||
${'the snippet is being saved'} | ${false} | ${caseClickSubmitBtn}
|
||||
`(
|
||||
'handles before unload prevent when $condition (expectPrevented=$expectPrevented)',
|
||||
({ expectPrevented, action }) => {
|
||||
|
|
|
@ -56,6 +56,15 @@ export const testEntries = {
|
|||
content: CONTENT_2,
|
||||
},
|
||||
},
|
||||
empty: {
|
||||
id: 'empty',
|
||||
diff: {
|
||||
action: SNIPPET_BLOB_ACTION_CREATE,
|
||||
filePath: '',
|
||||
previousPath: '',
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createBlobFromTestEntry = ({ diff, origContent }, isOrig = false) => ({
|
||||
|
|
|
@ -18,13 +18,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
|
|||
class="award-emoji-block"
|
||||
data-testid="award-html"
|
||||
>
|
||||
|
||||
|
||||
<gl-emoji
|
||||
data-name="thumbsup"
|
||||
/>
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
@ -52,13 +48,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
|
|||
class="award-emoji-block"
|
||||
data-testid="award-html"
|
||||
>
|
||||
|
||||
|
||||
<gl-emoji
|
||||
data-name="thumbsdown"
|
||||
/>
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
@ -86,13 +78,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
|
|||
class="award-emoji-block"
|
||||
data-testid="award-html"
|
||||
>
|
||||
|
||||
|
||||
<gl-emoji
|
||||
data-name="smile"
|
||||
/>
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
@ -120,13 +108,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
|
|||
class="award-emoji-block"
|
||||
data-testid="award-html"
|
||||
>
|
||||
|
||||
|
||||
<gl-emoji
|
||||
data-name="ok_hand"
|
||||
/>
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
@ -154,13 +138,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
|
|||
class="award-emoji-block"
|
||||
data-testid="award-html"
|
||||
>
|
||||
|
||||
|
||||
<gl-emoji
|
||||
data-name="cactus"
|
||||
/>
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
@ -188,13 +168,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
|
|||
class="award-emoji-block"
|
||||
data-testid="award-html"
|
||||
>
|
||||
|
||||
|
||||
<gl-emoji
|
||||
data-name="a"
|
||||
/>
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
@ -222,13 +198,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
|
|||
class="award-emoji-block"
|
||||
data-testid="award-html"
|
||||
>
|
||||
|
||||
|
||||
<gl-emoji
|
||||
data-name="b"
|
||||
/>
|
||||
|
||||
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`gfm_autocomplete/utils emojis config shows the emoji name and icon in the menu item 1`] = `
|
||||
"raised_hands
|
||||
<gl-emoji
|
||||
|
||||
data-name=\\"raised_hands\\"></gl-emoji>
|
||||
"
|
||||
`;
|
||||
exports[`gfm_autocomplete/utils emojis config shows the emoji name and icon in the menu item 1`] = `"raised_hands <gl-emoji data-name=\\"raised_hands\\"></gl-emoji>"`;
|
||||
|
||||
exports[`gfm_autocomplete/utils issues config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context issue title <script>alert('hi')</script>"`;
|
||||
|
||||
|
|
Loading…
Reference in a new issue