mirror of
https://github.com/zadam/trilium.git
synced 2026-03-22 16:23:50 +01:00
fix: add link dialog enter act correctly
This commit is contained in:
parent
5b77152fdf
commit
f6201d8581
160
apps/client/src/widgets/dialogs/add_link.spec.tsx
Normal file
160
apps/client/src/widgets/dialogs/add_link.spec.tsx
Normal file
@ -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<string, (payload: any) => 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 (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSubmit?.();
|
||||
}}>
|
||||
{props.children}
|
||||
{props.footer}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../react/FormGroup", () => ({
|
||||
default: ({ children }: { children: ComponentChildren }) => <div>{children}</div>
|
||||
}));
|
||||
|
||||
vi.mock("../react/Button", () => ({
|
||||
default: ({ text }: { text: string }) => <button type="submit">{text}</button>
|
||||
}));
|
||||
|
||||
vi.mock("../react/FormRadioGroup", () => ({
|
||||
default: () => null
|
||||
}));
|
||||
|
||||
vi.mock("../react/NoteAutocomplete", () => ({
|
||||
default: (props: any) => {
|
||||
latestNoteAutocompletePropsRef.current = props;
|
||||
return <input ref={props.inputRef} />;
|
||||
}
|
||||
}));
|
||||
|
||||
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(<AddLinkDialog />, 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);
|
||||
});
|
||||
});
|
||||
@ -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<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const hasSubmittedRef = useRef(false);
|
||||
const suggestionRef = useRef<Suggestion | null>(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<HTMLInputElement>) {
|
||||
if (e.key !== "Enter" || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitOnSelectionRef.current = true;
|
||||
}
|
||||
|
||||
function onAutocompleteKeyUpCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
submitOnSelectionRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
submitSelectedLink(suggestionRef.current);
|
||||
}
|
||||
|
||||
const autocompleteRef = useRef<HTMLInputElement>(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() {
|
||||
<FormGroup label={t("add_link.note")} name="note">
|
||||
<NoteAutocomplete
|
||||
inputRef={autocompleteRef}
|
||||
onChange={setSuggestion}
|
||||
onChange={onSuggestionChange}
|
||||
onKeyDownCapture={onAutocompleteKeyDownCapture}
|
||||
onKeyUpCapture={onAutocompleteKeyUpCapture}
|
||||
opts={{
|
||||
allowExternalLinks: true,
|
||||
allowCreatingNotes: true
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { t } from "../../services/i18n";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
||||
import type { RefObject } from "preact";
|
||||
import type { JSX,RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
||||
import { useSyncedRef } from "./hooks";
|
||||
|
||||
interface NoteAutocompleteProps {
|
||||
@ -16,12 +17,14 @@ interface NoteAutocompleteProps {
|
||||
onChange?: (suggestion: Suggestion | null) => void;
|
||||
onTextChange?: (text: string) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onKeyDownCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
||||
onKeyUpCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
||||
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<HTMLInputElement>(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")} />
|
||||
</div>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user