feat(notes): wire backend note search
This commit is contained in:
parent
7bec4e864d
commit
4748ed32a6
@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import SearchPage from "./page";
|
import SearchPage from "./page";
|
||||||
|
|
||||||
const listNoteSummariesMock = vi.fn();
|
const searchNoteSummariesMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("next/link", () => ({
|
vi.mock("next/link", () => ({
|
||||||
default: ({ href, children, ...props }: React.ComponentProps<"a"> & { href: string }) => (
|
default: ({ href, children, ...props }: React.ComponentProps<"a"> & { href: string }) => (
|
||||||
@ -13,12 +13,12 @@ vi.mock("next/link", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/notes-client", () => ({
|
vi.mock("@/lib/notes-client", () => ({
|
||||||
listNoteSummaries: () => listNoteSummariesMock(),
|
searchNoteSummaries: () => searchNoteSummariesMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("SearchPage", () => {
|
describe("SearchPage", () => {
|
||||||
it("renders an accessible search field, saved searches, and note links", async () => {
|
it("renders an accessible search field, saved searches, and note links", async () => {
|
||||||
listNoteSummariesMock.mockResolvedValue([
|
searchNoteSummariesMock.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: "note-prd-cutline",
|
id: "note-prd-cutline",
|
||||||
workspaceId: "workspace-product",
|
workspaceId: "workspace-product",
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { listNoteSummaries } from "@/lib/notes-client";
|
import { searchNoteSummaries } from "@/lib/notes-client";
|
||||||
import type { NoteSummary } from "@/lib/types";
|
import type { NoteSummary } from "@/lib/types";
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
@ -12,27 +12,21 @@ export default function SearchPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
const timeout = window.setTimeout(() => {
|
||||||
try {
|
void (async () => {
|
||||||
setNotes(await listNoteSummaries());
|
try {
|
||||||
} catch (err) {
|
setNotes(await searchNoteSummaries(query));
|
||||||
setError(err instanceof Error ? err.message : "Unable to load notes");
|
setError(null);
|
||||||
}
|
} catch (err) {
|
||||||
})();
|
setError(err instanceof Error ? err.message : "Unable to load notes");
|
||||||
}, []);
|
}
|
||||||
|
})();
|
||||||
|
}, 150);
|
||||||
|
|
||||||
const filteredNotes = useMemo(() => {
|
return () => {
|
||||||
const normalized = query.trim().toLowerCase();
|
window.clearTimeout(timeout);
|
||||||
if (!normalized) {
|
};
|
||||||
return notes;
|
}, [query]);
|
||||||
}
|
|
||||||
|
|
||||||
return notes.filter((note) =>
|
|
||||||
note.title.toLowerCase().includes(normalized) ||
|
|
||||||
note.excerpt.toLowerCase().includes(normalized) ||
|
|
||||||
note.tags.some((tag) => tag.toLowerCase().includes(normalized)),
|
|
||||||
);
|
|
||||||
}, [notes, query]);
|
|
||||||
|
|
||||||
const savedViews = [
|
const savedViews = [
|
||||||
{
|
{
|
||||||
@ -57,7 +51,7 @@ export default function SearchPage() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
title="Search"
|
title="Search"
|
||||||
description="Lexical search, tag filtering, and retrieval entry points. Semantic ranking and explainability remain follow-up work."
|
description="Lexical search, tag filtering, and retrieval entry points. Semantic ranking and explainability remain follow-up work."
|
||||||
actions={<div className="badge">Dense retrieval shell</div>}
|
actions={<div className="badge">Backend-backed note search</div>}
|
||||||
>
|
>
|
||||||
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--ml-space-4)" }}>
|
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--ml-space-4)" }}>
|
||||||
<aside className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>
|
<aside className="surface-card" style={{ padding: "var(--ml-space-5)", display: "grid", gap: "var(--ml-space-4)" }}>
|
||||||
@ -93,11 +87,11 @@ export default function SearchPage() {
|
|||||||
<span className="badge">workspace:all</span>
|
<span className="badge">workspace:all</span>
|
||||||
<span className="badge">status:active</span>
|
<span className="badge">status:active</span>
|
||||||
<span className="badge">source:manual+agent</span>
|
<span className="badge">source:manual+agent</span>
|
||||||
<span className="badge">matched:title+tags</span>
|
<span className="badge">matched:title+body</span>
|
||||||
</div>
|
</div>
|
||||||
{error ? <div style={{ color: "var(--ml-text-secondary)" }}>{error}</div> : null}
|
{error ? <div style={{ color: "var(--ml-text-secondary)" }}>{error}</div> : null}
|
||||||
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
|
||||||
{filteredNotes.map((note) => (
|
{notes.map((note) => (
|
||||||
<Link key={note.id} href={`/notes/${note.id}`} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
|
<Link key={note.id} href={`/notes/${note.id}`} className="surface-muted" style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) repeat(3, minmax(100px, auto))", gap: "var(--ml-space-3)", alignItems: "start" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) repeat(3, minmax(100px, auto))", gap: "var(--ml-space-3)", alignItems: "start" }}>
|
||||||
<div style={{ display: "grid", gap: "var(--ml-space-2)" }}>
|
<div style={{ display: "grid", gap: "var(--ml-space-2)" }}>
|
||||||
|
|||||||
@ -240,6 +240,14 @@ export async function listNoteSummaries(): Promise<NoteSummary[]> {
|
|||||||
return response.items.map(toNoteSummary);
|
return response.items.map(toNoteSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchNoteSummaries(query: string): Promise<NoteSummary[]> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
const search = query.trim();
|
||||||
|
const path = search ? `/notes?search=${encodeURIComponent(search)}` : "/notes";
|
||||||
|
const response = await api.fetch<NoteListResponse>(path);
|
||||||
|
return response.items.map(toNoteSummary);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listNotesForWorkspace(workspaceId: string): Promise<NoteSummary[]> {
|
export async function listNotesForWorkspace(workspaceId: string): Promise<NoteSummary[]> {
|
||||||
const api = createNotesApiClient();
|
const api = createNotesApiClient();
|
||||||
const response = await api.fetch<NoteListResponse>(`/notes?workspaceId=${encodeURIComponent(workspaceId)}`);
|
const response = await api.fetch<NoteListResponse>(`/notes?workspaceId=${encodeURIComponent(workspaceId)}`);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user