Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions tests/e2e/categorized-search.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test, expect } from "@playwright/test";

test.describe("Categorized Search Results (#132)", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});

test("categorizes search results into distinct sections for title, tag, and content matches", async ({ page }) => {
// 1. Create note with title match
await page.getByRole("button", { name: "New note", exact: true }).first().click();
await page.locator("#titleInput").fill("React Architecture");
await page.locator("#contentInput").fill("Overview of component design.");
await page.locator("#closeNoteEditorButton").click();
await expect(page.locator("#noteEditorOverlay")).toBeHidden();

// 2. Create note with tag match
await page.getByRole("button", { name: "New note", exact: true }).first().click();
await page.locator("#titleInput").fill("Web Frontend");
await page.locator("#contentInput").fill("Discussion with #react tag included.");
await page.locator("#closeNoteEditorButton").click();
await expect(page.locator("#noteEditorOverlay")).toBeHidden();

// 3. Create note with content body match only
await page.getByRole("button", { name: "New note", exact: true }).first().click();
await page.locator("#titleInput").fill("General Discussion");
await page.locator("#contentInput").fill("We are using React for state management.");
await page.locator("#closeNoteEditorButton").click();
await expect(page.locator("#noteEditorOverlay")).toBeHidden();

// Search for "React"
const searchInput = page.locator("#searchInput");
await searchInput.fill("React");

// Wait for search result sections to render
const noteBoard = page.locator("#noteList");
await expect(noteBoard).toBeVisible();

const headings = page.locator(".note-board-heading");
const headingTexts = await headings.allTextContents();

expect(headingTexts).toContain("TITLE MATCHES");
expect(headingTexts).toContain("TAG MATCHES");
expect(headingTexts).toContain("CONTENT MATCHES");

// Verify correct cards under sections
const titleSection = page.locator('.note-board-section[data-section-id="title"]');
await expect(titleSection).toContainText("React Architecture");

const tagSection = page.locator('.note-board-section[data-section-id="tags"]');
await expect(tagSection).toContainText("Web Frontend");

const contentSection = page.locator('.note-board-section[data-section-id="notes"]');
await expect(contentSection).toContainText("General Discussion");

// Clear search and verify standard section returns
await searchInput.fill("");
await expect(page.locator('.note-board-section[data-section-id="notes"]')).toBeVisible();
await expect(page.locator('.note-board-section[data-section-id="title"]')).toBeHidden();
});
});
20 changes: 20 additions & 0 deletions tests/unit/note-presentation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,23 @@ test("createNoteBoardSections partitions upstream order without taking query own
message: "NOTE_PRESENTATION_OPTIONS_INVALID",
});
});

