diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 288acd1b2f1..ab95e00b740 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -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, diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js index 0553990ecd8..65a6d1388e7 100644 --- a/app/assets/javascripts/behaviors/gl_emoji.js +++ b/app/assets/javascripts/behaviors/gl_emoji.js @@ -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 provided, use the instead } diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue new file mode 100644 index 00000000000..00ea06db0cf --- /dev/null +++ b/app/assets/javascripts/captcha/captcha_modal.vue @@ -0,0 +1,110 @@ + + + + + + + + {{ __('We want to be sure it is you, please confirm you are not a robot.') }} + + diff --git a/app/assets/javascripts/captcha/init_recaptcha_script.js b/app/assets/javascripts/captcha/init_recaptcha_script.js index b9df7604ed1..f546eef7d84 100644 --- a/app/assets/javascripts/captcha/init_recaptcha_script.js +++ b/app/assets/javascripts/captcha/init_recaptcha_script.js @@ -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(); }); diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 8deb6f59e5d..73820b4e429 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -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 ` - - `; + return ``; } diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index febb108ec71..949540d38d4 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -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 " is of ", 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 " is the of " - 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 `${name}`; - - const results = findEmoji(name); - if (!results.length) { - return `${name} ${GfmAutoComplete.glEmojiTag(name)}`; + templateFunction(item) { + if (GfmAutoComplete.isLoading(item)) { + return GfmAutoComplete.Loading.template; } - const { field, emoji } = results[0]; - return `${field} ${GfmAutoComplete.glEmojiTag(emoji.name)}`; + const escapedFieldValue = escape(item.fieldValue); + if (!GfmAutoComplete.glEmojiTag) { + return `${escapedFieldValue}`; + } + + return `${escapedFieldValue} ${GfmAutoComplete.glEmojiTag(item.emoji.name)}`; + }, + 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 diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index ffb5e242973..629f9b03255 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -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; + } + }, }, }; @@ -190,6 +242,11 @@ export default { class="loading-animation prepend-top-20 gl-mb-6" /> + li { .reverse-sort-btn { color: $gl-text-color-secondary; + + &.disabled { + color: $gl-text-color-disabled; + } } } diff --git a/changelogs/unreleased/262102-fix-stay-on-page-alert-showing-in-empty-snippet.yml b/changelogs/unreleased/262102-fix-stay-on-page-alert-showing-in-empty-snippet.yml new file mode 100644 index 00000000000..abec645e57d --- /dev/null +++ b/changelogs/unreleased/262102-fix-stay-on-page-alert-showing-in-empty-snippet.yml @@ -0,0 +1,5 @@ +--- +title: Fix "Stay on Page" alert showing in empty snippet +merge_request: 50400 +author: Kev @KevSlashNull +type: fixed diff --git a/changelogs/unreleased/gfm-emoji-refactor.yml b/changelogs/unreleased/gfm-emoji-refactor.yml new file mode 100644 index 00000000000..4825b3f29fe --- /dev/null +++ b/changelogs/unreleased/gfm-emoji-refactor.yml @@ -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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index fef84af888f..1d246e9ee25 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js index ea6613b53c9..c8bcbee7ea5 100644 --- a/spec/frontend/__helpers__/emoji.js +++ b/spec/frontend/__helpers__/emoji.js @@ -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); -} diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index e9482ffbd3d..4f7b8cce949 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -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(); diff --git a/spec/frontend/captcha/captcha_modal_spec.js b/spec/frontend/captcha/captcha_modal_spec.js new file mode 100644 index 00000000000..d8dce87a97e --- /dev/null +++ b/spec/frontend/captcha/captcha_modal_spec.js @@ -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, + ); + }); + }); + }); + }); +}); diff --git a/spec/frontend/captcha/init_recaptcha_script_spec.js b/spec/frontend/captcha/init_recaptcha_script_spec.js index df114821651..af07c9e474e 100644 --- a/spec/frontend/captcha/init_recaptcha_script_spec.js +++ b/spec/frontend/captcha/init_recaptcha_script_spec.js @@ -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(); + }); }); }); }); diff --git a/spec/frontend/emoji/emoji_spec.js b/spec/frontend/emoji/index_spec.js similarity index 59% rename from spec/frontend/emoji/emoji_spec.js rename to spec/frontend/emoji/index_spec.js index feec445bc8d..e5e37483a5a 100644 --- a/spec/frontend/emoji/emoji_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -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( `""`, @@ -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); }); }); }); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index aefd465532a..df781dc9467 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -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( - `${field || input} `, - ); - 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 = ``; + const expected = `${mockItem.fieldValue} ${glEmojiTag}`; - 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(/(\s+|\s+ s.trim()); - expect(item).toEqual( - `${heart.name}`, - ); - }); - - 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(/(\s+|\s+ s.trim()); - expect(item).toEqual(`${star.name}`); + expect(actual).toBe(expected); }); }); }); diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index b818f98efb1..1c06465907a 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -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 }) => { diff --git a/spec/frontend/snippets/test_utils.js b/spec/frontend/snippets/test_utils.js index 86262723157..fd389620d35 100644 --- a/spec/frontend/snippets/test_utils.js +++ b/spec/frontend/snippets/test_utils.js @@ -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) => ({ diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index 20ea897e29c..3be609f0dad 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -18,13 +18,9 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` class="award-emoji-block" data-testid="award-html" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - " -`; +exports[`gfm_autocomplete/utils emojis config shows the emoji name and icon in the menu item 1`] = `"raised_hands "`; exports[`gfm_autocomplete/utils issues config shows the iid and title in the menu item within a project context 1`] = `"123456 Project context issue title <script>alert('hi')</script>"`;
{{ __('We want to be sure it is you, please confirm you are not a robot.') }}