FIX: code "block" detection before showing autocomplete (#26023)

**TL;DR:** Refactor autocomplete to use async markdown parsing for code block detection.

Previously, the `inCodeBlock` function in `discourse/app/lib/utilities.js` used regular expressions to determine if a given position in the text was inside a code block. This approach had some limitations and could lead to incorrect behavior in certain edge cases.

This commit refactors `inCodeBlock` to use a more robust algorithm that leverages Discourse's markdown parsing library.

The new approach works as follows:

1. Check if the text contains any code block markers using a regular expression.
   If not, return `false` since the cursor can't be in a code block.
1. If potential code blocks exist, find a unique marker character that doesn't appear in the text.
1. Insert the unique marker character into the text at the cursor position.
1. Parse the modified text using Discourse's markdown parser, which converts the markdown into a tree of tokens.
1. Traverse the token tree to find the token that contains the unique marker character.
1. Check if the token's type is one of the types representing code blocks ("code_inline", "code_block", or "fence").
   If so, return `true`, indicating that the cursor is inside a code block.
   Otherwise, return `false`.

This algorithm provides a more accurate way to determine the cursor's position in relation to code blocks, accounting for the various ways code blocks can be represented in markdown.

To accommodate this change, the autocomplete `triggerRule` option is now an async function.

The autocomplete logic in `composer-editor.js`, `d-editor.js`, and `hashtag-autocomplete.js` has been updated to handle the async nature of `inCodeBlock`.

Additionally, many of the tests have been refactored to handle async behavior. The test helpers now simulate typing and autocomplete selection in a more realistic, step-by-step manner. This should make the tests more robust and reflective of real-world usage.

This is a significant refactor that touches multiple parts of the codebase, but it should lead to more accurate and reliable autocomplete behavior, especially when dealing with code blocks in the editor.

> Written by an 🤖 LLM. Edited by a 🧑‍💻 human.
This commit is contained in:
Régis Hanol 2024-03-11 17:35:50 +01:00 committed by GitHub
parent dcf1c2bc04
commit 47d1703b67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 239 additions and 357 deletions

View File

@ -241,8 +241,8 @@ export default Component.extend(ComposerUploadUppy, {
key: "@", key: "@",
transformComplete: (v) => v.username || v.name, transformComplete: (v) => v.username || v.name,
afterComplete: this._afterMentionComplete, afterComplete: this._afterMentionComplete,
triggerRule: (textarea) => triggerRule: async (textarea) =>
!inCodeBlock(textarea.value, caretPosition(textarea)), !(await inCodeBlock(textarea.value, caretPosition(textarea))),
onClose: destroyUserStatuses, onClose: destroyUserStatuses,
}); });
} }

View File

@ -532,10 +532,6 @@ export default Component.extend(TextareaTextManipulation, {
}, },
onKeyUp: (text, cp) => { onKeyUp: (text, cp) => {
if (inCodeBlock(text, cp)) {
return false;
}
const matches = const matches =
/(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec( /(?:^|[\s.\?,@\/#!%&*;:\[\]{}=\-_()])(:(?!:).?[\w-]*:?(?!:)(?:t\d?)?:?) ?$/gi.exec(
text.substring(0, cp) text.substring(0, cp)
@ -639,8 +635,8 @@ export default Component.extend(TextareaTextManipulation, {
}); });
}, },
triggerRule: (textarea) => triggerRule: async (textarea) =>
!inCodeBlock(textarea.value, caretPosition(textarea)), !(await inCodeBlock(textarea.value, caretPosition(textarea))),
}); });
}, },

View File

