element-portable/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx
David Langley 491f0cd08a
Change license (#13)
* Copyright headers 1

* Licence headers 2

* Copyright Headers 3

* Copyright Headers 4

* Copyright Headers 5

* Copyright Headers 6

* Copyright headers 7

* Add copyright headers for html and config file

* Replace license files and update package.json

* Update with CLA

* lint
2024-09-09 13:57:16 +00:00

859 lines
34 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import "@testing-library/jest-dom";
import React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer";
import SettingsStore from "../../../../../../src/settings/SettingsStore";
import { flushPromises, mockPlatformPeg, stubClient, mkStubRoom } from "../../../../../test-utils";
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
import * as EventUtils from "../../../../../../src/utils/EventUtils";
import { Action } from "../../../../../../src/dispatcher/actions";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../../../../src/contexts/RoomContext";
import {
ComposerContext,
getDefaultContextValue,
} from "../../../../../../src/components/views/rooms/wysiwyg_composer/ComposerContext";
import { createMocks } from "../utils";
import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
import { SubSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/types";
import { setSelection } from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
import { parseEditorStateTransfer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent";
import Autocompleter, { ICompletion } from "../../../../../../src/autocomplete/Autocompleter";
import AutocompleteProvider from "../../../../../../src/autocomplete/AutocompleteProvider";
import * as Permalinks from "../../../../../../src/utils/permalinks/Permalinks";
import { PermalinkParts } from "../../../../../../src/utils/permalinks/PermalinkConstructor";
describe("WysiwygComposer", () => {
const customRender = (onChange = jest.fn(), onSend = jest.fn(), disabled = false, initialContent?: string) => {
const { mockClient, defaultRoomContext } = createMocks();
return render(
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={defaultRoomContext}>
<WysiwygComposer
onChange={onChange}
onSend={onSend}
disabled={disabled}
initialContent={initialContent}
/>
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
};
afterEach(() => {
jest.resetAllMocks();
});
it("Should have contentEditable at false when disabled", async () => {
// When
customRender(jest.fn(), jest.fn(), true);
// Then
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "false"));
});
describe("Standard behavior", () => {
const onChange = jest.fn();
const onSend = jest.fn();
beforeEach(async () => {
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
customRender(onChange, onSend);
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
});
afterEach(() => {
onChange.mockReset();
onSend.mockReset();
});
it("Should have contentEditable at true", async () => {
// Then
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
});
it("Should have focus", async () => {
// Then
await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus());
});
it("Should call onChange handler", async () => {
// When
fireEvent.input(screen.getByRole("textbox"), {
data: "foo bar",
inputType: "insertText",
});
// Then
await waitFor(() => expect(onChange).toHaveBeenCalledWith("foo bar"));
});
it("Should call onSend when Enter is pressed", async () => {
//When
fireEvent(
screen.getByRole("textbox"),
new InputEvent("input", {
inputType: "insertParagraph",
}),
);
// Then it sends a message
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
});
it("Should not call onSend when Shift+Enter is pressed", async () => {
//When
await userEvent.type(screen.getByRole("textbox"), "{shift>}{enter}");
// Then it sends a message
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
});
it("Should not call onSend when ctrl+Enter is pressed", async () => {
//When
// Using userEvent.type or .keyboard wasn't working as expected in the case of ctrl+enter
fireEvent(
screen.getByRole("textbox"),
new KeyboardEvent("keydown", {
ctrlKey: true,
code: "Enter",
}),
);
// Then it sends a message
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
});
it("Should not call onSend when alt+Enter is pressed", async () => {
//When
await userEvent.type(screen.getByRole("textbox"), "{alt>}{enter}");
// Then it sends a message
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
});
it("Should not call onSend when meta+Enter is pressed", async () => {
//When
await userEvent.type(screen.getByRole("textbox"), "{meta>}{enter}");
// Then it sends a message
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
});
});
describe("Mentions and commands", () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
const mockCompletions: ICompletion[] = [
{
type: "user",
href: "https://matrix.to/#/@user_1:element.io",
completion: "user_1",
completionId: "@user_1:host.local",
range: { start: 1, end: 1 },
component: <div>user_1</div>,
},
{
type: "user",
href: "https://matrix.to/#/@user_2:element.io",
completion: "user_2",
completionId: "@user_2:host.local",
range: { start: 1, end: 1 },
component: <div>user_2</div>,
},
{
// no href user
type: "user",
href: undefined,
completion: "user_without_href",
completionId: "@user_3:host.local",
range: { start: 1, end: 1 },
component: <div>user_without_href</div>,
},
{
type: "room",
href: "https://matrix.to/#/#room_1:element.io",
completion: "#room_with_completion_id",
completionId: "@room_1:host.local",
range: { start: 1, end: 1 },
component: <div>room_with_completion_id</div>,
},
{
type: "room",
href: "https://matrix.to/#/#room_2:element.io",
completion: "#room_without_completion_id",
range: { start: 1, end: 1 },
component: <div>room_without_completion_id</div>,
},
{
type: "command",
completion: "/spoiler",
range: { start: 1, end: 1 },
component: <div>/spoiler</div>,
},
{
type: "at-room",
completion: "@room",
range: { start: 1, end: 1 },
component: <div>@room</div>,
},
{
type: "community",
completion: "community-completion",
range: { start: 1, end: 1 },
component: <div>community</div>,
},
];
const constructMockProvider = (data: ICompletion[]) =>
({
getCompletions: jest.fn().mockImplementation(async () => data),
getName: jest.fn().mockReturnValue("test provider"),
renderCompletions: jest.fn().mockImplementation((components) => components),
}) as unknown as AutocompleteProvider;
// for each test we will insert input simulating a user mention
const initialInput = "@abc";
const insertMentionInput = async () => {
fireEvent.input(screen.getByRole("textbox"), {
data: initialInput,
inputType: "insertText",
});
// the autocomplete suggestions container has the presentation role, wait for it to be present
expect(await screen.findByRole("presentation")).toBeInTheDocument();
};
beforeEach(async () => {
// setup the required spies
jest.spyOn(Autocompleter.prototype, "getCompletions").mockResolvedValue([
{
completions: mockCompletions,
provider: constructMockProvider(mockCompletions),
command: { command: ["truthy"] as RegExpExecArray }, // needed for us to unhide the autocomplete when testing
},
]);
jest.spyOn(Permalinks, "parsePermalink").mockReturnValue({
userId: "mockParsedUserId",
} as unknown as PermalinkParts);
// then render the component and wait for the composer to be ready
customRender();
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
});
afterEach(() => {
jest.clearAllMocks();
});
it("shows the autocomplete when text has @ prefix and autoselects the first item", async () => {
await insertMentionInput();
expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "true");
});
it("pressing up and down arrows allows us to change the autocomplete selection", async () => {
await insertMentionInput();
// press the down arrow - nb using .keyboard allows us to not have to specify a node, which
// means that we know the autocomplete is correctly catching the event
await userEvent.keyboard("{ArrowDown}");
expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "false");
expect(screen.getByText(mockCompletions[1].completion)).toHaveAttribute("aria-selected", "true");
// reverse the process and check again
await userEvent.keyboard("{ArrowUp}");
expect(screen.getByText(mockCompletions[0].completion)).toHaveAttribute("aria-selected", "true");
expect(screen.getByText(mockCompletions[1].completion)).toHaveAttribute("aria-selected", "false");
});
it("pressing enter selects the mention and inserts it into the composer as a link", async () => {
await insertMentionInput();
// press enter
await userEvent.keyboard("{Enter}");
screen.debug();
// check that it closes the autocomplete
await waitFor(() => {
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
// check that it inserts the completion text as a link
expect(screen.getByRole("link", { name: mockCompletions[0].completion })).toBeInTheDocument();
});
it("pressing escape closes the autocomplete", async () => {
await insertMentionInput();
// press escape
await userEvent.keyboard("{Escape}");
// check that it closes the autocomplete
await waitFor(() => {
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
});
it("typing with the autocomplete open still works as expected", async () => {
await insertMentionInput();
// add some more text, then check the autocomplete is open AND the text is in the composer
await userEvent.keyboard("extra");
expect(screen.queryByRole("presentation")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toHaveTextContent("@abcextra");
});
it("clicking on a mention in the composer dispatches the correct action", async () => {
await insertMentionInput();
// press enter
await userEvent.keyboard("{Enter}");
// check that it closes the autocomplete
await waitFor(() => {
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
// click on the user mention link that has been inserted
await userEvent.click(screen.getByRole("link", { name: mockCompletions[0].completion }));
expect(dispatchSpy).toHaveBeenCalledTimes(1);
// this relies on the output from the mock function in mkStubRoom
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
action: Action.ViewUser,
member: expect.objectContaining({
userId: mkStubRoom(undefined, undefined, undefined).getMember("any")?.userId,
}),
}),
);
});
it("selecting a mention without a href closes the autocomplete but does not insert a mention", async () => {
await insertMentionInput();
// select the relevant user by clicking
await userEvent.click(screen.getByText("user_without_href"));
// check that it closes the autocomplete
await waitFor(() => {
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
// check that it has not inserted a link
expect(screen.queryByRole("link", { name: "user_without_href" })).not.toBeInTheDocument();
});
it("selecting a room mention with a completionId uses client.getRoom", async () => {
await insertMentionInput();
// select the room suggestion by clicking
await userEvent.click(screen.getByText("room_with_completion_id"));
// check that it closes the autocomplete
await waitFor(() => {
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
// check that it has inserted a link and looked up the name from the mock client
// which will always return 'My room'
expect(screen.getByRole("link", { name: "My room" })).toBeInTheDocument();
});
it("selecting a room mention without a completionId uses client.getRooms", async () => {
await insertMentionInput();
// select the room suggestion
await userEvent.click(screen.getByText("room_without_completion_id"));
// check that it closes the autocomplete
await waitFor(() => {
expect(screen.queryByRole("presentation")).not.toBeInTheDocument();
});
// check that it has inserted a link and falls back to the completion text
expect(screen.getByRole("link", { name: "#room_without_completion_id" })).toBeInTheDocument();
});
it("selecting a command inserts the command", async () => {
await insertMentionInput();
// select the room suggestion
await userEvent.click(screen.getByText("/spoiler"));
// check that it has inserted the plain text
expect(screen.getByText("/spoiler")).toBeInTheDocument();
});
it("selecting an at-room completion inserts @room", async () => {
await insertMentionInput();
// select the room suggestion
await userEvent.click(screen.getByText("@room"));
// check that it has inserted the @room link
expect(screen.getByRole("link", { name: "@room" })).toBeInTheDocument();
});
it("allows a community completion to pass through", async () => {
await insertMentionInput();
// select the room suggestion
await userEvent.click(screen.getByText("community"));
// check that it we still have the initial text
expect(screen.getByText(initialInput)).toBeInTheDocument();
});
});
describe("When emoticons should be replaced by emojis", () => {
const onChange = jest.fn();
const onSend = jest.fn();
beforeEach(async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
if (name === "MessageComposerInput.autoReplaceEmoji") return true;
});
customRender(onChange, onSend);
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
});
it("typing a space to trigger an emoji replacement", async () => {
fireEvent.input(screen.getByRole("textbox"), {
data: ":P",
inputType: "insertText",
});
fireEvent.input(screen.getByRole("textbox"), {
data: " ",
inputType: "insertText",
});
await waitFor(() => expect(onChange).toHaveBeenNthCalledWith(3, expect.stringContaining("😛")));
});
});
describe("When settings require Ctrl+Enter to send", () => {
const onChange = jest.fn();
const onSend = jest.fn();
beforeEach(async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
if (name === "MessageComposerInput.ctrlEnterToSend") return true;
});
customRender(onChange, onSend);
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
});
afterEach(() => {
onChange.mockReset();
onSend.mockReset();
});
it("Should not call onSend when Enter is pressed", async () => {
// When
const textbox = screen.getByRole("textbox");
fireEvent(
textbox,
new InputEvent("input", {
inputType: "insertParagraph",
}),
);
// Then it does not send a message
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
fireEvent(
textbox,
new InputEvent("input", {
inputType: "insertText",
data: "other",
}),
);
// The focus is on the last text node
await waitFor(() => {
const selection = document.getSelection();
if (selection) {
// eslint-disable-next-line jest/no-conditional-expect
expect(selection.focusNode?.textContent).toEqual("other");
}
});
});
it("Should send a message when Ctrl+Enter is pressed", async () => {
// When
fireEvent(
screen.getByRole("textbox"),
new InputEvent("input", {
inputType: "sendMessage",
}),
);
// Then it sends a message
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
});
});
describe("Keyboard navigation", () => {
const { mockClient, defaultRoomContext, mockEvent, editorStateTransfer } = createMocks();
const customRender = (
client = mockClient,
roomContext = defaultRoomContext,
_editorStateTransfer?: EditorStateTransfer,
) => {
return render(
<MatrixClientContext.Provider value={client}>
<RoomContext.Provider value={roomContext}>
<ComposerContext.Provider
value={getDefaultContextValue({ editorStateTransfer: _editorStateTransfer })}
>
<WysiwygComposer
onChange={jest.fn()}
onSend={jest.fn()}
initialContent={
roomContext.room && _editorStateTransfer
? parseEditorStateTransfer(_editorStateTransfer, roomContext.room, client)
: undefined
}
/>
</ComposerContext.Provider>
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
};
afterEach(() => {
jest.resetAllMocks();
});
const setup = async (
editorState?: EditorStateTransfer,
client = stubClient(),
roomContext = defaultRoomContext,
) => {
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
customRender(client, roomContext, editorState);
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
return { textbox: screen.getByRole("textbox"), spyDispatcher };
};
beforeEach(() => {
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
});
describe("In message creation", () => {
it("Should not moving when the composer is filled", async () => {
// When
const { textbox, spyDispatcher } = await setup();
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
// Move at the beginning of the composer
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toHaveBeenCalledTimes(0);
});
it("Should moving when the composer is empty", async () => {
// When
const { textbox, spyDispatcher } = await setup();
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toHaveBeenCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
});
describe("In message editing", () => {
function select(selection: SubSelection) {
return act(async () => {
await setSelection(selection);
// the event is not automatically fired by jest
document.dispatchEvent(new CustomEvent("selectionchange"));
});
}
describe("Moving up", () => {
it("Should not moving when caret is not at beginning of the text", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 1,
focusNode: textNode,
focusOffset: 2,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toHaveBeenCalledTimes(0);
});
it("Should not moving when the content has changed", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toHaveBeenCalledTimes(0);
});
it("Should moving up", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toHaveBeenCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
it("Should moving up in list", async () => {
// When
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
);
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
expect(spyDispatcher).toHaveBeenCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
});
describe("Moving down", () => {
it("Should not moving when caret is not at the end of the text", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
const brNode = textbox.lastChild;
await select({
anchorNode: brNode,
anchorOffset: 0,
focusNode: brNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Then
expect(spyDispatcher).toHaveBeenCalledTimes(0);
});
it("Should not moving when the content has changed", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
const brNode = textbox.lastChild;
await select({
anchorNode: brNode,
anchorOffset: 0,
focusNode: brNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Then
expect(spyDispatcher).toHaveBeenCalledTimes(0);
});
it("Should moving down", async () => {
// When
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
// Skipping the BR tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toHaveBeenCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
it("Should moving down in list", async () => {
// When
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
);
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
// Skipping the BR tag and get the text node inside the last LI tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2].lastChild?.lastChild || textbox;
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
expect(spyDispatcher).toHaveBeenCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
it("Should close editing", async () => {
// When
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(undefined);
const { textbox, spyDispatcher } = await setup(editorStateTransfer);
// Skipping the BR tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toHaveBeenCalledWith({
action: Action.EditEvent,
event: null,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
});
});
});
});