blob: 7ad34293a8032ff29d7a8eaa3fb3b380b5a84675 [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
import './gr-suggestion-textarea';
import {GrSuggestionTextarea} from './gr-suggestion-textarea';
import {
Item,
ItemSelectedEventDetail,
} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import {
mockPromise,
pressKey,
stubFlags,
stubRestApi,
waitUntil,
} from '../../../test/test-utils';
import {fixture, html, assert} from '@open-wc/testing';
import {createAccountWithEmail} from '../../../test/test-data-generators';
import {Key} from '../../../utils/dom-util';
import {KnownExperimentId} from '../../../services/flags/flags';
suite('gr-suggestion-textarea tests with <gr-textarea>', () =>
createSuite(true)
);
suite('gr-suggestion-textarea tests with <iron-autogrow-textarea>', () =>
createSuite(false)
);
function createSuite(grTextareaEnabled: boolean) {
let element: GrSuggestionTextarea;
const setText = async (text: string) => {
element.text = text;
await element.updateComplete;
await element.textarea!.updateComplete;
element.wrapper.setCursorPosition(text.length);
element.handleTextChanged();
await element.updateComplete;
};
setup(async () => {
stubFlags('isEnabled')
.withArgs(KnownExperimentId.GR_TEXTAREA)
.returns(grTextareaEnabled);
element = await fixture<GrSuggestionTextarea>(
html`<gr-suggestion-textarea></gr-suggestion-textarea>`
);
sinon.stub(element.reporting, 'reportInteraction');
await element.updateComplete;
});
test('renders', () => {
const textareaHtml = grTextareaEnabled
? /* HTML */ `
<gr-textarea putcursoratendonfocus id="textarea"> </gr-textarea>
`
: /* HTML */ `
<iron-autogrow-textarea
aria-disabled="false"
focused=""
id="textarea"
>
</iron-autogrow-textarea>
`;
assert.shadowDom.equal(
element,
/* HTML */ `<div id="hiddenText"></div>
<span id="caratSpan"> </span>
<gr-autocomplete-dropdown id="emojiSuggestions" is-hidden="">
</gr-autocomplete-dropdown>
<gr-autocomplete-dropdown
id="mentionsSuggestions"
is-hidden=""
role="listbox"
>
</gr-autocomplete-dropdown>
${textareaHtml}`,
{
// gr-autocomplete-dropdown sizing seems to vary between local & CI
ignoreAttributes: [
{tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
],
}
);
});
suite('mention users', () => {
test('mentions selector is open when @ is typed & the textarea has focus', async () => {
// Needed for Safari tests. selectionStart is not updated when text is
// updated.
const listenerStub = sinon.stub();
element.addEventListener('text-changed', listenerStub);
stubRestApi('queryAccounts').returns(
Promise.resolve([
createAccountWithEmail('abc@google.com'),
createAccountWithEmail('abcdef@google.com'),
])
);
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText('@');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.equal(listenerStub.lastCall.args[0].detail.value, '@');
assert.isTrue(element.wrapper.isFocused());
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
assert.equal(element.specialCharIndex, 0);
assert.isFalse(element.mentionsSuggestions!.isHidden);
assert.equal(element.currentSearchString, '');
await setText('@abc@google.com');
assert.equal(element.currentSearchString, 'abc@google.com');
assert.equal(element.specialCharIndex, 0);
});
test('mention selector opens when previous char is \n', async () => {
stubRestApi('queryAccounts').returns(
Promise.resolve([
{
...createAccountWithEmail('abc@google.com'),
name: 'A',
display_name: 'display A',
},
{...createAccountWithEmail('abcdef@google.com'), name: 'B'},
])
);
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText('\n@');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.deepEqual(element.suggestions, [
{
dataValue: 'abc@google.com',
text: 'display A <abc@google.com>',
},
{
dataValue: 'abcdef@google.com',
text: 'B <abcdef@google.com>',
},
]);
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
});
test('mention suggestions cleared before request returns', async () => {
const promise = mockPromise<Item[]>();
stubRestApi('queryAccounts').returns(promise);
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
element.suggestions = [
{dataValue: 'prior@google.com', text: 'Prior suggestion'},
];
await setText('@');
await element.updateComplete;
assert.equal(element.suggestions.length, 0);
promise.resolve([
createAccountWithEmail('abc@google.com'),
createAccountWithEmail('abcdef@google.com'),
]);
await waitUntil(() => element.suggestions.length !== 0);
assert.deepEqual(element.suggestions, [
{
dataValue: 'abc@google.com',
text: 'abc@google.com <abc@google.com>',
},
{
dataValue: 'abcdef@google.com',
text: 'abcdef@google.com <abcdef@google.com>',
},
]);
});
test('mention dropdown shows suggestion for latest text', async () => {
const promise1 = mockPromise<Item[]>();
const promise2 = mockPromise<Item[]>();
const suggestionStub = stubRestApi('queryAccounts');
suggestionStub.returns(promise1);
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText('@');
assert.equal(element.currentSearchString, '');
suggestionStub.returns(promise2);
await setText('@abc@google.com');
// None of suggestions returned yet.
assert.equal(element.suggestions.length, 0);
await element.updateComplete;
assert.equal(element.currentSearchString, 'abc@google.com');
promise2.resolve([
createAccountWithEmail('abc@google.com'),
createAccountWithEmail('abcdef@google.com'),
]);
await waitUntil(() => element.suggestions.length !== 0);
assert.deepEqual(element.suggestions, [
{
dataValue: 'abc@google.com',
text: 'abc@google.com <abc@google.com>',
},
{
dataValue: 'abcdef@google.com',
text: 'abcdef@google.com <abcdef@google.com>',
},
]);
promise1.resolve([
createAccountWithEmail('dce@google.com'),
createAccountWithEmail('defcba@google.com'),
]);
// Empty the event queue.
await new Promise<void>(resolve => {
setTimeout(() => resolve());
});
// Suggestions didn't change
assert.deepEqual(element.suggestions, [
{
dataValue: 'abc@google.com',
text: 'abc@google.com <abc@google.com>',
},
{
dataValue: 'abcdef@google.com',
text: 'abcdef@google.com <abcdef@google.com>',
},
]);
});
test('selecting mentions from dropdown', async () => {
stubRestApi('queryAccounts').returns(
Promise.resolve([
createAccountWithEmail('abc@google.com'),
createAccountWithEmail('abcdef@google.com'),
])
);
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText('@');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
pressKey(element, 'ArrowDown');
await element.updateComplete;
pressKey(element, 'ArrowDown');
await element.updateComplete;
pressKey(element, Key.ENTER);
await element.updateComplete;
assert.equal(element.text, '@abcdef@google.com');
});
test('emoji dropdown does not open if mention dropdown is open', async () => {
const listenerStub = sinon.stub();
element.addEventListener('text-changed', listenerStub);
stubRestApi('queryAccounts').returns(
Promise.resolve([
createAccountWithEmail('abc@google.com'),
createAccountWithEmail('abcdef@google.com'),
])
);
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText('@');
element.suggestions = [
{
name: 'a',
value: 'a',
},
];
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
await setText('@h');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
await setText('@h');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
await setText('@h:');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
await setText('@h:D');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
});
test('mention dropdown does not open if emoji dropdown is open', async () => {
const listenerStub = sinon.stub();
element.addEventListener('text-changed', listenerStub);
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText(':');
element.suggestions = [
{
name: 'a',
value: 'a',
},
];
await element.updateComplete;
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
await setText(':D');
await element.updateComplete;
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
await setText(':D@');
await element.updateComplete;
// emoji dropdown hidden since we have no more suggestions
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
await setText(':D@b');
await element.updateComplete;
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
await setText(':D@b ');
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
});
test('mention dropdown is cleared if @ is deleted', async () => {
stubRestApi('queryAccounts').returns(
Promise.resolve([
createAccountWithEmail('abc@google.com'),
createAccountWithEmail('abcdef@google.com'),
])
);
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText('@');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isFalse(element.mentionsSuggestions!.isHidden);
element.text = '';
await element.updateComplete;
assert.isTrue(element.mentionsSuggestions!.isHidden);
});
});
test('monospace is set properly', () => {
assert.isFalse(element.classList.contains('monospace'));
});
test('hideBorder is set properly', () => {
assert.isFalse(element.textarea!.classList.contains('noBorder'));
});
test('emoji selector is not open when the textarea lacks focus', async () => {
// by default textarea has focus when rendered
// explicitly remove focus from the element for the test
element.blur();
element.wrapper.setCursorPosition(1);
element.text = ':';
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
});
test('emoji selector is not open when a general text is entered', async () => {
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
element.wrapper.setCursorPosition(9);
element.text = 'some text';
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
});
test('emoji selector is open when a colon is typed & the textarea has focus', async () => {
// Needed for Safari tests. selectionStart is not updated when text is
// updated.
const listenerStub = sinon.stub();
element.addEventListener('text-changed', listenerStub);
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText(':');
assert.equal(listenerStub.lastCall.args[0].detail.value, ':');
assert.isTrue(element.wrapper.isFocused());
await element.updateComplete;
await element.textarea!.updateComplete;
await element.emojiSuggestions!.updateComplete;
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.equal(element.specialCharIndex, 0);
assert.isTrue(!element.emojiSuggestions!.isHidden);
assert.equal(element.currentSearchString, '');
});
test('emoji selector opens when a colon is typed after space', async () => {
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText(' :');
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.equal(element.specialCharIndex, 1);
assert.isTrue(!element.emojiSuggestions!.isHidden);
assert.equal(element.currentSearchString, '');
});
test('emoji selector doesn`t open when a colon is typed after character', async () => {
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText('test:');
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isTrue(element.emojiSuggestions!.isHidden);
});
test('emoji selector opens when a colon is typed and some substring', async () => {
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText(':');
await setText(':t');
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.equal(element.specialCharIndex, 0);
assert.isTrue(!element.emojiSuggestions!.isHidden);
assert.equal(element.currentSearchString, 't');
});
test('emoji selector opens when a colon is typed in middle of text', async () => {
element.textarea!.focus();
// Needed for Safari tests. selectionStart is not updated when text is
// updated.
element.wrapper.setCursorPosition(1);
// Since selectionStart is on Chrome set always on end of text, we
// stub it to 1
const text = ': hello';
sinon.stub(element.wrapper, 'getCursorPosition').returns(1);
element.text = text;
await element.updateComplete;
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.equal(element.specialCharIndex, 0);
assert.isTrue(!element.emojiSuggestions!.isHidden);
assert.equal(element.currentSearchString, '');
});
test('emoji selector closes when text changes before the colon', async () => {
element.textarea!.focus();
await waitUntil(() => element.wrapper.isFocused() === true);
await setText('test test ');
await setText('test test :');
// typing : opens the selector
assert.isFalse(element.emojiSuggestions!.isHidden);
await setText('test test :smi');
assert.equal(element.currentSearchString, 'smi');
assert.isFalse(element.emojiSuggestions!.isHidden);
element.text = 'test test test :smi';
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
});
test('resetDropdown', async () => {
const closeSpy = sinon.spy(element, 'closeDropdown');
element.resetDropdown();
assert.equal(element.currentSearchString, '');
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.equal(element.specialCharIndex, -1);
element.emojiSuggestions!.open();
await element.updateComplete;
element.resetDropdown();
assert.isTrue(closeSpy.called);
});
test('determineEmojiSuggestions', () => {
const emojiText = 'tear';
const formatSpy = sinon.spy(element, 'formatSuggestions');
element.computeEmojiSuggestions(emojiText);
assert.isTrue(formatSpy.called);
assert.isTrue(
formatSpy.lastCall.calledWithExactly([
{
dataValue: '😂',
value: '😂',
match: "tears :')",
text: "😂 tears :')",
},
{dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
])
);
});
test('formatSuggestions', () => {
const matchedSuggestions = [
{value: '😢', match: 'tear'},
{value: '😂', match: 'tears'},
];
const suggestions = element.formatSuggestions(matchedSuggestions);
assert.deepEqual(
[
{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
{value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'},
],
suggestions
);
});
test('handleDropdownItemSelect', async () => {
element.text = 'test test :tears';
await element.updateComplete;
await element.textarea!.updateComplete;
element.wrapper.setCursorPosition(16);
element.specialCharIndex = 10;
element.handleTextChanged();
await element.updateComplete;
const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
const event = new CustomEvent<ItemSelectedEventDetail>('item-selected', {
detail: {trigger: 'click', selected: selectedItem},
});
element.handleDropdownItemSelect(event);
assert.equal(element.text, 'test test 😂');
// wait for reset dropdown to finish
await waitUntil(() => element.specialCharIndex === -1);
element.text = 'test test :tears';
await element.updateComplete;
await element.textarea!.updateComplete;
element.wrapper.setCursorPosition(16);
await element.updateComplete;
element.specialCharIndex = 10;
element.handleTextChanged();
// move the cursor to the left while the suggestion popup is open
element.wrapper.setCursorPosition(0);
element.handleDropdownItemSelect(event);
assert.equal(element.text, 'test test 😂');
// wait for reset dropdown to finish
await waitUntil(() => element.specialCharIndex === -1);
element.wrapper.setCursorPosition(16);
const text = 'test test :tears happy';
// Since selectionStart is on Chrome set always on end of text, we
// stub it to 16
const stub = sinon.stub(element.wrapper, 'getCursorPosition').returns(16);
element.text = text;
element.specialCharIndex = 10;
await element.updateComplete;
stub.restore();
// move the cursor to the right while the suggestion popup is open
element.wrapper.setCursorPosition(22);
element.handleDropdownItemSelect(event);
assert.equal(element.text, 'test test 😂 happy');
});
test('updateCaratPosition', async () => {
await setText('test');
element.updateCaratPosition();
assert.deepEqual(
element.hiddenText!.innerHTML,
element.text + element.caratSpan!.outerHTML
);
});
test('newline receives matching indentation', async () => {
const indentCommand = sinon.stub(document, 'execCommand');
await setText(' a');
element.handleEnterByKey(new KeyboardEvent('keydown', {key: 'Enter'}));
await element.updateComplete;
assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n ']);
});
test('emoji dropdown is closed when dropdown-closed is fired', async () => {
const resetSpy = sinon.spy(element, 'closeDropdown');
element.emojiSuggestions!.dispatchEvent(
new CustomEvent('dropdown-closed', {
composed: true,
bubbles: true,
})
);
await element.updateComplete;
assert.isTrue(resetSpy.called);
});
suite('keyboard shortcuts', async () => {
test('escape key', async () => {
const resetSpy = sinon.spy(element, 'resetDropdown');
pressKey(element.textarea! as HTMLElement, Key.ESC);
assert.isFalse(resetSpy.called);
await setText(':1');
pressKey(element.textarea! as HTMLElement, Key.ESC);
assert.isTrue(resetSpy.called);
assert.isTrue(element.emojiSuggestions!.isHidden);
});
test('up key', async () => {
const upSpy = sinon.spy(element.emojiSuggestions!, 'cursorUp');
pressKey(element.textarea! as HTMLElement, 'ArrowUp');
assert.isFalse(upSpy.called);
await setText(':1');
pressKey(element.textarea! as HTMLElement, 'ArrowUp');
assert.isTrue(upSpy.called);
});
test('down key', async () => {
const downSpy = sinon.spy(element.emojiSuggestions!, 'cursorDown');
pressKey(element.textarea! as HTMLElement, 'ArrowDown');
assert.isFalse(downSpy.called);
await setText(':1');
pressKey(element.textarea! as HTMLElement, 'ArrowDown');
assert.isTrue(downSpy.called);
});
test('enter key', async () => {
const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
pressKey(element.textarea! as HTMLElement, Key.ENTER);
assert.isFalse(enterSpy.called);
await setText(':1');
pressKey(element.textarea! as HTMLElement, Key.ENTER);
assert.isTrue(enterSpy.called);
await element.updateComplete;
assert.equal(element.text, '💯');
});
});
suite('gr-suggestion-textarea monospace', () => {
let element: GrSuggestionTextarea;
setup(async () => {
element = await fixture<GrSuggestionTextarea>(
html`<gr-suggestion-textarea monospace></gr-suggestion-textarea>`
);
await element.updateComplete;
});
test('monospace is set properly', () => {
assert.isTrue(element.classList.contains('monospace'));
});
});
suite('gr-suggestion-textarea hideBorder', () => {
let element: GrSuggestionTextarea;
setup(async () => {
element = await fixture<GrSuggestionTextarea>(
html`<gr-suggestion-textarea hide-border></gr-suggestion-textarea>`
);
await element.updateComplete;
});
test('hideBorder is set properly', () => {
assert.isTrue(element.textarea!.classList.contains('noBorder'));
});
});
}