feat: add wiki-link autocomplete in editor (closes #63)
This commit is contained in:
@@ -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