mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
fix(desktop): reserve autocomplete Enter before paragraph splitting
Keep the editor from invalidating a live channel query before the composer can select it. Retain immediate stale-query dismissal and cover channel, thread, forum, mention and emoji keyboard paths in the browser. Clarify that the cursor test exercises a supplied hook update. Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -127,6 +127,7 @@ export default defineConfig({
|
||||
"**/timeline-no-shift.spec.ts",
|
||||
"**/human-edit-agent-content.spec.ts",
|
||||
"**/empty-edit-delete.spec.ts",
|
||||
"**/channel-autocomplete.spec.ts",
|
||||
"**/reaction-order.spec.ts",
|
||||
"**/reaction-names.spec.ts",
|
||||
"**/inbox-reactions.spec.ts",
|
||||
|
||||
@@ -46,7 +46,11 @@ function enterEvent() {
|
||||
for (const [name, text, cursor] of [
|
||||
["replacing the loaded edit", "Edited, not deleted", 19],
|
||||
["clearing the loaded edit", "", 0],
|
||||
["moving before the channel reference", "Welcome to #general", 0],
|
||||
[
|
||||
"receiving an update with the cursor before the reference",
|
||||
"Welcome to #general",
|
||||
0,
|
||||
],
|
||||
]) {
|
||||
test(`${name} releases Enter before the debounce expires`, async (t) => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
|
||||
@@ -107,7 +107,7 @@ export type RichTextEditorOptions = {
|
||||
* ArrowUp fall through to normal caret movement.
|
||||
*/
|
||||
onEditLastOwnMessage?: () => boolean;
|
||||
/** When true, plain Enter is passed through (e.g. to select an autocomplete item). */
|
||||
/** When true, plain Enter is reserved for the composer's autocomplete handler. */
|
||||
isAutocompleteOpen?: React.RefObject<boolean>;
|
||||
/**
|
||||
* Called when the user clicks an existing link in the editor. The link
|
||||
@@ -315,7 +315,9 @@ export function useRichTextEditor({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor: ed }) => {
|
||||
if (isAutocompleteOpen?.current) return false;
|
||||
// Block splitBlock without stopping DOM propagation: the
|
||||
// composer's React handler selects the autocomplete item.
|
||||
if (isAutocompleteOpen?.current) return true;
|
||||
if (!onSubmitRef.current) return false;
|
||||
|
||||
const fenceResult = handleCodeFenceEnter(ed);
|
||||
|
||||
111
desktop/tests/e2e/channel-autocomplete.spec.ts
Normal file
111
desktop/tests/e2e/channel-autocomplete.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
async function openComposer(
|
||||
page: Page,
|
||||
surface: "channel" | "thread" | "forum",
|
||||
) {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
if (surface === "thread") {
|
||||
await page.locator('[data-message-id="mock-general-alice"]').hover();
|
||||
await page.getByTestId("reply-message-mock-general-alice").click();
|
||||
return page
|
||||
.getByTestId("message-thread-panel")
|
||||
.getByTestId("message-input");
|
||||
}
|
||||
if (surface === "forum") {
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await page.getByRole("button", { name: "Start a new post..." }).click();
|
||||
}
|
||||
return page.getByTestId("message-input");
|
||||
}
|
||||
|
||||
function channelSuggestion(page: Page) {
|
||||
return page.getByRole("button", { name: "#general stream", exact: true });
|
||||
}
|
||||
|
||||
for (const surface of ["channel", "thread", "forum"] as const) {
|
||||
test(`Enter selects a channel in the ${surface} composer without splitting or submitting`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = await openComposer(page, surface);
|
||||
await input.pressSequentially("#gener");
|
||||
await expect(channelSuggestion(page)).toBeVisible();
|
||||
await input.press("Enter");
|
||||
|
||||
// Assert literal text (including the separator), document shape and focus:
|
||||
// hook-only tests cannot catch ProseMirror splitting before React handles Enter.
|
||||
await expect.poll(() => input.textContent()).toBe("#general ");
|
||||
await expect(input.locator("p")).toHaveCount(1);
|
||||
await expect(input.locator("br")).toHaveCount(0);
|
||||
await expect(input).toBeFocused();
|
||||
await expect(channelSuggestion(page)).toBeHidden();
|
||||
|
||||
// Autocomplete must relinquish the next Enter to ordinary submission.
|
||||
await page.keyboard.type("selected channel");
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const content = "#general selected channel";
|
||||
const signed = (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter(
|
||||
(event) => event.content === content,
|
||||
);
|
||||
if (signed.length) return signed.length;
|
||||
return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
||||
(call) =>
|
||||
call.command === "send_channel_message" &&
|
||||
(call.payload as { content?: string }).content === content,
|
||||
).length;
|
||||
}),
|
||||
)
|
||||
.toBe(1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of ["Tab", "Escape", "Shift+Enter"] as const) {
|
||||
test(`channel autocomplete preserves ${key} behavior`, async ({ page }) => {
|
||||
const input = await openComposer(page, "channel");
|
||||
await input.fill("#gener");
|
||||
await expect(channelSuggestion(page)).toBeVisible();
|
||||
await input.press(key);
|
||||
await expect
|
||||
.poll(() => input.textContent())
|
||||
.toBe(key === "Tab" ? "#general " : "#gener");
|
||||
await expect(channelSuggestion(page)).toBeHidden();
|
||||
await expect(input.locator("p")).toHaveCount(1);
|
||||
if (key === "Shift+Enter") {
|
||||
await page.keyboard.type("next line");
|
||||
await expect(input).toHaveText("#genernext line");
|
||||
await expect(input.locator("br")).toHaveCount(1);
|
||||
} else {
|
||||
await expect(input.locator("br")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [prefix, expected] of [
|
||||
["@ali", "@alice "],
|
||||
[":joy", "😂 "],
|
||||
] as const) {
|
||||
test(`Enter still selects ${prefix} autocomplete without splitting`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const input = await openComposer(page, "channel");
|
||||
const dropdown = page.getByTestId(
|
||||
prefix === "@ali" ? "mention-autocomplete" : "emoji-autocomplete",
|
||||
);
|
||||
await input.fill(prefix);
|
||||
await expect(dropdown).toBeVisible();
|
||||
await input.press("Enter");
|
||||
await expect(dropdown).toBeHidden();
|
||||
await expect.poll(() => input.textContent()).toBe(expected);
|
||||
await expect(input.locator("p")).toHaveCount(1);
|
||||
await expect(input.locator("br")).toHaveCount(0);
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user