diff --git a/__tests__/components/waves/drops/EditDropLexical.test.tsx b/__tests__/components/waves/drops/EditDropLexical.test.tsx index 916454152d..3e1c74b2d5 100644 --- a/__tests__/components/waves/drops/EditDropLexical.test.tsx +++ b/__tests__/components/waves/drops/EditDropLexical.test.tsx @@ -1,14 +1,18 @@ -import React from 'react'; -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import EditDropLexical from '@/components/waves/drops/EditDropLexical'; -import type { ApiDropMentionedUser } from '@/generated/models/ApiDropMentionedUser'; +import React from "react"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; type MentionSelectHandler = (user: { mentioned_profile_id: string; handle_in_content: string; }) => void; +const useDeviceInfoMock = jest.fn(() => ({ + isApp: false, + isMobileDevice: false, +})); + const rootMock = { getChildren: jest.fn(() => [] as unknown[]), getAllTextNodes: jest.fn(() => [] as unknown[]), @@ -26,32 +30,32 @@ const editorMock = { let mentionSelectHandler: MentionSelectHandler | null = null; -jest.mock('@lexical/list', () => ({ +jest.mock("@lexical/list", () => ({ ListNode: class MockListNode {}, ListItemNode: class MockListItemNode {}, })); -jest.mock('@lexical/rich-text', () => ({ +jest.mock("@lexical/rich-text", () => ({ HeadingNode: class MockHeadingNode {}, QuoteNode: class MockQuoteNode {}, })); -jest.mock('@lexical/react/LexicalHorizontalRuleNode', () => ({ +jest.mock("@lexical/react/LexicalHorizontalRuleNode", () => ({ HorizontalRuleNode: class MockHorizontalRuleNode {}, })); -jest.mock('@lexical/code', () => ({ +jest.mock("@lexical/code", () => ({ CodeHighlightNode: class MockCodeHighlightNode {}, CodeNode: class MockCodeNode {}, $isCodeNode: () => false, })); -jest.mock('@lexical/link', () => ({ +jest.mock("@lexical/link", () => ({ AutoLinkNode: class MockAutoLinkNode {}, LinkNode: class MockLinkNode {}, })); -jest.mock('@lexical/react/LexicalComposer', () => ({ +jest.mock("@lexical/react/LexicalComposer", () => ({ LexicalComposer: ({ children }: any) => (
{children}
), })); -jest.mock('@lexical/react/LexicalRichTextPlugin', () => ({ +jest.mock("@lexical/react/LexicalRichTextPlugin", () => ({ RichTextPlugin: ({ contentEditable, placeholder }: any) => (
{contentEditable} @@ -59,7 +63,7 @@ jest.mock('@lexical/react/LexicalRichTextPlugin', () => ({
), })); -jest.mock('@lexical/react/LexicalContentEditable', () => ({ +jest.mock("@lexical/react/LexicalContentEditable", () => ({ ContentEditable: ({ className, style }: any) => (
({ /> ), })); -jest.mock('@lexical/react/LexicalErrorBoundary', () => ({ +jest.mock("@lexical/react/LexicalErrorBoundary", () => ({ __esModule: true, default: () => null, })); -jest.mock('@lexical/react/LexicalHistoryPlugin', () => ({ +jest.mock("@lexical/react/LexicalHistoryPlugin", () => ({ HistoryPlugin: () => null, })); -jest.mock('@lexical/react/LexicalOnChangePlugin', () => ({ +jest.mock("@lexical/react/LexicalOnChangePlugin", () => ({ OnChangePlugin: ({ onChange }: any) => { React.useEffect(() => { const mockEditorState = { @@ -88,98 +92,130 @@ jest.mock('@lexical/react/LexicalOnChangePlugin', () => ({ return null; }, })); -jest.mock('@lexical/react/LexicalMarkdownShortcutPlugin', () => ({ +jest.mock("@lexical/react/LexicalMarkdownShortcutPlugin", () => ({ MarkdownShortcutPlugin: () => null, })); -jest.mock('@lexical/react/LexicalListPlugin', () => ({ +jest.mock("@lexical/react/LexicalListPlugin", () => ({ ListPlugin: () => null, })); -jest.mock('@lexical/react/LexicalLinkPlugin', () => ({ +jest.mock("@lexical/react/LexicalLinkPlugin", () => ({ LinkPlugin: () => null, })); -jest.mock('@/components/drops/create/lexical/plugins/PlainTextPastePlugin', () => ({ - __esModule: true, - default: () => null, -})); -jest.mock('@/components/drops/create/lexical/plugins/emoji/EmojiPlugin', () => ({ - __esModule: true, - default: () => null, -})); -jest.mock('@/components/waves/CreateDropEmojiPicker', () => ({ +jest.mock( + "@/components/drops/create/lexical/plugins/PlainTextPastePlugin", + () => ({ + __esModule: true, + default: () => null, + }) +); +jest.mock( + "@/components/drops/create/lexical/plugins/emoji/EmojiPlugin", + () => ({ + __esModule: true, + default: () => null, + }) +); +jest.mock("@/components/waves/CreateDropEmojiPicker", () => ({ __esModule: true, default: () =>
, })); -jest.mock('@/hooks/useDeviceInfo', () => ({ +jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, - default: () => ({ isApp: false }), + default: () => useDeviceInfoMock(), })); -jest.mock('@/components/drops/create/lexical/plugins/mentions/MentionsPlugin', () => { - const React = require('react'); - return { - __esModule: true, - default: React.forwardRef(({ onSelect }: any, ref: any) => { - mentionSelectHandler = onSelect; - React.useImperativeHandle(ref, () => ({ - isMentionsOpen: jest.fn(() => false), - })); - return
; - }), - }; -}); -jest.mock('@/components/drops/create/lexical/nodes/MentionNode', () => ({ +jest.mock( + "@/components/drops/create/lexical/plugins/mentions/MentionsPlugin", + () => { + const React = require("react"); + return { + __esModule: true, + default: React.forwardRef(({ onSelect }: any, ref: any) => { + mentionSelectHandler = onSelect; + React.useImperativeHandle(ref, () => ({ + isMentionsOpen: jest.fn(() => false), + })); + return
; + }), + }; + } +); +jest.mock( + "@/components/drops/create/lexical/plugins/waves/WaveMentionsPlugin", + () => { + const React = require("react"); + return { + __esModule: true, + default: React.forwardRef((_props: any, ref: any) => { + React.useImperativeHandle(ref, () => ({ + isWaveMentionsOpen: jest.fn(() => false), + })); + return
; + }), + }; + } +); +jest.mock("@/components/drops/create/lexical/nodes/MentionNode", () => ({ MentionNode: class MockMentionNode {}, - $createMentionNode: jest.fn(() => ({ type: 'mention' })), + $createMentionNode: jest.fn(() => ({ type: "mention" })), })); -jest.mock('@/components/drops/create/lexical/nodes/HashtagNode', () => ({ +jest.mock("@/components/drops/create/lexical/nodes/HashtagNode", () => ({ HashtagNode: class MockHashtagNode {}, })); -jest.mock('@/components/drops/create/lexical/nodes/EmojiNode', () => ({ +jest.mock("@/components/drops/create/lexical/nodes/EmojiNode", () => ({ EmojiNode: class MockEmojiNode {}, })); -jest.mock('@/components/drops/create/lexical/ExampleTheme', () => ({ +jest.mock("@/components/drops/create/lexical/ExampleTheme", () => ({ __esModule: true, default: {}, })); -jest.mock('@/components/drops/create/lexical/transformers/MentionTransformer', () => ({ - MENTION_TRANSFORMER: {}, -})); -jest.mock('@/components/drops/create/lexical/transformers/HastagTransformer', () => ({ - HASHTAG_TRANSFORMER: {}, -})); -jest.mock('@/components/drops/create/lexical/transformers/markdownTransformers', () => ({ - SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE: [], -})); -jest.mock('@lexical/react/LexicalComposerContext', () => ({ +jest.mock( + "@/components/drops/create/lexical/transformers/MentionTransformer", + () => ({ + MENTION_TRANSFORMER: {}, + }) +); +jest.mock( + "@/components/drops/create/lexical/transformers/HastagTransformer", + () => ({ + HASHTAG_TRANSFORMER: {}, + }) +); +jest.mock( + "@/components/drops/create/lexical/transformers/markdownTransformers", + () => ({ + SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE: [], + }) +); +jest.mock("@lexical/react/LexicalComposerContext", () => ({ useLexicalComposerContext: () => [editorMock], })); -jest.mock('@lexical/markdown', () => ({ +jest.mock("@lexical/markdown", () => ({ __esModule: true, $convertFromMarkdownString: jest.fn(), })); -const { - $convertFromMarkdownString: convertFromMarkdownStringMock, -} = jest.requireMock('@lexical/markdown') as { - $convertFromMarkdownString: jest.Mock; -}; +const { $convertFromMarkdownString: convertFromMarkdownStringMock } = + jest.requireMock("@lexical/markdown") as { + $convertFromMarkdownString: jest.Mock; + }; -jest.mock('@/components/waves/drops/normalizeDropMarkdown', () => ({ +jest.mock("@/components/waves/drops/normalizeDropMarkdown", () => ({ __esModule: true, default: jest.fn((value: string) => value), normalizeDropMarkdown: jest.fn((value: string) => value), - exportDropMarkdown: jest.fn(() => 'mock markdown'), + exportDropMarkdown: jest.fn(() => "mock markdown"), })); const { normalizeDropMarkdown: normalizeDropMarkdownMock, exportDropMarkdown: exportDropMarkdownMock, -} = jest.requireMock('@/components/waves/drops/normalizeDropMarkdown') as { +} = jest.requireMock("@/components/waves/drops/normalizeDropMarkdown") as { normalizeDropMarkdown: jest.Mock; exportDropMarkdown: jest.Mock; }; -jest.mock('lexical', () => ({ +jest.mock("lexical", () => ({ $getRoot: getRootMock, COMMAND_PRIORITY_HIGH: 4, - KEY_ENTER_COMMAND: 'KEY_ENTER_COMMAND', - KEY_ESCAPE_COMMAND: 'KEY_ESCAPE_COMMAND', + KEY_ENTER_COMMAND: "KEY_ENTER_COMMAND", + KEY_ESCAPE_COMMAND: "KEY_ESCAPE_COMMAND", TextNode: class MockTextNode {}, $createParagraphNode: jest.fn(() => ({ append: jest.fn(), @@ -190,17 +226,21 @@ jest.mock('lexical', () => ({ insertAfter: jest.fn(), insertBefore: jest.fn(), remove: jest.fn(), - getTextContent: jest.fn(() => ''), + getTextContent: jest.fn(() => ""), })), $isElementNode: jest.fn(() => false), $isCodeNode: jest.fn(() => false), })); -describe('EditDropLexical', () => { +const EditDropLexical = + require("@/components/waves/drops/EditDropLexical").default; + +describe("EditDropLexical", () => { const defaultProps = { - initialContent: 'Initial content here', + initialContent: "Initial content here", initialMentions: [] as ApiDropMentionedUser[], - waveId: 'wave-123', + initialWaveMentions: [], + waveId: "wave-123", isSaving: false, onSave: jest.fn(), onCancel: jest.fn(), @@ -208,81 +248,98 @@ describe('EditDropLexical', () => { beforeEach(() => { jest.clearAllMocks(); - exportDropMarkdownMock.mockReturnValue('mock markdown'); + exportDropMarkdownMock.mockReturnValue("mock markdown"); normalizeDropMarkdownMock.mockImplementation((value: string) => value); convertFromMarkdownStringMock.mockReset(); rootMock.getChildren.mockReturnValue([]); rootMock.getAllTextNodes.mockReturnValue([]); mentionSelectHandler = null; + useDeviceInfoMock.mockReturnValue({ + isApp: false, + isMobileDevice: false, + }); }); - it('renders placeholder text and emoji picker', () => { + it("renders placeholder text and emoji picker", () => { render(); - expect(screen.getByText('Edit message...')).toBeInTheDocument(); - expect(screen.getByTestId('emoji-picker')).toBeInTheDocument(); + expect(screen.getByText("Edit message...")).toBeInTheDocument(); + expect(screen.getByTestId("emoji-picker")).toBeInTheDocument(); }); - it('saves updated markdown together with unique mentions', async () => { + it("saves updated markdown together with unique mentions", async () => { const user = userEvent.setup(); const onSave = jest.fn(); const onCancel = jest.fn(); - exportDropMarkdownMock.mockReturnValue('updated markdown'); + exportDropMarkdownMock.mockReturnValue("updated markdown"); - render(); + render( + + ); expect(mentionSelectHandler).toBeTruthy(); - const mention = { mentioned_profile_id: 'profile-1', handle_in_content: 'user1' }; + const mention = { + mentioned_profile_id: "profile-1", + handle_in_content: "user1", + }; await act(async () => { mentionSelectHandler?.(mention); mentionSelectHandler?.(mention); }); - const saveButton = screen.getByRole('button', { name: /save/i }); + const saveButton = screen.getByRole("button", { name: /save/i }); await user.click(saveButton); - expect(onSave).toHaveBeenCalledWith('updated markdown', [ - { mentioned_profile_id: 'profile-1', handle_in_content: 'user1' }, - ]); + expect(onSave).toHaveBeenCalledWith( + "updated markdown", + [{ mentioned_profile_id: "profile-1", handle_in_content: "user1" }], + [] + ); expect(onCancel).not.toHaveBeenCalled(); }); - it('calls onCancel when markdown has not changed', async () => { + it("calls onCancel when markdown has not changed", async () => { const user = userEvent.setup(); const onSave = jest.fn(); const onCancel = jest.fn(); - exportDropMarkdownMock.mockReturnValue('Initial content here'); + exportDropMarkdownMock.mockReturnValue("Initial content here"); - render(); + render( + + ); - const saveButton = screen.getByRole('button', { name: /save/i }); + const saveButton = screen.getByRole("button", { name: /save/i }); await user.click(saveButton); expect(onCancel).toHaveBeenCalledTimes(1); expect(onSave).not.toHaveBeenCalled(); }); - it('invokes keyboard command handlers', async () => { + it("invokes desktop keyboard command handlers", async () => { const onSave = jest.fn(); const onCancel = jest.fn(); - exportDropMarkdownMock.mockReturnValue('changed content'); + exportDropMarkdownMock.mockReturnValue("changed content"); - render(); + render( + + ); await act(async () => {}); const escapeCall = editorMock.registerCommand.mock.calls.find( - ([command]) => command === 'KEY_ESCAPE_COMMAND' + ([command]) => command === "KEY_ESCAPE_COMMAND" ); const enterCall = editorMock.registerCommand.mock.calls.find( - ([command]) => command === 'KEY_ENTER_COMMAND' + ([command]) => command === "KEY_ENTER_COMMAND" ); expect(escapeCall).toBeDefined(); expect(enterCall).toBeDefined(); const escapeHandler = escapeCall?.[1] as () => boolean; - const enterHandler = enterCall?.[1] as (event?: { shiftKey?: boolean | undefined }) => boolean; + const enterHandler = enterCall?.[1] as (event?: { + shiftKey?: boolean | undefined; + }) => boolean; await act(async () => { escapeHandler?.(); @@ -297,4 +354,51 @@ describe('EditDropLexical', () => { expect(exportDropMarkdownMock).toHaveBeenCalled(); expect(onCancel).toHaveBeenCalledTimes(1); }); + + it("does not submit on Enter for mobile devices", async () => { + const onSave = jest.fn(); + const onCancel = jest.fn(); + useDeviceInfoMock.mockReturnValue({ + isApp: false, + isMobileDevice: true, + }); + exportDropMarkdownMock.mockReturnValue("changed content"); + + render( + + ); + + await act(async () => {}); + + const enterCall = editorMock.registerCommand.mock.calls.find( + ([command]) => command === "KEY_ENTER_COMMAND" + ); + expect(enterCall).toBeDefined(); + + const enterHandler = enterCall?.[1] as (event?: { + shiftKey?: boolean | undefined; + }) => boolean; + + let handled = false; + await act(async () => { + handled = enterHandler?.({ shiftKey: false }) ?? false; + }); + + expect(handled).toBe(true); + expect(onSave).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("keeps actions visible and removes desktop shortcut copy on mobile web", () => { + useDeviceInfoMock.mockReturnValue({ + isApp: false, + isMobileDevice: true, + }); + + render(); + + expect(screen.queryByText("enter to")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); + }); }); diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index 551d2718f0..0e41d30cf3 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -105,7 +105,7 @@ const convertCodeNodesToFences = (root: RootNode) => { if (!node) continue; if ($isCodeNode(node)) { - const language = node.getLanguage?.() ?? ""; + const language = node.getLanguage() ?? ""; const safeLanguage = language.trim().replaceAll(/[`\n\r]/g, ""); const codeText = node.getTextContent(); const normalizedCode = codeText.endsWith("\n") @@ -146,6 +146,7 @@ function reconstructSplitMention( if (!mentionMatch) return false; const handle = mentionMatch[1]; + if (!handle) return false; const mentionNode = $createMentionNode(`@${handle}`); const currentText = currentNode.getTextContent(); @@ -348,6 +349,7 @@ function KeyboardPlugin({ onSave, onCancel, isSaving, + isMobileOrApp, initialContent, mentionsRef, waveMentionsRef, @@ -355,6 +357,7 @@ function KeyboardPlugin({ onSave: () => void; onCancel: () => void; isSaving: boolean; + isMobileOrApp: boolean; initialContent: string; mentionsRef: React.RefObject; waveMentionsRef: React.RefObject; @@ -382,6 +385,10 @@ function KeyboardPlugin({ return false; } + if (isMobileOrApp) { + return true; + } + if (event?.shiftKey) { return false; } @@ -415,6 +422,7 @@ function KeyboardPlugin({ onSave, onCancel, isSaving, + isMobileOrApp, initialContent, mentionsRef, waveMentionsRef, @@ -441,7 +449,8 @@ const EditDropLexical: React.FC = ({ const editorRef = useRef(null); const mentionsRef = useRef(null); const waveMentionsRef = useRef(null); - const { isApp } = useDeviceInfo(); + const { isApp, isMobileDevice } = useDeviceInfo(); + const isMobileOrApp = isMobileDevice || isApp; const normalizedInitialContent = useMemo( () => normalizeDropMarkdown(initialContent), [initialContent] @@ -586,6 +595,7 @@ const EditDropLexical: React.FC = ({ onSave={handleSave} onCancel={onCancel} isSaving={isSaving} + isMobileOrApp={isMobileOrApp} initialContent={normalizedInitialContent} mentionsRef={mentionsRef} waveMentionsRef={waveMentionsRef} @@ -595,14 +605,14 @@ const EditDropLexical: React.FC = ({ {!isApp && (
- escape to{" "} + {!isMobileDevice && <>escape to } {" "} - • enter to{" "} + {isMobileDevice ? "• " : "• enter to "}