diff --git a/app/assets/javascripts/initializers/initializeCommentsPage.js.erb b/app/assets/javascripts/initializers/initializeCommentsPage.js.erb
index b67b60719..a09cbb2a0 100644
--- a/app/assets/javascripts/initializers/initializeCommentsPage.js.erb
+++ b/app/assets/javascripts/initializers/initializeCommentsPage.js.erb
@@ -306,8 +306,11 @@ function handleFocus(event) {
area.blur();
} else {
var form = event.target.closest(".comment-form");
- form.classList.add("comment-form--initiated");
+ if (form) {
+ form.classList.add("comment-form--initiated");
+ }
handleSizeChange(event);
+ window.Forem.initializeMentionAutocompleteTextArea(area);
}
}
@@ -434,14 +437,13 @@ function handleSizeChange(event) {
function handleButtonsActivation(event) {
var textarea = event.target;
- var buttons = textarea.closest('.comment-form').getElementsByClassName('js-btn-enable');
- Array.from(buttons).forEach(function(button) {
- if (textarea.value.length > 0) {
- button.disabled = false;
- } else {
- button.disabled = true;
- }
- });
+ var commentForm = textarea.closest('.comment-form');
+ if (commentForm) {
+ var buttons = textarea.closest('.comment-form').getElementsByClassName('js-btn-enable');
+ Array.from(buttons).forEach(function(button) {
+ button.disabled = textarea.value.length === 0;
+ });
+ };
}
function validateField(event) {
diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx b/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx
index 0e5ffc6e6..d7cbbf43b 100644
--- a/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx
+++ b/app/javascript/crayons/MentionAutocompleteTextArea/MentionAutocompleteTextArea.jsx
@@ -36,6 +36,8 @@ const replaceTextArea = (originalNodeToReplace, newNode) => {
originalNodeToReplace,
'',
).cssText;
+ // Make sure no transition replays when the new textarea is mounted
+ newNode.style.transition = 'none';
// We need to manually remove the element, as Preact's diffing algorithm won't replace it in render
originalNodeToReplace.remove();
@@ -194,7 +196,7 @@ export const MentionAutocompleteTextArea = ({
const textWithSelection = `${textContent.substring(
0,
selectionInsertIndex,
- )}${username}${textContent.substring(inputRef.current.selectionStart)}`;
+ )}${username} ${textContent.substring(inputRef.current.selectionStart)}`;
// Clear the current search
setSearchTerm('');
@@ -204,8 +206,8 @@ export const MentionAutocompleteTextArea = ({
// Update the text area value
setTextContent(textWithSelection);
- // Update the cursor to directly after the selection
- const newCursorPosition = selectionInsertIndex + username.length + 1;
+ // Update the cursor to directly after the selection (+2 accounts for the @ sign, and adding a space after the username)
+ const newCursorPosition = selectionInsertIndex + username.length + 2;
setCursorPosition(newCursorPosition);
};
diff --git a/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/__snapshots__/MentionAutocompleteTextArea.test.jsx.snap b/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/__snapshots__/MentionAutocompleteTextArea.test.jsx.snap
index 9859b017f..8387988e6 100644
--- a/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/__snapshots__/MentionAutocompleteTextArea.test.jsx.snap
+++ b/app/javascript/crayons/MentionAutocompleteTextArea/__tests__/__snapshots__/MentionAutocompleteTextArea.test.jsx.snap
@@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[` should render 1`] = `"
"`;
+exports[` should render 1`] = `""`;
diff --git a/app/javascript/packs/base.jsx b/app/javascript/packs/base.jsx
index b142edcb7..1b6b80583 100644
--- a/app/javascript/packs/base.jsx
+++ b/app/javascript/packs/base.jsx
@@ -5,6 +5,39 @@ import {
initializeTouchDevice,
} from '../topNavigation/utilities';
+// Namespace for functions which need to be accessed in plain JS initializers
+window.Forem = {};
+
+window.Forem.initializeMentionAutocompleteTextArea = async (
+ originalTextArea,
+) => {
+ const parentContainer = originalTextArea.parentElement;
+
+ const alreadyInitialized = parentContainer.id === 'combobox-container';
+ if (alreadyInitialized) {
+ return;
+ }
+
+ const [
+ { MentionAutocompleteTextArea },
+ { fetchSearch },
+ { render, h },
+ ] = await Promise.all([
+ import('@crayons/MentionAutocompleteTextArea'),
+ import('@utilities/search'),
+ import('preact'),
+ ]);
+
+ render(
+ fetchSearch('usernames', { username })}
+ />,
+ parentContainer,
+ originalTextArea,
+ );
+};
+
window.showModal = async ({
title,
contentSelector,
diff --git a/app/javascript/utilities/textAreaUtils.js b/app/javascript/utilities/textAreaUtils.js
index 4768c44ed..a5786e859 100644
--- a/app/javascript/utilities/textAreaUtils.js
+++ b/app/javascript/utilities/textAreaUtils.js
@@ -101,11 +101,7 @@ const getIndexOfCurrentWordAutocompleteSymbol = (content, selectionIndex) => {
const currentCharacter = content.charAt(selectionIndex);
const previousCharacter = content.charAt(selectionIndex - 1);
- if (
- selectionIndex !== 0 &&
- previousCharacter !== ' ' &&
- previousCharacter !== ''
- ) {
+ if (selectionIndex !== 0 && ![' ', '', '\n'].includes(previousCharacter)) {
return getIndexOfCurrentWordAutocompleteSymbol(content, selectionIndex - 1);
}
diff --git a/app/views/notifications/shared/_comment_box.html.erb b/app/views/notifications/shared/_comment_box.html.erb
index 7796e5ae4..fd2ffb306 100644
--- a/app/views/notifications/shared/_comment_box.html.erb
+++ b/app/views/notifications/shared/_comment_box.html.erb
@@ -76,7 +76,7 @@
<%= f.hidden_field :commentable_type, value: json_data["comment"]["commentable"]["class"]["name"] %>
<%= f.hidden_field :parent_id, value: json_data["comment"]["id"] %>
- <%= f.text_area :body_markdown, id: "comment-textarea-for-#{json_data['comment']['id']}", class: "crayons-textfield mb-2 resize-y", placeholder: "Reply...", 'aria-label': "Reply to a comment..." %>
+ <%= f.text_area :body_markdown, id: "comment-textarea-for-#{json_data['comment']['id']}", class: "crayons-textfield mb-2 resize-y", placeholder: "Reply...", 'aria-label': "Reply to a comment...", onfocus: "handleFocus(event)" %>
diff --git a/cypress/fixtures/search/emptyUsernamesSearch.json b/cypress/fixtures/search/emptyUsernamesSearch.json
new file mode 100644
index 000000000..6b2dcd458
--- /dev/null
+++ b/cypress/fixtures/search/emptyUsernamesSearch.json
@@ -0,0 +1 @@
+{ "result": [] }
diff --git a/cypress/fixtures/search/usernames.json b/cypress/fixtures/search/usernames.json
new file mode 100644
index 000000000..0f4c980f9
--- /dev/null
+++ b/cypress/fixtures/search/usernames.json
@@ -0,0 +1,39 @@
+{
+ "result": [
+ {
+ "username": "search_user_1",
+ "name": "Search user 1",
+ "profile_image_90": "/images/apple-icon.png"
+ },
+ {
+ "username": "search_user_2",
+ "name": "Search user 2",
+ "profile_image_90": "/images/apple-icon.png"
+ },
+ {
+ "username": "search_user_3",
+ "name": "Search user 3",
+ "profile_image_90": "/images/apple-icon.png"
+ },
+ {
+ "username": "search_user_4",
+ "name": "Search user 4",
+ "profile_image_90": "/images/apple-icon.png"
+ },
+ {
+ "username": "search_user_5",
+ "name": "Search user 5",
+ "profile_image_90": "/images/apple-icon.png"
+ },
+ {
+ "username": "search_user_6",
+ "name": "Search user 6",
+ "profile_image_90": "/images/apple-icon.png"
+ },
+ {
+ "username": "search_user_7",
+ "name": "Search user 7",
+ "profile_image_90": "/images/apple-icon.png"
+ }
+ ]
+}
diff --git a/cypress/integration/articleFlows/commentOnArticle.spec.js b/cypress/integration/articleFlows/commentOnArticle.spec.js
new file mode 100644
index 000000000..06d93d316
--- /dev/null
+++ b/cypress/integration/articleFlows/commentOnArticle.spec.js
@@ -0,0 +1,169 @@
+describe('Comment on articles', () => {
+ beforeEach(() => {
+ cy.testSetup();
+ cy.fixture('users/articleEditorV1User.json').as('user');
+
+ cy.get('@user').then((user) => {
+ cy.loginUser(user).then(() => {
+ cy.visit('/');
+ cy.findAllByText('Test article').last().click();
+ });
+ });
+ });
+
+ it('should comment on an article with user mention autocomplete suggesting max 6 users', () => {
+ cy.intercept(
+ { method: 'GET', url: '/search/usernames' },
+ { fixture: 'search/usernames.json' },
+ );
+
+ cy.findByLabelText('Add a comment to the discussion').click();
+ cy.findByRole('combobox');
+
+ cy.findByLabelText('Add a comment to the discussion').as(
+ 'autocompleteCommentBox',
+ );
+
+ cy.get('@autocompleteCommentBox').type('Some text @s');
+ cy.findByText('Type to search for a user').should('exist');
+
+ cy.get('@autocompleteCommentBox').type('earch');
+
+ const expectedUsernames = [
+ '@search_user_1',
+ '@search_user_2',
+ '@search_user_2',
+ '@search_user_3',
+ '@search_user_4',
+ '@search_user_5',
+ '@search_user_6',
+ ];
+
+ expectedUsernames.forEach((name) => cy.findByText(name).should('exist'));
+ cy.findByText('@search_user_7').should('not.exist');
+
+ cy.findByText('@search_user_3').click();
+ cy.findByDisplayValue('Some text @search_user_3').should('exist');
+ });
+
+ it('should select a mention autocomplete suggestion by keyboard', () => {
+ cy.intercept(
+ { method: 'GET', url: '/search/usernames' },
+ { fixture: 'search/usernames.json' },
+ );
+
+ cy.findByLabelText('Add a comment to the discussion').click();
+ cy.findByRole('combobox');
+
+ cy.findByLabelText('Add a comment to the discussion').type(
+ 'Some text @search_user{downarrow}{enter}',
+ );
+
+ cy.findByDisplayValue('Some text @search_user_1').should('exist');
+ });
+
+ it('should accept entered comment text without user mention if no autocomplete suggestions', () => {
+ cy.intercept(
+ { method: 'GET', url: '/search/usernames' },
+ { fixture: 'search/emptyUsernamesSearch.json' },
+ );
+
+ cy.findByLabelText('Add a comment to the discussion').click();
+ cy.findByRole('combobox');
+
+ cy.findByLabelText('Add a comment to the discussion').as(
+ 'autocompleteCommentBox',
+ );
+
+ cy.get('@autocompleteCommentBox').type('Some text @user');
+
+ cy.findByText('No results found').should('exist');
+ cy.get('@autocompleteCommentBox').type(' ');
+ cy.findByText('No results found').should('not.exist');
+ cy.findByDisplayValue('Some text @user').should('exist');
+ });
+
+ it('should stop showing mention autocomplete suggestions on text delete', () => {
+ cy.intercept(
+ { method: 'GET', url: '/search/usernames' },
+ { fixture: 'search/usernames.json' },
+ );
+
+ cy.findByLabelText('Add a comment to the discussion').click();
+ cy.findByRole('combobox');
+
+ cy.findByLabelText('Add a comment to the discussion').as(
+ 'autocompleteCommentBox',
+ );
+
+ cy.get('@autocompleteCommentBox').type('Some text @se');
+ cy.findByText('@search_user_1').should('exist');
+
+ cy.get('@autocompleteCommentBox').type('{backspace}{backspace}{backspace}');
+ cy.findByText('@search_user_1').should('not.exist');
+ });
+
+ it('should resume search suggestions when user types after deleting', () => {
+ cy.intercept(
+ { method: 'GET', url: '/search/usernames' },
+ { fixture: 'search/usernames.json' },
+ );
+
+ cy.findByLabelText('Add a comment to the discussion').click();
+ cy.findByRole('combobox');
+
+ cy.findByLabelText('Add a comment to the discussion').as(
+ 'autocompleteCommentBox',
+ );
+
+ cy.get('@autocompleteCommentBox').type('Some text @se');
+ cy.get('@autocompleteCommentBox').type('{backspace}{backspace}');
+ cy.findByText('@search_user_1').should('not.exist');
+ cy.get('@autocompleteCommentBox').type('se');
+ cy.findByText('@search_user_1').should('exist');
+ });
+
+ it('should close the autocomplete suggestions on Escape press', () => {
+ cy.intercept(
+ { method: 'GET', url: '/search/usernames' },
+ { fixture: 'search/usernames.json' },
+ );
+
+ cy.findByLabelText('Add a comment to the discussion').click();
+ cy.findByRole('combobox');
+
+ cy.findByLabelText('Add a comment to the discussion').as(
+ 'autocompleteCommentBox',
+ );
+
+ cy.get('@autocompleteCommentBox').type('Some text @search');
+ cy.findByText('@search_user_1').should('be.visible');
+
+ cy.get('@autocompleteCommentBox').type('{Esc}');
+ cy.findByText('@search_user_1').should('not.be.visible');
+ });
+
+ it('should reply to a comment with user mention autocomplete', () => {
+ cy.intercept(
+ { method: 'GET', url: '/search/usernames' },
+ { fixture: 'search/usernames.json' },
+ );
+
+ cy.findByLabelText('Add a comment to the discussion').click();
+ cy.findByRole('combobox');
+
+ cy.findByLabelText('Add a comment to the discussion').type('first comment');
+ cy.findByRole('button', { name: /Submit/ }).click();
+
+ cy.findByRole('link', { name: /Reply/ }).click();
+
+ cy.findByRole('combobox', { name: /Reply to a comment/ }).as(
+ 'replyCombobox',
+ );
+ cy.get('@replyCombobox').click();
+ cy.get('@replyCombobox').type('Some text @search_user');
+
+ cy.findByText('@search_user_1').click();
+ cy.findByDisplayValue('Some text @search_user_1');
+ });
+});