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

@ -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",
});
});
});