@ -17,8 +17,9 @@ import discourseLater from "discourse-common/lib/later";
export const SKIP = "skip"; export const SKIP = "skip";
export const CANCELLED_STATUS = "__CANCELLED"; export const CANCELLED_STATUS = "__CANCELLED";
const allowedLettersRegex = /[\s\t\[\{\(\/]/;
let _autoCompletePopper; const ALLOWED_LETTERS_REGEXP = /[\s[{(/]/;
let _autoCompletePopper, _inputTimeout;
const keys = { const keys = {
backSpace: 8, backSpace: 8,
@ -46,15 +47,13 @@ const keys = {
z: 90, z: 90,
}; };
let inputTimeout;
export default function (options) { export default function (options) {
if (this.length === 0) { if (this.length === 0) {
return; return;
} }
if (options === "destroy" || options.updateData) { if (options === "destroy" || options.updateData) {
cancel(inputTimeout); cancel(_inputTimeout);
this[0].removeEventListener("keydown", handleKeyDown); this[0].removeEventListener("keydown", handleKeyDown);
this[0].removeEventListener("keyup", handleKeyUp); this[0].removeEventListener("keyup", handleKeyUp);
@ -242,7 +241,7 @@ export default function (options) {
// the time autocomplete was first displayed and the time of completion // the time autocomplete was first displayed and the time of completion
// Specifically this may happen due to uploads which inject a placeholder // Specifically this may happen due to uploads which inject a placeholder
// which is later replaced with a different length string. // which is later replaced with a different length string.
let pos = guessCompletePosition({ completeTerm: true }); let pos = await guessCompletePosition({ completeTerm: true });
if ( if (
pos.completeStart !== undefined && pos.completeStart !== undefined &&
@ -374,26 +373,31 @@ export default function (options) {
} else { } else {
selectedOption = -1; selectedOption = -1;
} }
ul.find("li").click(function ({ originalEvent }) { ul.find("li").click(async function ({ originalEvent }) {
// this is required to prevent the default behaviour when clicking on a <a> tag
originalEvent.preventDefault();
selectedOption = ul.find("li").index(this); selectedOption = ul.find("li").index(this);
// hack for Gboard, see meta.discourse.org/t/-/187009/24 // hack for Gboard, see meta.discourse.org/t/-/187009/24
if (autocompleteOptions == null) { if (autocompleteOptions == null) {
const opts = { ...options, _gboard_hack_force_lookup: true }; const opts = { ...options, _gboard_hack_force_lookup: true };
const forcedAutocompleteOptions = dataSource(prevTerm, opts); const data = await dataSource(prevTerm, opts);
forcedAutocompleteOptions?.then((data) => { if (data) {
updateAutoComplete(data); updateAutoComplete(data);
completeTerm(autocompleteOptions[selectedOption], originalEvent); await completeTerm(
autocompleteOptions[selectedOption],
originalEvent
);
if (!options.single) { if (!options.single) {
me.focus(); me.focus();
} }
}); }
} else { } else {
completeTerm(autocompleteOptions[selectedOption], originalEvent); await completeTerm(autocompleteOptions[selectedOption], originalEvent);
if (!options.single) { if (!options.single) {
me.focus(); me.focus();
} }
} }
return false;
}); });
if (options.appendSelector) { if (options.appendSelector) {
@ -537,19 +541,20 @@ export default function (options) {
closeAutocomplete(); closeAutocomplete();
}); });
function checkTriggerRule(opts) { async function checkTriggerRule(opts) {
return options.triggerRule ? options.triggerRule(me[0], opts) : true; const shouldTrigger = await options.triggerRule?.(me[0], opts);
return shouldTrigger ?? true;
} }
function handleKeyUp(e) { async function handleKeyUp(e) {
if (options.debounced) { if (options.debounced) {
discourseDebounce(this, performAutocomplete, e, INPUT_DELAY); discourseDebounce(this, performAutocomplete, e, INPUT_DELAY);
} else { } else {
performAutocomplete(e); await performAutocomplete(e);
} }
} }
function performAutocomplete(e) { async function performAutocomplete(e) {
if ([keys.esc, keys.enter].includes(e.which)) { if ([keys.esc, keys.enter].includes(e.which)) {
return true; return true;
} }
@ -572,9 +577,10 @@ export default function (options) {
if (completeStart === null && cp > 0) { if (completeStart === null && cp > 0) {
if (key === options.key) { if (key === options.key) {
let prevChar = me.val().charAt(cp - 2); let prevChar = me.val().charAt(cp - 2);
const shouldTrigger = await checkTriggerRule();
if ( if (
checkTriggerRule() && shouldTrigger &&
(!prevChar || allowedLettersRegex.test(prevChar)) (!prevChar || ALLOWED_LETTERS_REGEXP.test(prevChar))
) { ) {
completeStart = cp - 1; completeStart = cp - 1;
updateAutoComplete(dataSource("", options)); updateAutoComplete(dataSource("", options));
@ -586,13 +592,12 @@ export default function (options) {
} }
} }
function guessCompletePosition(opts) { async function guessCompletePosition(opts) {
let prev, stopFound, term; let prev, stopFound, term;
let prevIsGood = true; let prevIsGood = true;
let element = me[0]; let element = me[0];
let backSpace = opts && opts.backSpace; let backSpace = opts?.backSpace;
let completeTermOption = opts && opts.completeTerm; let completeTermOption = opts?.completeTerm;
let caretPos = caretPosition(element); let caretPos = caretPosition(element);
if (backSpace) { if (backSpace) {
@ -612,15 +617,15 @@ export default function (options) {
if (stopFound) { if (stopFound) {
prev = element.value[caretPos - 1]; prev = element.value[caretPos - 1];
const shouldTrigger = await checkTriggerRule({ backSpace });
if ( if (
checkTriggerRule({ backSpace }) && shouldTrigger &&
(prev === undefined || allowedLettersRegex.test(prev)) (prev === undefined || ALLOWED_LETTERS_REGEXP.test(prev))
) { ) {
start = caretPos; start = caretPos;
term = element.value.substring(caretPos + 1, initialCaretPos); term = element.value.substring(caretPos + 1, initialCaretPos);
end = caretPos + term.length; end = caretPos + term.length;
break; break;
} }
} }
@ -633,7 +638,7 @@ export default function (options) {
return { completeStart: start, completeEnd: end, term }; return { completeStart: start, completeEnd: end, term };
} }
function handleKeyDown(e) { async function handleKeyDown(e) {
let i, term, total, userToComplete; let i, term, total, userToComplete;
let cp; let cp;
@ -644,8 +649,8 @@ export default function (options) {
if (options.allowAny) { if (options.allowAny) {
// saves us wiring up a change event as well // saves us wiring up a change event as well
cancel(inputTimeout); cancel(_inputTimeout);
inputTimeout = discourseLater(function () { _inputTimeout = discourseLater(() => {
if (inputSelectedItems.length === 0) { if (inputSelectedItems.length === 0) {
inputSelectedItems.push(""); inputSelectedItems.push("");
} }
@ -669,7 +674,7 @@ export default function (options) {
} }
if (completeStart === null && e.which === keys.backSpace && options.key) { if (completeStart === null && e.which === keys.backSpace && options.key) {
let position = guessCompletePosition({ backSpace: true }); let position = await guessCompletePosition({ backSpace: true });
completeStart = position.completeStart; completeStart = position.completeStart;
if (position.completeEnd) { if (position.completeEnd) {
@ -716,7 +721,7 @@ export default function (options) {
selectedOption >= 0 && selectedOption >= 0 &&
(userToComplete = autocompleteOptions[selectedOption]) (userToComplete = autocompleteOptions[selectedOption])
) { ) {
completeTerm(userToComplete, e); await completeTerm(userToComplete, e);
} else { } else {
// We're cancelling it, really. // We're cancelling it, really.
return true; return true;

View File

@ -140,12 +140,8 @@ export function setupHashtagAutocomplete(
); );
} }
export function hashtagTriggerRule(textarea) { export async function hashtagTriggerRule(textarea) {
if (inCodeBlock(textarea.value, caretPosition(textarea))) { return !(await inCodeBlock(textarea.value, caretPosition(textarea)));
return false;
}
return true;
} }
function _setup( function _setup(
@ -168,7 +164,8 @@ function _setup(
} }
return _searchGeneric(term, siteSettings, contextualHashtagConfiguration); return _searchGeneric(term, siteSettings, contextualHashtagConfiguration);
}, },
triggerRule: (textarea, opts) => hashtagTriggerRule(textarea, opts), triggerRule: async (textarea, opts) =>
await hashtagTriggerRule(textarea, opts),
}); });
} }

View File

@ -1,5 +1,6 @@
import Handlebars from "handlebars"; import Handlebars from "handlebars";
import $ from "jquery"; import $ from "jquery";
import { parseAsync } from "discourse/lib/text";
import toMarkdown from "discourse/lib/to-markdown"; import toMarkdown from "discourse/lib/to-markdown";
import { capabilities } from "discourse/services/capabilities"; import { capabilities } from "discourse/services/capabilities";
import * as AvatarUtils from "discourse-common/lib/avatar-utils"; import * as AvatarUtils from "discourse-common/lib/avatar-utils";
@ -421,34 +422,45 @@ export function postRNWebviewMessage(prop, value) {
} }
} }
const CODE_BLOCKS_REGEX = function pickMarker(text) {
/^( |\t).*|`[^`]+`|^```[^]*?^```|\[code\][^]*?\[\/code\]/gm; // Uses the private use area (U+E000 to U+F8FF) to find a character that
//| ^ | ^ | ^ | ^ | // is not present in the text. This character will be used as a marker in
// | | | | // place of the caret.
// | | | code blocks between [code] for (let code = 0xe000; code <= 0xf8ff; ++code) {
// | | | const char = String.fromCharCode(code);
// | | +--- code blocks between three backticks if (!text.includes(char)) {
// | | return char;
// | +----- inline code between backticks
// |
// +------- paragraphs starting with 2 spaces or tab
const OPEN_CODE_BLOCKS_REGEX = /^( |\t).*|`[^`]+|^```[^]*?|\[code\][^]*?/gm;
export function inCodeBlock(text, pos) {
let end = 0;
for (const match of text.matchAll(CODE_BLOCKS_REGEX)) {
end = match.index + match[0].length;
if (match.index <= pos && pos <= end) {
return true;
} }
} }
return null;
}
// Character at position `pos` can be in a code block that is unfinished. function findToken(tokens, marker, level = 0) {
// To check this case, we look for any open code blocks after the last closed if (level > 50) {
// code block. return null;
const lastOpenBlock = text.slice(end).search(OPEN_CODE_BLOCKS_REGEX); }
return lastOpenBlock !== -1 && pos >= end + lastOpenBlock; const token = tokens.find((t) => (t.content ?? "").includes(marker));
return token?.children ? findToken(token.children, marker, level + 1) : token;
}
const CODE_MARKERS_REGEX = / |```|~~~|(?<!`)`(?!`)|\[code\]/;
const CODE_TOKEN_TYPES = ["code_inline", "code_block", "fence"];
export async function inCodeBlock(text, pos) {
if (!CODE_MARKERS_REGEX.test(text)) {
return false;
}
const marker = pickMarker(text);
if (!marker) {
return false;
}
const markedText = text.slice(0, pos) + marker + text.slice(pos);
const tokens = await parseAsync(markedText);
const type = findToken(tokens, marker)?.type;
return CODE_TOKEN_TYPES.includes(type);
} }
// Return an array of modifier keys that are pressed during a given `MouseEvent` // Return an array of modifier keys that are pressed during a given `MouseEvent`

View File

@ -1,18 +1,19 @@
import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; import { click, visit } from "@ember/test-helpers";
import { test } from "qunit"; import { test } from "qunit";
import { setCaretPosition } from "discourse/lib/utilities"; import { setCaretPosition } from "discourse/lib/utilities";
import { import {
acceptance, acceptance,
emulateAutocomplete,
exists, exists,
fakeTime, fakeTime,
loggedInUser, loggedInUser,
query, query,
queryAll, queryAll,
simulateKeys,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
acceptance("Composer - editor mentions", function (needs) { acceptance("Composer - editor mentions", function (needs) {
let clock = null; let clock = null;
const status = { const status = {
emoji: "tooth", emoji: "tooth",
description: "off to dentist", description: "off to dentist",
@ -21,12 +22,7 @@ acceptance("Composer - editor mentions", function (needs) {
needs.user(); needs.user();
needs.settings({ enable_mentions: true, allow_uncategorized_topics: true }); needs.settings({ enable_mentions: true, allow_uncategorized_topics: true });
needs.hooks.afterEach(() => clock?.restore());
needs.hooks.afterEach(() => {
if (clock) {
clock.restore();
}
});
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
server.get("/u/search/users", () => { server.get("/u/search/users", () => {
@ -65,11 +61,12 @@ acceptance("Composer - editor mentions", function (needs) {
await visit("/"); await visit("/");
await click("#create-topic"); await click("#create-topic");
await emulateAutocomplete(".d-editor-input", "abc @u"); const editor = query(".d-editor-input");
await click(".autocomplete.ac-user .selected");
await simulateKeys(editor, "abc @u\r");
assert.strictEqual( assert.strictEqual(
query(".d-editor-input").value, editor.value,
"abc @user ", "abc @user ",
"should replace mention correctly" "should replace mention correctly"
); );
@ -78,21 +75,13 @@ acceptance("Composer - editor mentions", function (needs) {
test("selecting user mentions after deleting characters", async function (assert) { test("selecting user mentions after deleting characters", async function (assert) {
await visit("/"); await visit("/");
await click("#create-topic"); await click("#create-topic");
await fillIn(".d-editor-input", "abc @user a");
// Emulate user typing `@` and `u` in the editor const editor = query(".d-editor-input");
await triggerKeyEvent(".d-editor-input", "keydown", "Backspace");
await fillIn(".d-editor-input", "abc @user ");
await triggerKeyEvent(".d-editor-input", "keyup", "Backspace");
await triggerKeyEvent(".d-editor-input", "keydown", "Backspace"); await simulateKeys(editor, "abc @user a\b\b\r");
await fillIn(".d-editor-input", "abc @user");
await triggerKeyEvent(".d-editor-input", "keyup", "Backspace");
await click(".autocomplete.ac-user .selected");
assert.strictEqual( assert.strictEqual(
query(".d-editor-input").value, editor.value,
"abc @user ", "abc @user ",
"should replace mention correctly" "should replace mention correctly"
); );
@ -102,25 +91,14 @@ acceptance("Composer - editor mentions", function (needs) {
await visit("/"); await visit("/");
await click("#create-topic"); await click("#create-topic");
// Emulate user pressing backspace in the editor
const editor = query(".d-editor-input"); const editor = query(".d-editor-input");
await fillIn(".d-editor-input", "abc @user 123");
await simulateKeys(editor, "abc @user 123");
await setCaretPosition(editor, 9); await setCaretPosition(editor, 9);
await simulateKeys(editor, "\b\b\r");
await triggerKeyEvent(".d-editor-input", "keydown", "Backspace");
await fillIn(".d-editor-input", "abc @use 123");
await triggerKeyEvent(".d-editor-input", "keyup", "Backspace");
await setCaretPosition(editor, 8);
await triggerKeyEvent(".d-editor-input", "keydown", "Backspace");
await fillIn(".d-editor-input", "abc @us 123");
await triggerKeyEvent(".d-editor-input", "keyup", "Backspace");
await setCaretPosition(editor, 7);
await click(".autocomplete.ac-user .selected");
assert.strictEqual( assert.strictEqual(
query(".d-editor-input").value, editor.value,
"abc @user 123", "abc @user 123",
"should replace mention correctly" "should replace mention correctly"
); );
@ -134,12 +112,15 @@ acceptance("Composer - editor mentions", function (needs) {
await visit("/"); await visit("/");
await click("#create-topic"); await click("#create-topic");
await emulateAutocomplete(".d-editor-input", "@u"); const editor = query(".d-editor-input");
await simulateKeys(editor, "@u");
assert.ok( assert.ok(
exists(`.autocomplete .emoji[alt='${status.emoji}']`), exists(`.autocomplete .emoji[alt='${status.emoji}']`),
"status emoji is shown" "status emoji is shown"
); );
assert.equal( assert.equal(
query( query(
".autocomplete .user-status-message-description" ".autocomplete .user-status-message-description"
@ -153,14 +134,16 @@ acceptance("Composer - editor mentions", function (needs) {
await visit("/"); await visit("/");
await click("#create-topic"); await click("#create-topic");
await emulateAutocomplete(".d-editor-input", "abc @u"); const editor = query(".d-editor-input");
await simulateKeys(editor, "abc @u");
assert.deepEqual( assert.deepEqual(
[...queryAll(".ac-user .username")].map((e) => e.innerText), [...queryAll(".ac-user .username")].map((e) => e.innerText),
["user", "user2", "user_group", "foo"] ["user", "user2", "user_group", "foo"]
); );
await emulateAutocomplete(".d-editor-input", "abc @f"); await simulateKeys(editor, "\bf");
assert.deepEqual( assert.deepEqual(
[...queryAll(".ac-user .username")].map((e) => e.innerText), [...queryAll(".ac-user .username")].map((e) => e.innerText),

View File

@ -1,4 +1,4 @@
import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; import { click, visit } from "@ember/test-helpers";
import { IMAGE_VERSION as v } from "pretty-text/emoji/version"; import { IMAGE_VERSION as v } from "pretty-text/emoji/version";
import { test } from "qunit"; import { test } from "qunit";
import { import {
@ -6,6 +6,8 @@ import {
exists, exists,
normalizeHtml, normalizeHtml,
query, query,
simulateKey,
simulateKeys,
visible, visible,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
@ -16,12 +18,13 @@ acceptance("Emoji", function (needs) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create"); await click("#topic-footer-buttons .btn.create");
await fillIn(".d-editor-input", "this is an emoji :blonde_woman:"); await simulateKeys(query(".d-editor-input"), "a :blonde_wo\t");
assert.ok(visible(".d-editor-preview")); assert.ok(visible(".d-editor-preview"));
assert.strictEqual( assert.strictEqual(
normalizeHtml(query(".d-editor-preview").innerHTML.trim()), normalizeHtml(query(".d-editor-preview").innerHTML.trim()),
normalizeHtml( normalizeHtml(
`<p>this is an emoji <img src="/images/emoji/twitter/blonde_woman.png?v=${v}" title=":blonde_woman:" class="emoji" alt=":blonde_woman:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>` `<p>a <img src="/images/emoji/twitter/blonde_woman.png?v=${v}" title=":blonde_woman:" class="emoji" alt=":blonde_woman:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
) )
); );
}); });
@ -30,13 +33,13 @@ acceptance("Emoji", function (needs) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create"); await click("#topic-footer-buttons .btn.create");
await fillIn(".d-editor-input", "this is an emoji :blonde_woman:t5:"); await simulateKeys(query(".d-editor-input"), "a :blonde_woman:t5:");
assert.ok(visible(".d-editor-preview")); assert.ok(visible(".d-editor-preview"));
assert.strictEqual( assert.strictEqual(
normalizeHtml(query(".d-editor-preview").innerHTML.trim()), normalizeHtml(query(".d-editor-preview").innerHTML.trim()),
normalizeHtml( normalizeHtml(
`<p>this is an emoji <img src="/images/emoji/twitter/blonde_woman/5.png?v=${v}" title=":blonde_woman:t5:" class="emoji" alt=":blonde_woman:t5:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>` `<p>a <img src="/images/emoji/twitter/blonde_woman/5.png?v=${v}" title=":blonde_woman:t5:" class="emoji" alt=":blonde_woman:t5:" loading="lazy" width="20" height="20" style="aspect-ratio: 20 / 20;"></p>`
) )
); );
}); });
@ -49,13 +52,13 @@ acceptance("Emoji", function (needs) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create"); await click("#topic-footer-buttons .btn.create");
await fillIn(".d-editor-input", ":s"); const editor = query(".d-editor-input");
await triggerKeyEvent(".d-editor-input", "keyup", "ArrowDown"); // ensures a keyup is triggered
await simulateKeys(editor, ":s");
assert.notOk(exists(".autocomplete.ac-emoji")); assert.notOk(exists(".autocomplete.ac-emoji"));
await fillIn(".d-editor-input", ":sw"); await simulateKey(editor, "w");
await triggerKeyEvent(".d-editor-input", "keyup", "ArrowDown"); // ensures a keyup is triggered
assert.ok(exists(".autocomplete.ac-emoji")); assert.ok(exists(".autocomplete.ac-emoji"));
}); });

View File

@ -2,14 +2,13 @@ import { click, visit } from "@ember/test-helpers";
import { test } from "qunit"; import { test } from "qunit";
import { import {
acceptance, acceptance,
emulateAutocomplete, query,
simulateKeys,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
acceptance("#hashtag autocompletion in composer", function (needs) { acceptance("#hashtag autocompletion in composer", function (needs) {
needs.user(); needs.user();
needs.settings({ needs.settings({ tagging_enabled: true });
tagging_enabled: true,
});
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
server.get("/hashtags", () => { server.get("/hashtags", () => {
return helper.response({ return helper.response({
@ -56,8 +55,7 @@ acceptance("#hashtag autocompletion in composer", function (needs) {
test(":emoji: unescape in autocomplete search results", async function (assert) { test(":emoji: unescape in autocomplete search results", async function (assert) {
await visit("/t/internationalization-localization/280"); await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .btn.create"); await click("#topic-footer-buttons .btn.create");
await simulateKeys(query(".d-editor-input"), "abc #o");
await emulateAutocomplete(".d-editor-input", "abc #o");
assert.dom(".hashtag-autocomplete__option").exists({ count: 3 }); assert.dom(".hashtag-autocomplete__option").exists({ count: 3 });
assert assert

View File

@ -1,6 +1,5 @@
import { render } from "@ember/test-helpers"; import { render } from "@ember/test-helpers";
import { setupRenderingTest as emberSetupRenderingTest } from "ember-qunit"; import { setupRenderingTest as emberSetupRenderingTest } from "ember-qunit";
import $ from "jquery";
import QUnit, { test } from "qunit"; import QUnit, { test } from "qunit";
import { autoLoadModules } from "discourse/instance-initializers/auto-load-modules"; import { autoLoadModules } from "discourse/instance-initializers/auto-load-modules";
import { AUTO_GROUPS } from "discourse/lib/constants"; import { AUTO_GROUPS } from "discourse/lib/constants";
@ -48,8 +47,6 @@ export function setupRenderingTest(hooks) {
autoLoadModules(this.owner, this.registry); autoLoadModules(this.owner, this.registry);
this.owner.lookup("service:store"); this.owner.lookup("service:store");
$.fn.autocomplete = function () {};
}); });
} }

View File

@ -1,9 +1,9 @@
import { run } from "@ember/runloop"; import { run } from "@ember/runloop";
import { import {
fillIn,
getApplication, getApplication,
settled, settled,
triggerKeyEvent, triggerKeyEvent,
typeIn,
} from "@ember/test-helpers"; } from "@ember/test-helpers";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { setupApplicationTest } from "ember-qunit"; import { setupApplicationTest } from "ember-qunit";
@ -610,14 +610,31 @@ export async function paste(element, text, otherClipboardData = {}) {
return e; return e;
} }
export async function emulateAutocomplete(inputSelector, text) { export async function simulateKey(element, key) {
await triggerKeyEvent(inputSelector, "keydown", "Backspace"); if (key === "\b") {
await fillIn(inputSelector, `${text} `); await triggerKeyEvent(element, "keydown", "Backspace");
await triggerKeyEvent(inputSelector, "keyup", "Backspace");
await triggerKeyEvent(inputSelector, "keydown", "Backspace"); const pos = element.selectionStart;
await fillIn(inputSelector, text); element.value = element.value.slice(0, pos - 1) + element.value.slice(pos);
await triggerKeyEvent(inputSelector, "keyup", "Backspace"); element.selectionStart = pos - 1;
element.selectionEnd = pos - 1;
await triggerKeyEvent(element, "keyup", "Backspace");
} else if (key === "\t") {
await triggerKeyEvent(element, "keydown", "Tab");
await triggerKeyEvent(element, "keyup", "Tab");
} else if (key === "\r") {
await triggerKeyEvent(element, "keydown", "Enter");
await triggerKeyEvent(element, "keyup", "Enter");
} else {
await typeIn(element, key);
}
}
export async function simulateKeys(element, keys) {
for (let key of keys) {
await simulateKey(element, key);
}
} }
// The order of attributes can vary in different browsers. When comparing // The order of attributes can vary in different browsers. When comparing

View File

@ -2,261 +2,153 @@ import { setupTest } from "ember-qunit";
import { compile } from "handlebars"; import { compile } from "handlebars";
import $ from "jquery"; import $ from "jquery";
import { module, test } from "qunit"; import { module, test } from "qunit";
import autocomplete from "discourse/lib/autocomplete"; import { setCaretPosition } from "discourse/lib/utilities";
import {
simulateKey,
simulateKeys,
} from "discourse/tests/helpers/qunit-helpers";
module("Unit | Utility | autocomplete", function (hooks) { module("Unit | Utility | autocomplete", function (hooks) {
setupTest(hooks); setupTest(hooks);
let elements = [];
function textArea(value) { let _element;
let element = document.createElement("TEXTAREA");
element.value = value;
document.getElementById("ember-testing").appendChild(element);
elements.push(element);
return element;
}
function cleanup() { const template = compile(
elements.forEach((e) => { `
e.remove(); <div id='ac-testing' class='autocomplete ac-test'>
autocomplete.call($(e), { cancel: true });
autocomplete.call($(e), "destroy");
});
elements = [];
}
hooks.afterEach(function () {
cleanup();
});
function simulateKey(element, key) {
let keyCode = key.charCodeAt(0);
let bubbled = false;
let trackBubble = function () {
bubbled = true;
};
element.addEventListener("keydown", trackBubble);
let keyboardEvent = new KeyboardEvent("keydown", {
key,
keyCode,
which: keyCode,
});
element.dispatchEvent(keyboardEvent);
element.removeEventListener("keydown", trackBubble);
if (bubbled) {
let pos = element.selectionStart;
let value = element.value;
// backspace
if (key === "\b") {
element.value = value.slice(0, pos - 1) + value.slice(pos);
element.selectionStart = pos - 1;
element.selectionEnd = pos - 1;
} else {
element.value = value.slice(0, pos) + key + value.slice(pos);
element.selectionStart = pos + 1;
element.selectionEnd = pos + 1;
}
}
element.dispatchEvent(
new KeyboardEvent("keyup", { key, keyCode, which: keyCode })
);
}
test("Autocomplete can complete really short terms correctly", async function (assert) {
let element = textArea("");
let $element = $(element);
autocomplete.call($element, {
key: ":",
transformComplete: () => "sad:",
dataSource: () => [":sad:"],
template: compile(`<div id='ac-testing' class='autocomplete ac-test'>
<ul> <ul>
{{#each options as |option|}} {{#each options as |option|}}
<li> <li><a href>{{option}}</a></li>
<a href>
{{option}}
</a>
</li>
{{/each}} {{/each}}
</ul> </ul>
</div>`), </div>
`.trim()
);
function textArea() {
_element = document.createElement("TEXTAREA");
document.getElementById("ember-testing").appendChild(_element);
return _element;
}
hooks.afterEach(() => {
if (!_element) {
return;
}
const $e = $(_element);
$e.autocomplete({ cancel: true });
$e.autocomplete("destroy");
_element.remove();
}); });
simulateKey(element, "a"); test("Autocomplete can complete really short terms correctly", async function (assert) {
simulateKey(element, " "); const element = textArea();
simulateKey(element, ":"); $(element).autocomplete({
simulateKey(element, ")"); key: ":",
simulateKey(element, "\r"); template,
transformComplete: (e) => e.slice(1),
dataSource: () => [":sad:"],
});
let sleep = (millisecs) => await simulateKeys(element, "a :)\r");
new Promise((promise) => setTimeout(promise, millisecs));
// completeTerm awaits transformComplete
// we need to wait for it to be done
// Note: this is somewhat questionable given that when people
// press ENTER on an autocomplete they do not want to be beholden
// to an async function.
let inputEquals = async function (value) {
let count = 3000;
while (count > 0 && element.value !== value) {
count -= 1;
await sleep(1);
}
};
await inputEquals("a :sad: ");
assert.strictEqual(element.value, "a :sad: "); assert.strictEqual(element.value, "a :sad: ");
assert.strictEqual(element.selectionStart, 8); assert.strictEqual(element.selectionStart, 8);
assert.strictEqual(element.selectionEnd, 8); assert.strictEqual(element.selectionEnd, 8);
}); });
test("Autocomplete can account for cursor drift correctly", function (assert) { test("Autocomplete can account for cursor drift correctly", async function (assert) {
let element = textArea(""); const element = textArea();
let $element = $(element); const db = ["test1", "test2"];
autocomplete.call($element, { $(element).autocomplete({
key: "@", key: "@",
dataSource: (term) => template,
["test1", "test2"].filter((word) => word.includes(term)), dataSource: (term) => db.filter((word) => word.includes(term)),
template: compile(`<div id='ac-testing' class='autocomplete ac-test'>
<ul>
{{#each options as |option|}}
<li>
<a href>
{{option}}
</a>
</li>
{{/each}}
</ul>
</div>`),
}); });
simulateKey(element, "@"); await simulateKeys(element, "@\r");
simulateKey(element, "\r");
assert.strictEqual(element.value, "@test1 "); assert.strictEqual(element.value, "@test1 ");
assert.strictEqual(element.selectionStart, 7); assert.strictEqual(element.selectionStart, 7);
assert.strictEqual(element.selectionEnd, 7); assert.strictEqual(element.selectionEnd, 7);
simulateKey(element, "@"); await simulateKeys(element, "@2\r");
simulateKey(element, "2");
simulateKey(element, "\r");
assert.strictEqual(element.value, "@test1 @test2 "); assert.strictEqual(element.value, "@test1 @test2 ");
assert.strictEqual(element.selectionStart, 14); assert.strictEqual(element.selectionStart, 14);
assert.strictEqual(element.selectionEnd, 14); assert.strictEqual(element.selectionEnd, 14);
element.selectionStart = 6; await setCaretPosition(element, 6);
element.selectionEnd = 6; await simulateKeys(element, "\b\b");
simulateKey(element, "\b"); assert.strictEqual(element.value, "@tes @test2 ");
simulateKey(element, "\b");
simulateKey(element, "\r"); await simulateKey(element, "\r");
assert.strictEqual(element.value, "@test1 @test2 "); assert.strictEqual(element.value, "@test1 @test2 ");
assert.strictEqual(element.selectionStart, 7); assert.strictEqual(element.selectionStart, 7);
assert.strictEqual(element.selectionEnd, 7); assert.strictEqual(element.selectionEnd, 7);
// lets see that deleting last space triggers autocomplete // ensures that deleting last space triggers autocomplete
element.selectionStart = element.value.length; await setCaretPosition(element, element.value.length);
element.selectionEnd = element.value.length; await simulateKey(element, "\b");
simulateKey(element, "\b");
let list = document.querySelectorAll("#ac-testing ul li");
assert.strictEqual(list.length, 1);
simulateKey(element, "\b"); assert.dom("#ac-testing ul li").exists({ count: 1 });
list = document.querySelectorAll("#ac-testing ul li");
assert.strictEqual(list.length, 2); await simulateKey(element, "\b");
assert.dom("#ac-testing ul li").exists({ count: 2 });
// close autocomplete // close autocomplete
simulateKey(element, "\r"); await simulateKey(element, "\r");
// does not trigger by mistake at the start // does not trigger by mistake at the start
element.value = "test"; element.value = "test";
element.selectionStart = element.value.length;
element.selectionEnd = element.value.length;
simulateKey(element, "\b"); await setCaretPosition(element, element.value.length);
list = document.querySelectorAll("#ac-testing ul li"); await simulateKey(element, "\b");
assert.strictEqual(list.length, 0);
assert.dom("#ac-testing ul li").exists({ count: 0 });
}); });
test("Autocomplete can handle spaces", function (assert) { test("Autocomplete can handle spaces", async function (assert) {
let element = textArea(""); const element = textArea();
let $element = $(element); const db = [
autocomplete.call($element, {
key: "@",
dataSource: (term) =>
[
{ username: "jd", name: "jane dale" }, { username: "jd", name: "jane dale" },
{ username: "jb", name: "jack black" }, { username: "jb", name: "jack black" },
] ];
.filter((user) => {
return user.username.includes(term) || user.name.includes(term); $(element).autocomplete({
}) key: "@",
template,
dataSource: (term) =>
db
.filter(
(user) => user.username.includes(term) || user.name.includes(term)
)
.map((user) => user.username), .map((user) => user.username),
template: compile(`<div id='ac-testing' class='autocomplete ac-test'>
<ul>
{{#each options as |option|}}
<li>
<a href>
{{option}}
</a>
</li>
{{/each}}
</ul>
</div>`),
}); });
simulateKey(element, "@"); await simulateKeys(element, "@jane d\r");
simulateKey(element, "j");
simulateKey(element, "a");
simulateKey(element, "n");
simulateKey(element, "e");
simulateKey(element, " ");
simulateKey(element, "d");
simulateKey(element, "\r");
assert.strictEqual(element.value, "@jd "); assert.strictEqual(element.value, "@jd ");
}); });
test("Autocomplete can render on @", function (assert) { test("Autocomplete can render on @", async function (assert) {
let element = textArea("@"); const element = textArea();
let $element = $(element);
autocomplete.call($element, { $(element).autocomplete({
key: "@", key: "@",
template,
dataSource: () => ["test1", "test2"], dataSource: () => ["test1", "test2"],
template: compile(`<div id='ac-testing' class='autocomplete ac-test'>
<ul>
{{#each options as |option|}}
<li>
<a href>
{{option}}
</a>
</li>
{{/each}}
</ul>
</div>`),
}); });
element.dispatchEvent(new KeyboardEvent("keydown", { key: "@" })); await simulateKey(element, "@");
element.dispatchEvent(new KeyboardEvent("keyup", { key: "@" }));
let list = document.querySelectorAll("#ac-testing ul li"); assert.dom("#ac-testing ul li").exists({ count: 2 });
assert.strictEqual(list.length, 2); assert.dom("#ac-testing li a.selected").exists({ count: 1 });
assert.dom("#ac-testing li a.selected").hasText("test1");
let selected = document.querySelectorAll("#ac-testing ul li a.selected");
assert.strictEqual(selected.length, 1);
assert.strictEqual(selected[0].innerText, "test1");
}); });
}); });

View File

@ -219,35 +219,17 @@ module("Unit | Utilities", function (hooks) {
); );
}); });
test("inCodeBlock", function (assert) { test("inCodeBlock", async function (assert) {
const texts = [ const text =
// CLOSED CODE BLOCKS: "000\n\n```\n111\n```\n\n000\n\n`111 111`\n\n000\n\n[code]\n111\n[/code]\n\n 111\n\t111\n\n000`000";
"000\n\n 111\n\n000",
"000 `111` 000",
"000\n```\n111\n```\n000",
"000\n[code]111[/code]\n000",
// OPEN CODE BLOCKS:
"000\n\n 111",
"000 `111",
"000\n```\n111",
"000\n[code]111",
// COMPLEX TEST:
"000\n\n```\n111\n```\n\n000\n\n`111 111`\n\n000\n\n[code]\n111\n[/code]\n\n 111\n\t111\n\n000`111",
// INDENTED OPEN CODE BLOCKS:
// - Using tab
"000\n\t```111\n\t111\n\t111```\n000",
// - Using spaces
`000\n \`\`\`111\n 111\n 111\`\`\`\n000`,
];
texts.forEach((text) => {
for (let i = 0; i < text.length; ++i) { for (let i = 0; i < text.length; ++i) {
if (text[i] === "0" || text[i] === "1") { if (text[i] === "0" || text[i] === "1") {
assert.strictEqual(inCodeBlock(text, i), text[i] === "1"); let inCode = await inCodeBlock(text, i);
assert.strictEqual(inCode, text[i] === "1");
} }
} }
}); });
});
test("mergeSortedLists", function (assert) { test("mergeSortedLists", function (assert) {
const comparator = (a, b) => b > a; const comparator = (a, b) => b > a;

View File

@ -3,10 +3,10 @@ import { skip, test } from "qunit";
import pretender, { response } from "discourse/tests/helpers/create-pretender"; import pretender, { response } from "discourse/tests/helpers/create-pretender";
import { import {
acceptance, acceptance,
emulateAutocomplete,
loggedInUser, loggedInUser,
publishToMessageBus, publishToMessageBus,
query, query,
simulateKeys,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
acceptance("Chat | User status on mentions", function (needs) { acceptance("Chat | User status on mentions", function (needs) {
@ -321,7 +321,7 @@ acceptance("Chat | User status on mentions", function (needs) {
} }
async function typeWithAutocompleteAndSend(text) { async function typeWithAutocompleteAndSend(text) {
await emulateAutocomplete(".chat-composer__input", text); await simulateKeys(query(".chat-composer__input"), text);
await click(".autocomplete.ac-user .selected"); await click(".autocomplete.ac-user .selected");
await click(".chat-composer-button.-send"); await click(".chat-composer-button.-send");
} }