feat: add wiki-link autocomplete in editor (closes #63)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed

This commit is contained in:
Jason Woltje
2026-01-30 00:23:46 -06:00
parent 22cd68811d
commit c9cee504e8
4 changed files with 999 additions and 10 deletions

View File

@@ -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();
});
});