Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React, { createRef } from "react";
import { act, render } from "@testing-library/react";
import NewWaveMentionsPlugin, {
WaveMentionTypeaheadOption,
} from "@/components/drops/create/lexical/plugins/waves/WaveMentionsPlugin";

jest.mock("@lexical/react/LexicalComposerContext", () => ({
useLexicalComposerContext: () => [{ update: (fn: () => void) => fn() }],
}));

let capturedProps: any;
jest.mock("@lexical/react/LexicalTypeaheadMenuPlugin", () => ({
LexicalTypeaheadMenuPlugin: (props: any) => {
capturedProps = props;
return <div data-testid="typeahead" />;
},
MenuOption: class {
key: string;
ref: { current: HTMLElement | null };

constructor(key: string) {
this.key = key;
this.ref = { current: null };
}

setRefElement = (element: HTMLElement | null) => {
this.ref.current = element;
};
},
useBasicTypeaheadTriggerMatch: () => jest.fn(() => null),
}));

jest.mock("@/hooks/useWavesSearch", () => ({
useWavesSearch: jest.fn(),
}));

jest.mock("@/components/drops/create/lexical/nodes/WaveMentionNode", () => ({
$createWaveMentionNode: jest.fn(),
}));

jest.mock(
"@/components/drops/create/lexical/utils/codeContextDetection",
() => ({
isInCodeContext: jest.fn(() => false),
})
);

const { useWavesSearch } = require("@/hooks/useWavesSearch");
const {
$createWaveMentionNode,
} = require("@/components/drops/create/lexical/nodes/WaveMentionNode");

