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

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