From f6201d8581607228bdf50234f7885dcb7fb3b56e Mon Sep 17 00:00:00 2001 From: Jin <22962980+JYC333@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:23:26 +0000 Subject: [PATCH] fix: add link dialog enter act correctly --- .../src/widgets/dialogs/add_link.spec.tsx | 160 ++++++++++++++++++ apps/client/src/widgets/dialogs/add_link.tsx | 76 ++++++--- .../src/widgets/react/NoteAutocomplete.tsx | 15 +- 3 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 apps/client/src/widgets/dialogs/add_link.spec.tsx diff --git a/apps/client/src/widgets/dialogs/add_link.spec.tsx b/apps/client/src/widgets/dialogs/add_link.spec.tsx new file mode 100644 index 0000000000..7525712185 --- /dev/null +++ b/apps/client/src/widgets/dialogs/add_link.spec.tsx @@ -0,0 +1,160 @@ +import $ from "jquery"; +import type { ComponentChildren } from "preact"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + triliumEventHandlers, + latestModalPropsRef, + latestNoteAutocompletePropsRef, + addLinkSpy, + logErrorSpy, + showRecentNotesSpy, + setTextSpy +} = vi.hoisted(() => ({ + triliumEventHandlers: new Map void>(), + latestModalPropsRef: { current: null as any }, + latestNoteAutocompletePropsRef: { current: null as any }, + addLinkSpy: vi.fn(() => Promise.resolve()), + logErrorSpy: vi.fn(), + showRecentNotesSpy: vi.fn(), + setTextSpy: vi.fn() +})); + +vi.mock("../../services/i18n", () => ({ + t: (key: string) => key +})); + +vi.mock("../../services/tree", () => ({ + default: { + getNoteIdFromUrl: (notePath: string) => notePath.split("/").at(-1), + getNoteTitle: vi.fn(async () => "Target note") + } +})); + +vi.mock("../../services/ws", () => ({ + logError: logErrorSpy +})); + +vi.mock("../../services/note_autocomplete", () => ({ + __esModule: true, + default: { + showRecentNotes: showRecentNotesSpy, + setText: setTextSpy + } +})); + +vi.mock("../react/react_utils", () => ({ + refToJQuerySelector: (ref: { current: HTMLInputElement | null }) => $(ref.current) +})); + +vi.mock("../react/hooks", () => ({ + useTriliumEvent: (name: string, handler: (payload: any) => void) => { + triliumEventHandlers.set(name, handler); + } +})); + +vi.mock("../react/Modal", () => ({ + default: (props: any) => { + latestModalPropsRef.current = props; + + if (!props.show) { + return null; + } + + return ( +
{ + e.preventDefault(); + props.onSubmit?.(); + }}> + {props.children} + {props.footer} +
+ ); + } +})); + +vi.mock("../react/FormGroup", () => ({ + default: ({ children }: { children: ComponentChildren }) =>
{children}
+})); + +vi.mock("../react/Button", () => ({ + default: ({ text }: { text: string }) => +})); + +vi.mock("../react/FormRadioGroup", () => ({ + default: () => null +})); + +vi.mock("../react/NoteAutocomplete", () => ({ + default: (props: any) => { + latestNoteAutocompletePropsRef.current = props; + return ; + } +})); + +import AddLinkDialog from "./add_link"; + +describe("AddLinkDialog", () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.clearAllMocks(); + latestModalPropsRef.current = null; + latestNoteAutocompletePropsRef.current = null; + triliumEventHandlers.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + act(() => { + render(null, container); + }); + container.remove(); + }); + + it("submits the selected note when Enter picks an autocomplete suggestion", async () => { + act(() => { + render(, container); + }); + + const showDialog = triliumEventHandlers.get("showAddLinkDialog"); + expect(showDialog).toBeTypeOf("function"); + + await act(async () => { + showDialog?.({ + text: "", + hasSelection: false, + addLink: addLinkSpy + }); + }); + + const suggestion = { + notePath: "root/target-note", + noteTitle: "Target note" + }; + + act(() => { + latestNoteAutocompletePropsRef.current.onKeyDownCapture({ + key: "Enter", + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + isComposing: false + }); + latestNoteAutocompletePropsRef.current.onChange(suggestion); + }); + + expect(latestModalPropsRef.current.show).toBe(false); + expect(logErrorSpy).not.toHaveBeenCalled(); + + await act(async () => { + latestModalPropsRef.current.onHidden(); + }); + + expect(addLinkSpy).toHaveBeenCalledWith("root/target-note", null); + }); +}); diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx index 4bb1d1711c..5d0ec6471c 100644 --- a/apps/client/src/widgets/dialogs/add_link.tsx +++ b/apps/client/src/widgets/dialogs/add_link.tsx @@ -1,15 +1,17 @@ +import type { JSX } from "preact"; +import { useEffect,useRef, useState } from "preact/hooks"; + import { t } from "../../services/i18n"; -import Modal from "../react/Modal"; -import Button from "../react/Button"; -import FormRadioGroup from "../react/FormRadioGroup"; -import NoteAutocomplete from "../react/NoteAutocomplete"; -import { useRef, useState, useEffect } from "preact/hooks"; -import tree from "../../services/tree"; import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; +import tree from "../../services/tree"; import { logError } from "../../services/ws"; +import Button from "../react/Button"; import FormGroup from "../react/FormGroup.js"; -import { refToJQuerySelector } from "../react/react_utils"; +import FormRadioGroup from "../react/FormRadioGroup"; import { useTriliumEvent } from "../react/hooks"; +import Modal from "../react/Modal"; +import NoteAutocomplete from "../react/NoteAutocomplete"; +import { refToJQuerySelector } from "../react/react_utils"; type LinkType = "reference-link" | "external-link" | "hyper-link"; @@ -26,6 +28,8 @@ export default function AddLinkDialog() { const [ suggestion, setSuggestion ] = useState(null); const [ shown, setShown ] = useState(false); const hasSubmittedRef = useRef(false); + const suggestionRef = useRef(null); + const submitOnSelectionRef = useRef(false); useTriliumEvent("showAddLinkDialog", opts => { setOpts(opts); @@ -85,15 +89,44 @@ export default function AddLinkDialog() { .trigger("select"); } - function onSubmit() { - hasSubmittedRef.current = true; + function submitSelectedLink(selectedSuggestion: Suggestion | null) { + submitOnSelectionRef.current = false; + hasSubmittedRef.current = Boolean(selectedSuggestion); - if (suggestion) { - // Insertion logic in onHidden because it needs focus. - setShown(false); - } else { + if (!selectedSuggestion) { logError("No link to add."); + return; } + + // Insertion logic in onHidden because it needs focus. + setShown(false); + } + + function onSuggestionChange(nextSuggestion: Suggestion | null) { + suggestionRef.current = nextSuggestion; + setSuggestion(nextSuggestion); + + if (submitOnSelectionRef.current && nextSuggestion) { + submitSelectedLink(nextSuggestion); + } + } + + function onAutocompleteKeyDownCapture(e: JSX.TargetedKeyboardEvent) { + if (e.key !== "Enter" || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing) { + return; + } + + submitOnSelectionRef.current = true; + } + + function onAutocompleteKeyUpCapture(e: JSX.TargetedKeyboardEvent) { + if (e.key === "Enter") { + submitOnSelectionRef.current = false; + } + } + + function onSubmit() { + submitSelectedLink(suggestionRef.current); } const autocompleteRef = useRef(null); @@ -109,19 +142,22 @@ export default function AddLinkDialog() { onSubmit={onSubmit} onShown={onShown} onHidden={() => { + submitOnSelectionRef.current = false; + // Insert the link. - if (hasSubmittedRef.current && suggestion && opts) { + if (hasSubmittedRef.current && suggestionRef.current && opts) { hasSubmittedRef.current = false; - if (suggestion.notePath) { + if (suggestionRef.current.notePath) { // Handle note link - opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); - } else if (suggestion.externalLink) { + opts.addLink(suggestionRef.current.notePath, linkType === "reference-link" ? null : linkTitle); + } else if (suggestionRef.current.externalLink) { // Handle external link - opts.addLink(suggestion.externalLink, linkTitle, true); + opts.addLink(suggestionRef.current.externalLink, linkTitle, true); } } + suggestionRef.current = null; setSuggestion(null); setShown(false); }} @@ -130,7 +166,9 @@ export default function AddLinkDialog() { void; onTextChange?: (text: string) => void; onKeyDown?: (e: KeyboardEvent) => void; + onKeyDownCapture?: JSX.KeyboardEventHandler; + onKeyUpCapture?: JSX.KeyboardEventHandler; onBlur?: (newValue: string) => void; noteIdChanged?: (noteId: string) => void; noteId?: string; } -export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onBlur }: NoteAutocompleteProps) { +export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onKeyDownCapture, onKeyUpCapture, onBlur }: NoteAutocompleteProps) { const ref = useSyncedRef(externalInputRef); useEffect(() => { @@ -127,6 +130,8 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, id={id} ref={ref} className="note-autocomplete form-control" + onKeyDownCapture={onKeyDownCapture} + onKeyUpCapture={onKeyUpCapture} placeholder={placeholder ?? t("add_link.search_note")} /> );