describe("WaveMentionsPlugin", () => {
beforeEach(() => {
capturedProps = null;
jest.clearAllMocks();
});

it("builds options from waves and exposes open state", () => {
(useWavesSearch as jest.Mock).mockReturnValue({
waves: [
{ id: "wave-1", name: "Wave Alpha", picture: null },
{ id: "wave-2", name: "Wave Beta", picture: null },
],
});

const ref = createRef<any>();
render(<NewWaveMentionsPlugin onSelect={jest.fn()} ref={ref} />);

expect(capturedProps.options).toHaveLength(2);
expect(capturedProps.options[0]).toBeInstanceOf(WaveMentionTypeaheadOption);

act(() => {
capturedProps.onOpen();
});
expect(ref.current.isWaveMentionsOpen()).toBe(true);

act(() => {
capturedProps.onClose();
});
expect(ref.current.isWaveMentionsOpen()).toBe(false);
});

it("creates wave mention node and emits sanitized mention payload", () => {
(useWavesSearch as jest.Mock).mockReturnValue({
waves: [{ id: "wave-1", name: "Brack]et Wave", picture: null }],
});

const mentionNode = {
select: jest.fn(),
};
($createWaveMentionNode as jest.Mock).mockReturnValue(mentionNode);

const onSelect = jest.fn();
render(<NewWaveMentionsPlugin onSelect={onSelect} ref={createRef()} />);

const closeMenu = jest.fn();
const nodeToReplace = {
replace: jest.fn(),
};

act(() => {
capturedProps.onSelectOption(
capturedProps.options[0],
nodeToReplace,
closeMenu
);
});

expect($createWaveMentionNode).toHaveBeenCalledWith("#Bracket Wave");
expect(nodeToReplace.replace).toHaveBeenCalledWith(mentionNode);
expect(mentionNode.select).toHaveBeenCalled();
expect(onSelect).toHaveBeenCalledWith({
wave_id: "wave-1",
wave_name_in_content: "Bracket Wave",
});
expect(closeMenu).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import WaveMentionsTypeaheadMenu from "@/components/drops/create/lexical/plugins/waves/WaveMentionsTypeaheadMenu";

function createOption(name: string) {
return {
key: `option-${name}`,
name,
picture: null,
setRefElement: jest.fn(),
};
}

describe("WaveMentionsTypeaheadMenu", () => {
const baseRect = {
x: 0,
y: 0,
width: 320,
height: 120,
top: 100,
right: 320,
bottom: 220,
left: 0,
toJSON: () => "",
};

let getBoundingClientRectMock: jest.SpyInstance;

beforeEach(() => {
getBoundingClientRectMock = jest
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue(baseRect as DOMRect);
});

afterEach(() => {
getBoundingClientRectMock.mockRestore();
jest.clearAllMocks();
});

it("renders with bottom positioning when there is more space below", () => {
const options = [createOption("Wave Alpha"), createOption("Wave Beta")];
const { container } = render(
<WaveMentionsTypeaheadMenu
selectedIndex={0}
options={options as any}
setHighlightedIndex={jest.fn()}
selectOptionAndCleanUp={jest.fn()}
/>
);

expect(container.firstChild).toHaveClass("tw-top-full");
expect(container.firstChild).toHaveClass("tw-mt-1");
});

it("switches to top positioning when there is more space above", async () => {
getBoundingClientRectMock.mockReturnValue({
...baseRect,
top: 700,
bottom: 790,
} as DOMRect);

const options = [createOption("Wave Alpha")];
const { container } = render(
<WaveMentionsTypeaheadMenu
selectedIndex={0}
options={options as any}
setHighlightedIndex={jest.fn()}
selectOptionAndCleanUp={jest.fn()}
/>
);

await waitFor(() => {
expect(container.firstChild).toHaveClass("tw-bottom-full");
expect(container.firstChild).toHaveClass("tw-mb-1");
});
});

it("selects clicked option and remains stable across repeated resize events", () => {
const options = [createOption("Wave Alpha"), createOption("Wave Beta")];
const setHighlightedIndex = jest.fn();
const selectOptionAndCleanUp = jest.fn();

render(
<WaveMentionsTypeaheadMenu
selectedIndex={0}
options={options as any}
setHighlightedIndex={setHighlightedIndex}
selectOptionAndCleanUp={selectOptionAndCleanUp}
/>
);

fireEvent.click(screen.getByRole("button", { name: "Wave Alpha" }));

expect(setHighlightedIndex).toHaveBeenCalledWith(0);
expect(selectOptionAndCleanUp).toHaveBeenCalledWith(options[0]);

for (let i = 0; i < 5; i += 1) {
fireEvent(window, new Event("resize"));
}

expect(
screen.getByRole("button", { name: "Wave Alpha" })
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useRef, useSyncExternalStore } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { WaveMentionTypeaheadOption } from "./WaveMentionsPlugin";
import WaveMentionsTypeaheadMenuItem from "./WaveMentionsTypeaheadMenuItem";

Expand All @@ -16,45 +16,70 @@ export default function WaveMentionsTypeaheadMenu({
readonly selectOptionAndCleanUp: (option: WaveMentionTypeaheadOption) => void;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<"top" | "bottom">("bottom");

const getPositionSnapshot = useCallback(() => {
if (globalThis.window === undefined) return "bottom";
const updatePosition = useCallback(() => {
if (globalThis.window === undefined) return;
const win = globalThis.window;
const element = menuRef.current;
if (!element) return "bottom";
if (!element) return;
const rect = element.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = win.innerHeight - rect.bottom;
return spaceBelow >= spaceAbove ? "bottom" : "top";
const nextPosition: "top" | "bottom" =
spaceBelow >= spaceAbove ? "bottom" : "top";

setPosition((current) =>
current === nextPosition ? current : nextPosition
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}, []);

const subscribeToPosition = useCallback((onStoreChange: () => void) => {
if (globalThis.window === undefined) {
return () => {};
}
useEffect(() => {
if (globalThis.window === undefined) return;

const win = globalThis.window;
const handleChange = () => onStoreChange();
win.addEventListener("resize", handleChange);
const cancelInitialUpdate =
typeof win.requestAnimationFrame === "function"
? (() => {
const frame = win.requestAnimationFrame(() => {
updatePosition();
});
return () => {
win.cancelAnimationFrame(frame);
};
})()
: (() => {
const timeout = win.setTimeout(() => {
updatePosition();
}, 0);
return () => {
win.clearTimeout(timeout);
};
})();

let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== "undefined" && menuRef.current) {
resizeObserver = new ResizeObserver(handleChange);
resizeObserver.observe(menuRef.current);
win.addEventListener("resize", updatePosition);

if (typeof ResizeObserver === "undefined") {
return () => {
cancelInitialUpdate();
win.removeEventListener("resize", updatePosition);
};
}

handleChange();
const resizeObserver = new ResizeObserver(() => {
updatePosition();
});
const element = menuRef.current;
if (element) {
resizeObserver.observe(element);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return () => {
win.removeEventListener("resize", handleChange);
resizeObserver?.disconnect();
cancelInitialUpdate();
win.removeEventListener("resize", updatePosition);
resizeObserver.disconnect();
};
}, []);

const position = useSyncExternalStore(
subscribeToPosition,
getPositionSnapshot,
() => "bottom"
);
}, [updatePosition]);

return (
<div
Expand All @@ -78,7 +103,9 @@ export default function WaveMentionsTypeaheadMenu({
}}
name={option.name}
picture={option.picture}
setRefElement={(element) => option.setRefElement(element)}
setRefElement={(element) => {
option.setRefElement(element);
}}
/>
))}
</ul>
Expand Down