test("createNoteBoardSections categorizes search results when query is provided", async () => {
const { createNoteBoardSections } = await loadModule();
const notes = [
Object.freeze({ id: "note-title", title: "React Components", content: "Details", tags: [] }),
Object.freeze({ id: "note-tag", title: "Web Dev", content: "Notes", tags: ["react", "frontend"] }),
Object.freeze({ id: "note-japanese", title: "Kanji 漢", content: "Learning", template: "kanji", tags: ["kanji"] }),
Object.freeze({ id: "note-content", title: "General", content: "Mentions React in body", tags: [] }),
];
const notesById = new Map(notes.map((note) => [note.id, note]));
const orderedIds = ["note-title", "note-tag", "note-japanese", "note-content"];

const sections = createNoteBoardSections({ notesById, orderedIds, query: "react" });
assert.deepEqual(sections, [
{ id: "title", label: "TITLE MATCHES", orderedIds: ["note-title"] },
{ id: "tags", label: "TAG MATCHES", orderedIds: ["note-tag"] },
{ id: "japanese", label: "JAPANESE STUDY", orderedIds: ["note-japanese"] },
{ id: "notes", label: "CONTENT MATCHES", orderedIds: ["note-content"] },
]);
});
12 changes: 6 additions & 6 deletions ui/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ export function createListView({ container, onSelect, onEmptyAction = () => {},
virtualized: false,
};

function projectSections(notesById, orderedIds) {
return createNoteBoardSections({ notesById, orderedIds })
function projectSections(notesById, orderedIds, query = "") {
return createNoteBoardSections({ notesById, orderedIds, query })
.filter((section) => section.orderedIds.length > 0);
}

Expand Down Expand Up @@ -154,7 +154,7 @@ export function createListView({ container, onSelect, onEmptyAction = () => {},
}

function renderWindow() {
const { notesById, boardIds } = currentPayload;
const { notesById, boardIds, query } = currentPayload;
const scrollTop = scrollOwner.scrollTop;
const viewport = Math.max(
scrollOwner.clientHeight || VIRTUAL_ROW_HEIGHT * 6,
Expand All @@ -170,7 +170,7 @@ export function createListView({ container, onSelect, onEmptyAction = () => {},
const visibleCount = Math.ceil(viewport / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN * 2;
const endIndex = Math.min(boardIds.length, startIndex + visibleCount);
const visibleIds = boardIds.slice(startIndex, endIndex);
const sections = projectSections(notesById, visibleIds);
const sections = projectSections(notesById, visibleIds, query);
const retainedIds = new Set();
const fragment = document.createDocumentFragment();

Expand Down Expand Up @@ -279,8 +279,8 @@ export function createListView({ container, onSelect, onEmptyAction = () => {},
}
}

function render({ notesById, orderedIds, activeId, query, emptyPresentation, viewMode = "list" }) {
const sections = projectSections(notesById, orderedIds);
function render({ notesById, orderedIds, activeId, query = "", emptyPresentation, viewMode = "list" }) {
const sections = projectSections(notesById, orderedIds, query);
const boardIds = sections.flatMap((section) => section.orderedIds);
const virtualized = boardIds.length >= VIRTUALIZATION_THRESHOLD;
currentPayload = {
Expand Down
57 changes: 49 additions & 8 deletions ui/notePresentation.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,28 +81,69 @@ export function deriveNotePreview(content, options = {}) {
return `${projected.slice(0, maxLength - 1).trimEnd()}…`.padEnd(maxLength, "…");
}

export function createNoteBoardSections({ notesById, orderedIds } = {}) {
export function createNoteBoardSections({ notesById, orderedIds, query = "" } = {}) {
if (!(notesById instanceof Map) || !Array.isArray(orderedIds)) {
throw presentationError();
}

const pinnedIds = [];
const noteIds = [];
const normalizedQuery = typeof query === "string" ? query.trim().toLowerCase() : "";

if (!normalizedQuery) {
const pinnedIds = [];
const noteIds = [];
for (const id of orderedIds) {
const note = notesById.get(id);
if (!note || typeof note !== "object") {
continue;
}
if (note.pinned === true) {
pinnedIds.push(id);
} else {
noteIds.push(id);
}
}

return [
{ id: "pinned", label: "PINNED", orderedIds: pinnedIds },
{ id: "notes", label: "NOTES", orderedIds: noteIds },
];
}

const titleIds = [];
const tagIds = [];
const japaneseIds = [];
const contentIds = [];

for (const id of orderedIds) {
const note = notesById.get(id);
if (!note || typeof note !== "object") {
continue;
}
if (note.pinned === true) {
pinnedIds.push(id);

const title = typeof note.title === "string" ? note.title.toLowerCase() : "";
const tags = Array.isArray(note.tags) ? note.tags.map((t) => String(t).toLowerCase()) : [];
const isJapanese = Boolean(
note.japanese
|| note.template
|| tags.some((t) => ["n5", "n4", "n3", "n2", "n1", "vocabulary", "kanji", "grammar", "japanese"].includes(t)),
);

if (title.includes(normalizedQuery)) {
titleIds.push(id);
} else if (tags.some((t) => t.includes(normalizedQuery.replace(/^#/, "")))) {
tagIds.push(id);
} else if (isJapanese) {
japaneseIds.push(id);
} else {
noteIds.push(id);
contentIds.push(id);
}
}

return [
{ id: "pinned", label: "PINNED", orderedIds: pinnedIds },
{ id: "notes", label: "NOTES", orderedIds: noteIds },
{ id: "title", label: "TITLE MATCHES", orderedIds: titleIds },
{ id: "tags", label: "TAG MATCHES", orderedIds: tagIds },
{ id: "japanese", label: "JAPANESE STUDY", orderedIds: japaneseIds },
{ id: "notes", label: "CONTENT MATCHES", orderedIds: contentIds },
];
}

Expand Down
Loading