Add (updated) mention autocomplete to post comments (#13061)
* add mention autocomplete functionality to article and notification comments * add cypress tests * make sure entry transitions dont replay * undo change to async useeffect covered in other pr * move search into dynamic import * exit search if enter pressed in middle of search term * refactor button activation code * add a space after a mention is selected * tweak cypress test for reply to ensure it awaits combobox * small refactor
This commit is contained in:
parent
3f6600541e
commit
a23caead07
9 changed files with 261 additions and 19 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<MentionAutocompleteTextArea /> should render 1`] = `"<div aria-live=\\"polite\\" class=\\"screen-reader-only\\"></div><div id=\\"combobox-container\\" class=\\"crayons-autocomplete\\" data-reach-combobox=\\"\\" data-state=\\"idle\\"><textarea aria-autocomplete=\\"both\\" aria-controls=\\"listbox--combobox-container\\" aria-expanded=\\"false\\" aria-haspopup=\\"listbox\\" role=\\"combobox\\" data-mention-autocomplete-active=\\"true\\" data-reach-combobox-input=\\"\\" data-state=\\"idle\\" aria-label=\\"test text area\\" id=\\"test-text-area\\" style=\\"font: -webkit-small-control; text-rendering: auto; letter-spacing: normal; word-spacing: normal; line-height: normal; text-transform: none; text-indent: 0; text-shadow: none; display: inline-block; text-align: start; background-color: white; border: 1px solid; flex-direction: column; resize: auto; cursor: auto; padding: 2px; white-space: pre-wrap; word-wrap: break-word; visibility: visible;\\"></textarea></div>"`;
|
||||
exports[`<MentionAutocompleteTextArea /> should render 1`] = `"<div aria-live=\\"polite\\" class=\\"screen-reader-only\\"></div><div id=\\"combobox-container\\" class=\\"crayons-autocomplete\\" data-reach-combobox=\\"\\" data-state=\\"idle\\"><textarea aria-autocomplete=\\"both\\" aria-controls=\\"listbox--combobox-container\\" aria-expanded=\\"false\\" aria-haspopup=\\"listbox\\" role=\\"combobox\\" data-mention-autocomplete-active=\\"true\\" data-reach-combobox-input=\\"\\" data-state=\\"idle\\" aria-label=\\"test text area\\" id=\\"test-text-area\\" style=\\"font: -webkit-small-control; text-rendering: auto; letter-spacing: normal; word-spacing: normal; line-height: normal; text-transform: none; text-indent: 0; text-shadow: none; display: inline-block; text-align: start; background-color: white; border: 1px solid; flex-direction: column; resize: auto; cursor: auto; padding: 2px; white-space: pre-wrap; word-wrap: break-word; visibility: visible; transition: none;\\"></textarea></div>"`;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<MentionAutocompleteTextArea
|
||||
replaceElement={originalTextArea}
|
||||
fetchSuggestions={(username) => fetchSearch('usernames', { username })}
|
||||
/>,
|
||||
parentContainer,
|
||||
originalTextArea,
|
||||
);
|
||||
};
|
||||
|
||||
window.showModal = async ({
|
||||
title,
|
||||
contentSelector,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)" %>
|
||||
<div class="actions">
|
||||
<button type="submit" class="crayons-btn comment-action-button" onclick="validateField(event)">Submit</button>
|
||||
</div>
|
||||
|
|
|
|||
1
cypress/fixtures/search/emptyUsernamesSearch.json
Normal file
1
cypress/fixtures/search/emptyUsernamesSearch.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "result": [] }
|
||||
39
cypress/fixtures/search/usernames.json
Normal file
39
cypress/fixtures/search/usernames.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
169
cypress/integration/articleFlows/commentOnArticle.spec.js
Normal file
169
cypress/integration/articleFlows/commentOnArticle.spec.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue