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:
parent
68ff19fb4b
commit
0889dc55da
3 changed files with 506 additions and 126 deletions
|
@ -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");
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue