Room and user mentions for plain text editor (#10665)

* update useSuggestion

* update useSuggestion-tests

* add processMention tests

* add test

* add getMentionOrCommand tests

* change mock href for codeQL reasons

* fix TS issue in test

* add a big old cypress test

* fix lint error

* update comments

* reorganise functions in order of importance

* rename functions and variables

* add endOffset to return object

* fix failing tests

* update function names and comments

* update comment, remove delay

* update comments and early return

* nest mappedSuggestion inside Suggestion state and update test

* rename suggestion => suggestionData

* update comment

* add argument to findSuggestionInText

* make findSuggestionInText return mappedSuggestion

* fix TS error

* update comments and index check from === -1 to < 0

* tidy logic in increment functions

* rename variable

* Big refactor to address multiple comments, improve behaviour and add tests

* improve comments

* tidy up comment

* extend comment

* combine similar returns

* update comment

* remove single use variable

* fix comments
This commit is contained in:
alunturner 2023-05-11 15:28:42 +01:00 committed by GitHub
parent 68ff19fb4b
commit 0889dc55da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 506 additions and 126 deletions

View file

@ -15,9 +15,11 @@ limitations under the License.
*/
/// <reference types="cypress" />
import { EventType } from "matrix-js-sdk/src/@types/event";
import { HomeserverInstance } from "../../plugins/utils/homeserver";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { MatrixClient } from "../../global";
describe("Composer", () => {
let homeserver: HomeserverInstance;
@ -181,6 +183,81 @@ describe("Composer", () => {
});
});
describe("Mentions", () => {
// TODO add tests for rich text mode
describe("Plain text mode", () => {
it("autocomplete behaviour tests", () => {
// Setup a private room so we have another user to mention
const otherUserName = "Bob";
let bobClient: MatrixClient;
cy.getBot(homeserver, {
displayName: otherUserName,
}).then((bob) => {
bobClient = bob;
});
// create DM with bob
cy.getClient().then(async (cli) => {
const bobRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bobClient.getUserId());
await cli.setAccountData("m.direct" as EventType, {
[bobClient.getUserId()]: [bobRoom.room_id],
});
});
cy.viewRoomByName("Bob");
// Select plain text mode after composer is ready
cy.get("div[contenteditable=true]").should("exist");
cy.findByRole("button", { name: "Hide formatting" }).click();
// Typing a single @ does not display the autocomplete menu and contents
cy.findByRole("textbox").type("@");
cy.findByTestId("autocomplete-wrapper").should("be.empty");
// Entering the first letter of the other user's name opens the autocomplete...
cy.findByRole("textbox").type(otherUserName.slice(0, 1));
cy.findByTestId("autocomplete-wrapper")
.should("not.be.empty")
.within(() => {
// ...with the other user name visible, and clicking that username...
cy.findByText(otherUserName).should("exist").click();
});
// ...inserts the username into the composer
cy.findByRole("textbox").within(() => {
// TODO update this test when the mentions are inserted as pills, instead
// of as text
cy.findByText(otherUserName, { exact: false }).should("exist");
});
// Send the message to clear the composer
cy.findByRole("button", { name: "Send message" }).click();
// Typing an @, then other user's name, then trailing space closes the autocomplete
cy.findByRole("textbox").type(`@${otherUserName} `);
cy.findByTestId("autocomplete-wrapper").should("be.empty");
// Send the message to clear the composer
cy.findByRole("button", { name: "Send message" }).click();
// Moving the cursor back to an "incomplete" mention opens the autocomplete
cy.findByRole("textbox").type(`initial text @${otherUserName.slice(0, 1)} abc`);
cy.findByTestId("autocomplete-wrapper").should("be.empty");
// Move the cursor left by 4 to put it to: `@B| abc`, check autocomplete displays
cy.findByRole("textbox").type(`${"{leftArrow}".repeat(4)}`);
cy.findByTestId("autocomplete-wrapper").should("not.be.empty");
// Selecting the autocomplete option using Enter inserts it into the composer
cy.findByRole("textbox").type(`{Enter}`);
cy.findByRole("textbox").within(() => {
// TODO update this test when the mentions are inserted as pills, instead
// of as text
cy.findByText(otherUserName, { exact: false }).should("exist");
});
});
});
});
it("sends a message when you click send or press Enter", () => {
// Type a message
cy.get("div[contenteditable=true]").type("my message 0");

View file

@ -20,13 +20,13 @@ import { SyntheticEvent, useState } from "react";
/**
* Information about the current state of the `useSuggestion` hook.
*/
export type Suggestion = MappedSuggestion & {
/**
* The information in a `MappedSuggestion` is sufficient to generate a query for the autocomplete
* component but more information is required to allow manipulation of the correct part of the DOM
* when selecting an option from the autocomplete. These three pieces of information allow us to
* do that.
*/
export type Suggestion = {
mappedSuggestion: MappedSuggestion;
/* The information in a `MappedSuggestion` is sufficient to generate a query for the autocomplete
component but more information is required to allow manipulation of the correct part of the DOM
when selecting an option from the autocomplete. These three pieces of information allow us to
do that.
*/
node: Node;
startOffset: number;
endOffset: number;
@ -39,38 +39,37 @@ type SuggestionState = Suggestion | null;
* @param editorRef - a ref to the div that is the composer textbox
* @param setText - setter function to set the content of the composer
* @returns
* - `handleMention`: TODO a function that will insert @ or # mentions which are selected from
* the autocomplete into the composer
* - `handleMention`: a function that will insert @ or # mentions which are selected from
* the autocomplete into the composer, given an href, the text to display, and any additional attributes
* - `handleCommand`: a function that will replace the content of the composer with the given replacement text.
* Can be used to process autocomplete of slash commands
* - `onSelect`: a selection change listener to be attached to the plain text composer
* - `suggestion`: if the cursor is inside something that could be interpreted as a command or a mention,
* this will be an object representing that command or mention, otherwise it is null
*/
export function useSuggestion(
editorRef: React.RefObject<HTMLDivElement>,
setText: (text: string) => void,
): {
handleMention: (link: string, text: string, attributes: Attributes) => void;
handleMention: (href: string, displayName: string, attributes: Attributes) => void;
handleCommand: (text: string) => void;
onSelect: (event: SyntheticEvent<HTMLDivElement>) => void;
suggestion: MappedSuggestion | null;
} {
const [suggestion, setSuggestion] = useState<SuggestionState>(null);
const [suggestionData, setSuggestionData] = useState<SuggestionState>(null);
// TODO handle the mentions (@user, #room etc)
const handleMention = (): void => {};
// We create a `seletionchange` handler here because we need to know when the user has moved the cursor,
// We create a `selectionchange` handler here because we need to know when the user has moved the cursor,
// we can not depend on input events only
const onSelect = (): void => processSelectionChange(editorRef, suggestion, setSuggestion);
const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData);
const handleMention = (href: string, displayName: string, attributes: Attributes): void =>
processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText);
const handleCommand = (replacementText: string): void =>
processCommand(replacementText, suggestion, setSuggestion, setText);
processCommand(replacementText, suggestionData, setSuggestionData, setText);
return {
suggestion: mapSuggestion(suggestion),
suggestion: suggestionData?.mappedSuggestion ?? null,
handleCommand,
handleMention,
onSelect,
@ -78,41 +77,118 @@ export function useSuggestion(
}
/**
* Convert a PlainTextSuggestionPattern (or null) to a MappedSuggestion (or null)
* When the selection changes inside the current editor, check to see if the cursor is inside
* something that could be a command or a mention and update the suggestion state if so
*
* @param suggestion - the suggestion that is the JS equivalent of the rust model's representation
* @returns - null if the input is null, a MappedSuggestion if the input is non-null
* @param editorRef - ref to the composer
* @param setSuggestionData - the setter for the suggestion state
*/
export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | null => {
if (suggestion === null) {
return null;
} else {
const { node, startOffset, endOffset, ...mappedSuggestion } = suggestion;
return mappedSuggestion;
export function processSelectionChange(
editorRef: React.RefObject<HTMLDivElement>,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
): void {
const selection = document.getSelection();
// return early if we do not have a current editor ref with a cursor selection inside a text node
if (
editorRef.current === null ||
selection === null ||
!selection.isCollapsed ||
selection.anchorNode?.nodeName !== "#text"
) {
setSuggestionData(null);
return;
}
};
// from here onwards we have a cursor inside a text node
const { anchorNode: currentNode, anchorOffset: currentOffset } = selection;
// if we have no text content, return, clearing the suggestion state
if (currentNode.textContent === null) {
setSuggestionData(null);
return;
}
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
const isFirstTextNode = currentNode === firstTextNode;
const foundSuggestion = findSuggestionInText(currentNode.textContent, currentOffset, isFirstTextNode);
// if we have not found a suggestion, return, clearing the suggestion state
if (foundSuggestion === null) {
setSuggestionData(null);
return;
}
setSuggestionData({
mappedSuggestion: foundSuggestion.mappedSuggestion,
node: currentNode,
startOffset: foundSuggestion.startOffset,
endOffset: foundSuggestion.endOffset,
});
}
/**
* Replaces the relevant part of the editor text with a link representing a mention after it
* is selected from the autocomplete.
*
* @param href - the href that the inserted link will use
* @param displayName - the text content of the link
* @param attributes - additional attributes to add to the link, can include data-* attributes
* @param suggestionData - representation of the part of the DOM that will be replaced
* @param setSuggestionData - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export function processMention(
href: string,
displayName: string,
attributes: Attributes, // these will be used when formatting the link as a pill
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text: string) => void,
): void {
// if we do not have a suggestion, return early
if (suggestionData === null) {
return;
}
const { node } = suggestionData;
const textBeforeReplacement = node.textContent?.slice(0, suggestionData.startOffset) ?? "";
const textAfterReplacement = node.textContent?.slice(suggestionData.endOffset) ?? "";
// TODO replace this markdown style text insertion with a pill representation
const newText = `[${displayName}](<${href}>) `;
const newCursorOffset = textBeforeReplacement.length + newText.length;
const newContent = textBeforeReplacement + newText + textAfterReplacement;
// insert the new text, move the cursor, set the text state, clear the suggestion state
node.textContent = newContent;
document.getSelection()?.setBaseAndExtent(node, newCursorOffset, node, newCursorOffset);
setText(newContent);
setSuggestionData(null);
}
/**
* Replaces the relevant part of the editor text with the replacement text after a command is selected
* from the autocomplete.
*
* @param replacementText - the text that we will insert into the DOM
* @param suggestion - representation of the part of the DOM that will be replaced
* @param setSuggestion - setter function to set the suggestion state
* @param suggestionData - representation of the part of the DOM that will be replaced
* @param setSuggestionData - setter function to set the suggestion state
* @param setText - setter function to set the content of the composer
*/
export const processCommand = (
export function processCommand(
replacementText: string,
suggestion: SuggestionState,
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
suggestionData: SuggestionState,
setSuggestionData: React.Dispatch<React.SetStateAction<SuggestionState>>,
setText: (text: string) => void,
): void => {
): void {
// if we do not have a suggestion, return early
if (suggestion === null) {
if (suggestionData === null) {
return;
}
const { node } = suggestion;
const { node } = suggestionData;
// for a command, we know we start at the beginning of the text node, so build the replacement
// string (note trailing space) and manually adjust the node's textcontent
@ -123,70 +199,120 @@ export const processCommand = (
// hook and clear the suggestion from state
document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length);
setText(newContent);
setSuggestion(null);
};
setSuggestionData(null);
}
/**
* When the selection changes inside the current editor, check to see if the cursor is inside
* something that could require the autocomplete to be opened and update the suggestion state
* if so
* TODO expand this to handle mentions
* Given some text content from a node and the cursor position, find the word that the cursor is currently inside
* and then test that word to see if it is a suggestion. Return the `MappedSuggestion` with start and end offsets if
* the cursor is inside a valid suggestion, null otherwise.
*
* @param editorRef - ref to the composer
* @param suggestion - the current suggestion state
* @param setSuggestion - the setter for the suggestion state
* @param text - the text content of a node
* @param offset - the current cursor offset position within the node
* @param isFirstTextNode - whether or not the node is the first text node in the editor. Used to determine
* if a command suggestion is found or not
* @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null
*/
export const processSelectionChange = (
editorRef: React.RefObject<HTMLDivElement>,
suggestion: SuggestionState,
setSuggestion: React.Dispatch<React.SetStateAction<SuggestionState>>,
): void => {
const selection = document.getSelection();
export function findSuggestionInText(
text: string,
offset: number,
isFirstTextNode: boolean,
): { mappedSuggestion: MappedSuggestion; startOffset: number; endOffset: number } | null {
// Return null early if the offset is outside the content
if (offset < 0 || offset > text.length) {
return null;
}
// return early if we do not have a current editor ref with a cursor selection inside a text node
// Variables to keep track of the indices we will be slicing from and to in order to create
// a substring of the word that the cursor is currently inside
let startSliceIndex = offset;
let endSliceIndex = offset;
// Search backwards from the current cursor position to find the start index of the word
// containing the cursor
while (shouldDecrementStartIndex(text, startSliceIndex)) {
startSliceIndex--;
}
// Search forwards from the current cursor position to find the end index of the word
// containing the cursor
while (shouldIncrementEndIndex(text, endSliceIndex)) {
endSliceIndex++;
}
// Get the word at the cursor then check if it contains a suggestion or not
const wordAtCursor = text.slice(startSliceIndex, endSliceIndex);
const mappedSuggestion = getMappedSuggestion(wordAtCursor);
/**
* If we have a word that could be a command, it is not a valid command if:
* - the node we're looking at isn't the first text node in the editor (adding paragraphs can
* result in nested <p> tags inside the editor <div>)
* - the starting index is anything other than 0 (they can only appear at the start of a message)
* - there is more text following the command (eg `/spo asdf|` should not be interpreted as
* something requiring autocomplete)
*/
if (
editorRef.current === null ||
selection === null ||
!selection.isCollapsed ||
selection.anchorNode?.nodeName !== "#text"
mappedSuggestion === null ||
(mappedSuggestion.type === "command" &&
(!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length))
) {
return;
return null;
}
// here we have established that both anchor and focus nodes in the selection are
// the same node, so rename to `currentNode` for later use
const { anchorNode: currentNode } = selection;
return { mappedSuggestion, startOffset: startSliceIndex, endOffset: startSliceIndex + wordAtCursor.length };
}
// first check is that the text node is the first text node of the editor, as adding paragraphs can result
// in nested <p> tags inside the editor <div>
const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode();
/**
* Associated function for findSuggestionInText. Checks the character at the preceding index
* to determine if the search loop should continue.
*
* @param text - text content to check for mentions or commands
* @param index - the current index to check
* @returns true if check should keep moving backwards, false otherwise
*/
function shouldDecrementStartIndex(text: string, index: number): boolean {
// If the index is at or outside the beginning of the string, return false
if (index <= 0) return false;
// if we're not in the first text node or we have no text content, return
if (currentNode !== firstTextNode || currentNode.textContent === null) {
return;
// We are inside the string so can guarantee that there is a preceding character
// Keep searching backwards if the preceding character is not a space
return !/\s/.test(text[index - 1]);
}
/**
* Associated function for findSuggestionInText. Checks the character at the current index
* to determine if the search loop should continue.
*
* @param text - text content to check for mentions or commands
* @param index - the current index to check
* @returns true if check should keep moving forwards, false otherwise
*/
function shouldIncrementEndIndex(text: string, index: number): boolean {
// If the index is at or outside the end of the string, return false
if (index >= text.length) return false;
// Keep searching forwards if the current character is not a space
return !/\s/.test(text[index]);
}
/**
* Given a string, return a `MappedSuggestion` if the string contains a suggestion. Otherwise return null.
*
* @param text - string to check for a suggestion
* @returns a `MappedSuggestion` if a suggestion is present, null otherwise
*/
export function getMappedSuggestion(text: string): MappedSuggestion | null {
const firstChar = text.charAt(0);
const restOfString = text.slice(1);
switch (firstChar) {
case "/":
return { keyChar: firstChar, text: restOfString, type: "command" };
case "#":
case "@":
return { keyChar: firstChar, text: restOfString, type: "mention" };
default:
return null;
}
// it's a command if:
// it is the first textnode AND
// it starts with /, not // AND
// then has letters all the way up to the end of the textcontent
const commandRegex = /^\/(\w*)$/;
const commandMatches = currentNode.textContent.match(commandRegex);
// if we don't have any matches, return, clearing the suggeston state if it is non-null
if (commandMatches === null) {
if (suggestion !== null) {
setSuggestion(null);
}
return;
} else {
setSuggestion({
keyChar: "/",
type: "command",
text: commandMatches[1],
node: selection.anchorNode,
startOffset: 0,
endOffset: currentNode.textContent.length,
});
}
};
}

View file

@ -17,16 +17,16 @@ import React from "react";
import {
Suggestion,
mapSuggestion,
findSuggestionInText,
getMappedSuggestion,
processCommand,
processMention,
processSelectionChange,
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion";
function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}): Suggestion {
return {
keyChar: "/",
type: "command",
text: "some text",
mappedSuggestion: { keyChar: "/", type: "command", text: "some text", ...props.mappedSuggestion },
node: document.createTextNode(""),
startOffset: 0,
endOffset: 0,
@ -34,24 +34,6 @@ function createMockPlainTextSuggestionPattern(props: Partial<Suggestion> = {}):
};
}
describe("mapSuggestion", () => {
it("returns null if called with a null argument", () => {
expect(mapSuggestion(null)).toBeNull();
});
it("returns a mapped suggestion when passed a suggestion", () => {
const inputFields = {
keyChar: "/" as const,
type: "command" as const,
text: "some text",
};
const input = createMockPlainTextSuggestionPattern(inputFields);
const output = mapSuggestion(input);
expect(output).toEqual(inputFields);
});
});
describe("processCommand", () => {
it("does not change parent hook state if suggestion is null", () => {
// create a mockSuggestion using the text node above
@ -85,6 +67,48 @@ describe("processCommand", () => {
});
});
describe("processMention", () => {
// TODO refactor and expand tests when mentions become <a> tags
it("returns early when suggestion is null", () => {
const mockSetSuggestion = jest.fn();
const mockSetText = jest.fn();
processMention("href", "displayName", {}, null, mockSetSuggestion, mockSetText);
expect(mockSetSuggestion).not.toHaveBeenCalled();
expect(mockSetText).not.toHaveBeenCalled();
});
it("can insert a mention into an empty text node", () => {
// make an empty text node, set the cursor inside it and then append to the document
const textNode = document.createTextNode("");
document.body.appendChild(textNode);
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 0);
// call the util function
const href = "href";
const displayName = "displayName";
const mockSetSuggestion = jest.fn();
const mockSetText = jest.fn();
processMention(
href,
displayName,
{},
{ node: textNode, startOffset: 0, endOffset: 0 } as unknown as Suggestion,
mockSetSuggestion,
mockSetText,
);
// placeholder testing for the changed content - these tests will all be changed
// when the mention is inserted as an <a> tagfs
const { textContent } = textNode;
expect(textContent!.includes(href)).toBe(true);
expect(textContent!.includes(displayName)).toBe(true);
expect(mockSetText).toHaveBeenCalledWith(expect.stringContaining(displayName));
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
});
});
describe("processSelectionChange", () => {
function createMockEditorRef(element: HTMLDivElement | null = null): React.RefObject<HTMLDivElement> {
return { current: element } as React.RefObject<HTMLDivElement>;
@ -112,14 +136,14 @@ describe("processSelectionChange", () => {
// we monitor for the call to document.createNodeIterator to indicate an early return
const nodeIteratorSpy = jest.spyOn(document, "createNodeIterator");
processSelectionChange(mockEditorRef, null, jest.fn());
processSelectionChange(mockEditorRef, jest.fn());
expect(nodeIteratorSpy).not.toHaveBeenCalled();
// tidy up to avoid potential impacts on other tests
nodeIteratorSpy.mockRestore();
});
it("does not call setSuggestion if selection is not a cursor", () => {
it("calls setSuggestion with null if selection is not a cursor", () => {
const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content");
const mockEditorRef = createMockEditorRef(mockEditor);
@ -128,11 +152,11 @@ describe("processSelectionChange", () => {
document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 4);
// process the selection and check that we do not attempt to set the suggestion
processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
expect(mockSetSuggestion).not.toHaveBeenCalled();
processSelectionChange(mockEditorRef, mockSetSuggestion);
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
});
it("does not call setSuggestion if selection cursor is not inside a text node", () => {
it("calls setSuggestion with null if selection cursor is not inside a text node", () => {
const [mockEditor] = appendEditorWithTextNodeContaining("content");
const mockEditorRef = createMockEditorRef(mockEditor);
@ -140,8 +164,8 @@ describe("processSelectionChange", () => {
document.getSelection()?.setBaseAndExtent(mockEditor, 0, mockEditor, 0);
// process the selection and check that we do not attempt to set the suggestion
processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
expect(mockSetSuggestion).not.toHaveBeenCalled();
processSelectionChange(mockEditorRef, mockSetSuggestion);
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
});
it("calls setSuggestion with null if we have an existing suggestion but no command match", () => {
@ -153,7 +177,7 @@ describe("processSelectionChange", () => {
// the call to process the selection will have an existing suggestion in state due to the second
// argument being non-null, expect that we clear this suggestion now that the text is not a command
processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion);
processSelectionChange(mockEditorRef, mockSetSuggestion);
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
});
@ -166,14 +190,167 @@ describe("processSelectionChange", () => {
document.getSelection()?.setBaseAndExtent(textNode, 3, textNode, 3);
// process the change and check the suggestion that is set looks as we expect it to
processSelectionChange(mockEditorRef, null, mockSetSuggestion);
processSelectionChange(mockEditorRef, mockSetSuggestion);
expect(mockSetSuggestion).toHaveBeenCalledWith({
keyChar: "/",
type: "command",
text: "potentialCommand",
mappedSuggestion: {
keyChar: "/",
type: "command",
text: "potentialCommand",
},
node: textNode,
startOffset: 0,
endOffset: commandText.length,
});
});
it("does not treat a command outside the first text node to be a suggestion", () => {
const [mockEditor] = appendEditorWithTextNodeContaining("some text in first node");
const [, commandTextNode] = appendEditorWithTextNodeContaining("/potentialCommand");
const mockEditorRef = createMockEditorRef(mockEditor);
// create a selection in the text node that has identical start and end locations, ie it is a cursor
document.getSelection()?.setBaseAndExtent(commandTextNode, 3, commandTextNode, 3);
// process the change and check the suggestion that is set looks as we expect it to
processSelectionChange(mockEditorRef, mockSetSuggestion);
expect(mockSetSuggestion).toHaveBeenCalledWith(null);
});
});
describe("findSuggestionInText", () => {
const command = "/someCommand";
const userMention = "@userMention";
const roomMention = "#roomMention";
const mentionTestCases = [userMention, roomMention];
const allTestCases = [command, userMention, roomMention];
it("returns null if content does not contain any mention or command characters", () => {
expect(findSuggestionInText("hello", 1, true)).toBeNull();
});
it("returns null if content contains a command but is not the first text node", () => {
expect(findSuggestionInText(command, 1, false)).toBeNull();
});
it("returns null if the offset is outside the content length", () => {
expect(findSuggestionInText("hi", 30, true)).toBeNull();
expect(findSuggestionInText("hi", -10, true)).toBeNull();
});
it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => {
const expected = {
mappedSuggestion: getMappedSuggestion(text),
startOffset: 0,
endOffset: text.length,
};
// test for cursor immediately before and after special character, before end, at end
expect(findSuggestionInText(text, 0, true)).toEqual(expected);
expect(findSuggestionInText(text, 1, true)).toEqual(expected);
expect(findSuggestionInText(text, text.length - 2, true)).toEqual(expected);
expect(findSuggestionInText(text, text.length, true)).toEqual(expected);
});
it("returns null when a command is followed by other text", () => {
const followingText = " followed by something";
// check for cursor inside and outside the command
expect(findSuggestionInText(command + followingText, command.length - 2, true)).toBeNull();
expect(findSuggestionInText(command + followingText, command.length + 2, true)).toBeNull();
});
it.each(mentionTestCases)("returns an object when a %s is followed by other text", (mention) => {
const followingText = " followed by something else";
expect(findSuggestionInText(mention + followingText, mention.length - 2, true)).toEqual({
mappedSuggestion: getMappedSuggestion(mention),
startOffset: 0,
endOffset: mention.length,
});
});
it("returns null if there is a command surrounded by text", () => {
const precedingText = "text before the command ";
const followingText = " text after the command";
expect(
findSuggestionInText(precedingText + command + followingText, precedingText.length + 4, true),
).toBeNull();
});
it.each(mentionTestCases)("returns an object if %s is surrounded by text", (mention) => {
const precedingText = "I want to mention ";
const followingText = " in my message";
const textInput = precedingText + mention + followingText;
const expected = {
mappedSuggestion: getMappedSuggestion(mention),
startOffset: precedingText.length,
endOffset: precedingText.length + mention.length,
};
// when the cursor is immediately before the special character
expect(findSuggestionInText(textInput, precedingText.length, true)).toEqual(expected);
// when the cursor is inside the mention
expect(findSuggestionInText(textInput, precedingText.length + 3, true)).toEqual(expected);
// when the cursor is right at the end of the mention
expect(findSuggestionInText(textInput, precedingText.length + mention.length, true)).toEqual(expected);
});
it("returns null for text content with an email address", () => {
const emailInput = "send to user@test.com";
expect(findSuggestionInText(emailInput, 15, true)).toBeNull();
});
it("returns null for double slashed command", () => {
const doubleSlashCommand = "//not a command";
expect(findSuggestionInText(doubleSlashCommand, 4, true)).toBeNull();
});
it("returns null for slash separated text", () => {
const slashSeparatedInput = "please to this/that/the other";
expect(findSuggestionInText(slashSeparatedInput, 21, true)).toBeNull();
});
it("returns an object for a mention that contains punctuation", () => {
const mentionWithPunctuation = "@userX14#5a_-";
const precedingText = "mention ";
const mentionInput = precedingText + mentionWithPunctuation;
expect(findSuggestionInText(mentionInput, 12, true)).toEqual({
mappedSuggestion: getMappedSuggestion(mentionWithPunctuation),
startOffset: precedingText.length,
endOffset: precedingText.length + mentionWithPunctuation.length,
});
});
it("returns null when user inputs any whitespace after the special character", () => {
const mentionWithSpaceAfter = "@ somebody";
expect(findSuggestionInText(mentionWithSpaceAfter, 2, true)).toBeNull();
});
});
describe("getMappedSuggestion", () => {
it("returns null when the first character is not / # @", () => {
expect(getMappedSuggestion("Zzz")).toBe(null);
});
it("returns the expected mapped suggestion when first character is # or @", () => {
expect(getMappedSuggestion("@user-mention")).toEqual({
type: "mention",
keyChar: "@",
text: "user-mention",
});
expect(getMappedSuggestion("#room-mention")).toEqual({
type: "mention",
keyChar: "#",
text: "room-mention",
});
});
it("returns the expected mapped suggestion when first character is /", () => {
expect(getMappedSuggestion("/command")).toEqual({
type: "command",
keyChar: "/",
text: "command",
});
});
});