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'); + }); +});