feat: Add wiki-link autocomplete in editor (closes #63) #120
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useRef } from "react";
|
||||||
|
import { LinkAutocomplete } from "./LinkAutocomplete";
|
||||||
|
|
||||||
interface EntryEditorProps {
|
interface EntryEditorProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -8,13 +9,14 @@ interface EntryEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EntryEditor - Markdown editor with live preview
|
* EntryEditor - Markdown editor with live preview and link autocomplete
|
||||||
*/
|
*/
|
||||||
export function EntryEditor({ content, onChange }: EntryEditorProps) {
|
export function EntryEditor({ content, onChange }: EntryEditorProps) {
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="entry-editor">
|
<div className="entry-editor relative">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Content (Markdown)
|
Content (Markdown)
|
||||||
@@ -33,16 +35,23 @@ export function EntryEditor({ content, onChange }: EntryEditorProps) {
|
|||||||
<div className="whitespace-pre-wrap">{content}</div>
|
<div className="whitespace-pre-wrap">{content}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<textarea
|
<div className="relative">
|
||||||
value={content}
|
<textarea
|
||||||
onChange={(e) => onChange(e.target.value)}
|
ref={textareaRef}
|
||||||
className="w-full min-h-[300px] p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
value={content}
|
||||||
placeholder="Write your content here... (Markdown supported)"
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
className="w-full min-h-[300px] p-4 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Write your content here... (Markdown supported)"
|
||||||
|
/>
|
||||||
|
<LinkAutocomplete
|
||||||
|
textareaRef={textareaRef}
|
||||||
|
onInsert={(newContent) => onChange(newContent)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Supports Markdown formatting. Use the Preview button to see how it will look.
|
Supports Markdown formatting. Type <code className="text-xs">[[</code> to insert links to other entries.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
384
apps/web/src/components/knowledge/LinkAutocomplete.tsx
Normal file
384
apps/web/src/components/knowledge/LinkAutocomplete.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { apiGet } from "@/lib/api/client";
|
||||||
|
import type { KnowledgeEntryWithTags } from "@mosaic/shared";
|
||||||
|
|
||||||
|
interface LinkAutocompleteProps {
|
||||||
|
/**
|
||||||
|
* The textarea element to attach autocomplete to
|
||||||
|
*/
|
||||||
|
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
/**
|
||||||
|
* Callback when a link is selected
|
||||||
|
*/
|
||||||
|
onInsert: (linkText: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutocompleteState {
|
||||||
|
isOpen: boolean;
|
||||||
|
query: string;
|
||||||
|
position: { top: number; left: number };
|
||||||
|
triggerIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkAutocomplete - Provides autocomplete for wiki-style links in markdown
|
||||||
|
*
|
||||||
|
* Detects when user types `[[` and shows a dropdown with matching entries.
|
||||||
|
* Arrow keys navigate, Enter selects, Esc cancels.
|
||||||
|
* Inserts `[[slug|title]]` on selection.
|
||||||
|
*/
|
||||||
|
export function LinkAutocomplete({
|
||||||
|
textareaRef,
|
||||||
|
onInsert,
|
||||||
|
}: LinkAutocompleteProps): React.ReactElement {
|
||||||
|
const [state, setState] = useState<AutocompleteState>({
|
||||||
|
isOpen: false,
|
||||||
|
query: "",
|
||||||
|
position: { top: 0, left: 0 },
|
||||||
|
triggerIndex: -1,
|
||||||
|
});
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number>(0);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for knowledge entries matching the query
|
||||||
|
*/
|
||||||
|
const searchEntries = useCallback(async (query: string): Promise<void> => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiGet<{
|
||||||
|
data: KnowledgeEntryWithTags[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}>(`/api/knowledge/search?q=${encodeURIComponent(query)}&limit=10`);
|
||||||
|
|
||||||
|
const searchResults: SearchResult[] = response.data.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
slug: entry.slug,
|
||||||
|
title: entry.title,
|
||||||
|
summary: entry.summary,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setResults(searchResults);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to search entries:", error);
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced search - waits 300ms after user stops typing
|
||||||
|
*/
|
||||||
|
const debouncedSearch = useCallback(
|
||||||
|
(query: string): void => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
void searchEntries(query);
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
[searchEntries]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate dropdown position relative to cursor
|
||||||
|
*/
|
||||||
|
const calculateDropdownPosition = useCallback(
|
||||||
|
(textarea: HTMLTextAreaElement, cursorIndex: number): { top: number; left: number } => {
|
||||||
|
// Create a mirror div to measure text position
|
||||||
|
const mirror = document.createElement("div");
|
||||||
|
const styles = window.getComputedStyle(textarea);
|
||||||
|
|
||||||
|
// Copy relevant styles
|
||||||
|
[
|
||||||
|
"fontFamily",
|
||||||
|
"fontSize",
|
||||||
|
"fontWeight",
|
||||||
|
"letterSpacing",
|
||||||
|
"lineHeight",
|
||||||
|
"padding",
|
||||||
|
"border",
|
||||||
|
"boxSizing",
|
||||||
|
"whiteSpace",
|
||||||
|
"wordWrap",
|
||||||
|
].forEach((prop) => {
|
||||||
|
mirror.style[prop as keyof CSSStyleDeclaration] = styles[prop as keyof CSSStyleDeclaration] as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
mirror.style.position = "absolute";
|
||||||
|
mirror.style.visibility = "hidden";
|
||||||
|
mirror.style.width = `${textarea.clientWidth}px`;
|
||||||
|
mirror.style.height = "auto";
|
||||||
|
mirror.style.whiteSpace = "pre-wrap";
|
||||||
|
mirror.style.wordWrap = "break-word";
|
||||||
|
|
||||||
|
// Get text up to cursor
|
||||||
|
const textBeforeCursor = textarea.value.substring(0, cursorIndex);
|
||||||
|
mirror.textContent = textBeforeCursor;
|
||||||
|
|
||||||
|
// Create a span for the cursor position
|
||||||
|
const cursorSpan = document.createElement("span");
|
||||||
|
cursorSpan.textContent = "|";
|
||||||
|
mirror.appendChild(cursorSpan);
|
||||||
|
|
||||||
|
document.body.appendChild(mirror);
|
||||||
|
|
||||||
|
const textareaRect = textarea.getBoundingClientRect();
|
||||||
|
const cursorSpanRect = cursorSpan.getBoundingClientRect();
|
||||||
|
|
||||||
|
const top = cursorSpanRect.top - textareaRect.top + textarea.scrollTop + 20;
|
||||||
|
const left = cursorSpanRect.left - textareaRect.left + textarea.scrollLeft;
|
||||||
|
|
||||||
|
document.body.removeChild(mirror);
|
||||||
|
|
||||||
|
return { top, left };
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle input changes in the textarea
|
||||||
|
*/
|
||||||
|
const handleInput = useCallback((): void => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const text = textarea.value;
|
||||||
|
|
||||||
|
// Look for `[[` before cursor
|
||||||
|
const textBeforeCursor = text.substring(0, cursorPos);
|
||||||
|
const lastTrigger = textBeforeCursor.lastIndexOf("[[");
|
||||||
|
|
||||||
|
// Check if we're in an autocomplete context
|
||||||
|
if (lastTrigger !== -1) {
|
||||||
|
const textAfterTrigger = textBeforeCursor.substring(lastTrigger + 2);
|
||||||
|
|
||||||
|
// Check if there's a closing `]]` between trigger and cursor
|
||||||
|
const hasClosing = textAfterTrigger.includes("]]");
|
||||||
|
|
||||||
|
if (!hasClosing) {
|
||||||
|
// We're in autocomplete mode
|
||||||
|
const query = textAfterTrigger;
|
||||||
|
const position = calculateDropdownPosition(textarea, cursorPos);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isOpen: true,
|
||||||
|
query,
|
||||||
|
position,
|
||||||
|
triggerIndex: lastTrigger,
|
||||||
|
});
|
||||||
|
|
||||||
|
debouncedSearch(query);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in autocomplete mode
|
||||||
|
if (state.isOpen) {
|
||||||
|
setState({
|
||||||
|
isOpen: false,
|
||||||
|
query: "",
|
||||||
|
position: { top: 0, left: 0 },
|
||||||
|
triggerIndex: -1,
|
||||||
|
});
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
}, [textareaRef, state.isOpen, calculateDropdownPosition, debouncedSearch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard navigation in the dropdown
|
||||||
|
*/
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent): void => {
|
||||||
|
if (!state.isOpen) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % results.length);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length > 0 && selectedIndex >= 0) {
|
||||||
|
const selected = results[selectedIndex];
|
||||||
|
if (selected) {
|
||||||
|
insertLink(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
setState({
|
||||||
|
isOpen: false,
|
||||||
|
query: "",
|
||||||
|
position: { top: 0, left: 0 },
|
||||||
|
triggerIndex: -1,
|
||||||
|
});
|
||||||
|
setResults([]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.isOpen, results, selectedIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert the selected link into the textarea
|
||||||
|
*/
|
||||||
|
const insertLink = useCallback(
|
||||||
|
(result: SearchResult): void => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const linkText = `[[${result.slug}|${result.title}]]`;
|
||||||
|
const text = textarea.value;
|
||||||
|
const before = text.substring(0, state.triggerIndex);
|
||||||
|
const after = text.substring(textarea.selectionStart);
|
||||||
|
const newText = before + linkText + after;
|
||||||
|
|
||||||
|
onInsert(newText);
|
||||||
|
|
||||||
|
// Close autocomplete
|
||||||
|
setState({
|
||||||
|
isOpen: false,
|
||||||
|
query: "",
|
||||||
|
position: { top: 0, left: 0 },
|
||||||
|
triggerIndex: -1,
|
||||||
|
});
|
||||||
|
setResults([]);
|
||||||
|
|
||||||
|
// Move cursor after the inserted link
|
||||||
|
const newCursorPos = state.triggerIndex + linkText.length;
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[textareaRef, state.triggerIndex, onInsert]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle click on a result
|
||||||
|
*/
|
||||||
|
const handleResultClick = useCallback(
|
||||||
|
(result: SearchResult): void => {
|
||||||
|
insertLink(result);
|
||||||
|
},
|
||||||
|
[insertLink]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event listeners
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
textarea.addEventListener("input", handleInput);
|
||||||
|
textarea.addEventListener("keydown", handleKeyDown as unknown as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
textarea.removeEventListener("input", handleInput);
|
||||||
|
textarea.removeEventListener("keydown", handleKeyDown as unknown as EventListener);
|
||||||
|
};
|
||||||
|
}, [textareaRef, handleInput, handleKeyDown]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup timeout on unmount
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!state.isOpen) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute z-50 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-64 overflow-y-auto min-w-[300px] max-w-[500px]"
|
||||||
|
style={{
|
||||||
|
top: `${state.position.top}px`,
|
||||||
|
left: `${state.position.left}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{state.query ? "No entries found" : "Start typing to search..."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="py-1">
|
||||||
|
{results.map((result, index) => (
|
||||||
|
<li
|
||||||
|
key={result.id}
|
||||||
|
className={`px-3 py-2 cursor-pointer transition-colors ${
|
||||||
|
index === selectedIndex
|
||||||
|
? "bg-blue-50 dark:bg-blue-900/30"
|
||||||
|
: "hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleResultClick(result)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{result.title}
|
||||||
|
</div>
|
||||||
|
{result.summary && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
||||||
|
{result.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
{result.slug}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="px-3 py-2 text-xs text-gray-400 dark:text-gray-500 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
↑↓ Navigate • Enter Select • Esc Cancel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
apps/web/src/components/knowledge/__tests__/EntryEditor.test.tsx
Normal file
142
apps/web/src/components/knowledge/__tests__/EntryEditor.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { EntryEditor } from "../EntryEditor";
|
||||||
|
|
||||||
|
// Mock the LinkAutocomplete component
|
||||||
|
vi.mock("../LinkAutocomplete", () => ({
|
||||||
|
LinkAutocomplete: () => <div data-testid="link-autocomplete">LinkAutocomplete</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("EntryEditor", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
content: "",
|
||||||
|
onChange: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render textarea in edit mode by default", () => {
|
||||||
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
||||||
|
expect(textarea).toBeInTheDocument();
|
||||||
|
expect(textarea.tagName).toBe("TEXTAREA");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display current content in textarea", () => {
|
||||||
|
const content = "# Test Content\n\nThis is a test.";
|
||||||
|
render(<EntryEditor {...defaultProps} content={content} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||||
|
expect(textarea.value).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onChange when content is modified", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChangeMock = vi.fn();
|
||||||
|
|
||||||
|
render(<EntryEditor {...defaultProps} onChange={onChangeMock} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
||||||
|
await user.type(textarea, "Hello");
|
||||||
|
|
||||||
|
expect(onChangeMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle between edit and preview modes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const content = "# Test\n\nPreview this content.";
|
||||||
|
|
||||||
|
render(<EntryEditor {...defaultProps} content={content} />);
|
||||||
|
|
||||||
|
// Initially in edit mode
|
||||||
|
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Switch to preview mode
|
||||||
|
const previewButton = screen.getByText("Preview");
|
||||||
|
await user.click(previewButton);
|
||||||
|
|
||||||
|
// Should show preview
|
||||||
|
expect(screen.queryByPlaceholderText(/Write your content here/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Edit")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(content)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Switch back to edit mode
|
||||||
|
const editButton = screen.getByText("Edit");
|
||||||
|
await user.click(editButton);
|
||||||
|
|
||||||
|
// Should show textarea again
|
||||||
|
expect(screen.getByPlaceholderText(/Write your content here/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Preview")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render LinkAutocomplete component in edit mode", () => {
|
||||||
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not render LinkAutocomplete in preview mode", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
// LinkAutocomplete should be present in edit mode
|
||||||
|
expect(screen.getByTestId("link-autocomplete")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Switch to preview mode
|
||||||
|
const previewButton = screen.getByText("Preview");
|
||||||
|
await user.click(previewButton);
|
||||||
|
|
||||||
|
// LinkAutocomplete should not be in preview mode
|
||||||
|
expect(screen.queryByTestId("link-autocomplete")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show help text about wiki-link syntax", () => {
|
||||||
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Type/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/\[\[/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/to insert links/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain content when toggling between modes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const content = "# My Content\n\nThis should persist.";
|
||||||
|
|
||||||
|
render(<EntryEditor {...defaultProps} content={content} />);
|
||||||
|
|
||||||
|
// Verify content in edit mode
|
||||||
|
const textarea = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||||
|
expect(textarea.value).toBe(content);
|
||||||
|
|
||||||
|
// Toggle to preview
|
||||||
|
await user.click(screen.getByText("Preview"));
|
||||||
|
expect(screen.getByText(content)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Toggle back to edit
|
||||||
|
await user.click(screen.getByText("Edit"));
|
||||||
|
const textareaAfter = screen.getByPlaceholderText(/Write your content here/) as HTMLTextAreaElement;
|
||||||
|
expect(textareaAfter.value).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply correct styling classes", () => {
|
||||||
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(/Write your content here/);
|
||||||
|
expect(textarea).toHaveClass("font-mono");
|
||||||
|
expect(textarea).toHaveClass("text-sm");
|
||||||
|
expect(textarea).toHaveClass("min-h-[300px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have label for content field", () => {
|
||||||
|
render(<EntryEditor {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Content (Markdown)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { LinkAutocomplete } from "../LinkAutocomplete";
|
||||||
|
import * as apiClient from "@/lib/api/client";
|
||||||
|
|
||||||
|
// Mock the API client
|
||||||
|
vi.mock("@/lib/api/client", () => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockApiGet = apiClient.apiGet as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
describe("LinkAutocomplete", () => {
|
||||||
|
let textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
let onInsertMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create a real textarea element
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.style.width = "500px";
|
||||||
|
textarea.style.height = "300px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
|
||||||
|
textareaRef = { current: textarea };
|
||||||
|
onInsertMock = vi.fn();
|
||||||
|
|
||||||
|
// Reset mocks
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockApiGet.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up
|
||||||
|
if (textareaRef.current) {
|
||||||
|
document.body.removeChild(textareaRef.current);
|
||||||
|
}
|
||||||
|
vi.clearAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not show dropdown initially", () => {
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show dropdown when typing [[", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should perform debounced search when typing query", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
const mockResults = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
slug: "test-entry",
|
||||||
|
title: "Test Entry",
|
||||||
|
summary: "A test entry",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
content: "Content",
|
||||||
|
contentHtml: "<p>Content</p>",
|
||||||
|
status: "PUBLISHED" as const,
|
||||||
|
visibility: "PUBLIC" as const,
|
||||||
|
createdBy: "user-1",
|
||||||
|
updatedBy: "user-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockApiGet.mockResolvedValue(mockResults);
|
||||||
|
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[test");
|
||||||
|
|
||||||
|
// Should not call API immediately
|
||||||
|
expect(mockApiGet).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Fast-forward 300ms
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith(
|
||||||
|
"/api/knowledge/search?q=test&limit=10"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should navigate results with arrow keys", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
const mockResults = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
slug: "entry-one",
|
||||||
|
title: "Entry One",
|
||||||
|
summary: "First entry",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
content: "Content",
|
||||||
|
contentHtml: "<p>Content</p>",
|
||||||
|
status: "PUBLISHED" as const,
|
||||||
|
visibility: "PUBLIC" as const,
|
||||||
|
createdBy: "user-1",
|
||||||
|
updatedBy: "user-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
slug: "entry-two",
|
||||||
|
title: "Entry Two",
|
||||||
|
summary: "Second entry",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
content: "Content",
|
||||||
|
contentHtml: "<p>Content</p>",
|
||||||
|
status: "PUBLISHED" as const,
|
||||||
|
visibility: "PUBLIC" as const,
|
||||||
|
createdBy: "user-1",
|
||||||
|
updatedBy: "user-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { total: 2, page: 1, limit: 10, totalPages: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockApiGet.mockResolvedValue(mockResults);
|
||||||
|
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[test");
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Entry One")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// First item should be selected (highlighted)
|
||||||
|
const firstItem = screen.getByText("Entry One").closest("li");
|
||||||
|
expect(firstItem).toHaveClass("bg-blue-50");
|
||||||
|
|
||||||
|
// Press ArrowDown
|
||||||
|
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||||
|
|
||||||
|
// Second item should now be selected
|
||||||
|
await waitFor(() => {
|
||||||
|
const secondItem = screen.getByText("Entry Two").closest("li");
|
||||||
|
expect(secondItem).toHaveClass("bg-blue-50");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Press ArrowUp
|
||||||
|
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||||
|
|
||||||
|
// First item should be selected again
|
||||||
|
await waitFor(() => {
|
||||||
|
const firstItem = screen.getByText("Entry One").closest("li");
|
||||||
|
expect(firstItem).toHaveClass("bg-blue-50");
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should insert link on Enter key", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
const mockResults = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
slug: "test-entry",
|
||||||
|
title: "Test Entry",
|
||||||
|
summary: "A test entry",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
content: "Content",
|
||||||
|
contentHtml: "<p>Content</p>",
|
||||||
|
status: "PUBLISHED" as const,
|
||||||
|
visibility: "PUBLIC" as const,
|
||||||
|
createdBy: "user-1",
|
||||||
|
updatedBy: "user-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockApiGet.mockResolvedValue(mockResults);
|
||||||
|
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[test");
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Press Enter to select
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should insert link on click", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
const mockResults = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
slug: "test-entry",
|
||||||
|
title: "Test Entry",
|
||||||
|
summary: "A test entry",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
content: "Content",
|
||||||
|
contentHtml: "<p>Content</p>",
|
||||||
|
status: "PUBLISHED" as const,
|
||||||
|
visibility: "PUBLIC" as const,
|
||||||
|
createdBy: "user-1",
|
||||||
|
updatedBy: "user-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockApiGet.mockResolvedValue(mockResults);
|
||||||
|
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[test");
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Test Entry")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on the result
|
||||||
|
fireEvent.click(screen.getByText("Test Entry"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close dropdown on Escape key", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[test");
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Press Escape
|
||||||
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close dropdown when closing brackets are typed", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[test");
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type closing brackets
|
||||||
|
await user.type(textarea, "]]");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show 'No entries found' when search returns no results", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
mockApiGet.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[nonexistent");
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("No entries found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show loading state while searching", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
// Mock a slow API response
|
||||||
|
let resolveSearch: (value: unknown) => void;
|
||||||
|
const searchPromise = new Promise((resolve) => {
|
||||||
|
resolveSearch = resolve;
|
||||||
|
});
|
||||||
|
mockApiGet.mockReturnValue(searchPromise as Promise<any>);
|
||||||
|
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[test");
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Searching...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve the search
|
||||||
|
resolveSearch!({
|
||||||
|
data: [],
|
||||||
|
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("Searching...")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display summary preview for entries", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
const mockResults = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
slug: "test-entry",
|
||||||
|
title: "Test Entry",
|
||||||
|
summary: "This is a helpful summary",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
content: "Content",
|
||||||
|
contentHtml: "<p>Content</p>",
|
||||||
|
status: "PUBLISHED" as const,
|
||||||
|
visibility: "PUBLIC" as const,
|
||||||
|
createdBy: "user-1",
|
||||||
|
updatedBy: "user-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockApiGet.mockResolvedValue(mockResults);
|
||||||
|
|
||||||
|
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
|
||||||
|
|
||||||
|
const textarea = textareaRef.current!;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
await user.type(textarea, "[[test");
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("This is a helpful summary")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user