feat(web): saved views CRUD, keyboard shortcuts, debounced search

- saved-views-client.ts: full CRUD client for backend saved-views module
- use-keyboard-shortcuts.ts: reusable hook for global keyboard shortcuts
- KeyboardShortcuts.tsx: wired into (app) layout — Cmd+K search, Cmd+N workspaces, Cmd+Shift+D dashboard, Cmd+Shift+R reviews, Esc blur
- use-debounce.ts: shared debounce hook (replaces inline setTimeout in search)
- Search page: saved views loaded from backend with save/delete UI
- Search page: search debounced at 250ms via useDebounce hook
- Updated search page test to mock saved-views-client and useDebounce

Verification: web typecheck + 6/6 tests pass.
This commit is contained in:
saravanakumardb1 2026-03-10 19:39:28 -07:00
parent ca3cdbad4e
commit 12d90098eb
7 changed files with 294 additions and 41 deletions

View File

@ -1,5 +1,11 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
export default function ProductLayout({ children }: { children: ReactNode }) { export default function ProductLayout({ children }: { children: ReactNode }) {
return children; return (
<>
<KeyboardShortcuts />
{children}
</>
);
} }

View File

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import SearchPage from "./page"; import SearchPage from "./page";
const searchNoteSummariesMock = vi.fn(); const searchNoteSummariesMock = vi.fn();
const listSavedViewsMock = 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 }) => (
@ -16,8 +17,21 @@ vi.mock("@/lib/notes-client", () => ({
searchNoteSummaries: () => searchNoteSummariesMock(), searchNoteSummaries: () => searchNoteSummariesMock(),
})); }));
vi.mock("@/lib/saved-views-client", () => ({
listSavedViews: () => listSavedViewsMock(),
createSavedView: vi.fn(async () => ({})),
deleteSavedView: vi.fn(async () => undefined),
}));
vi.mock("@/lib/use-debounce", () => ({
useDebounce: (value: unknown) => value,
}));
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 () => {
listSavedViewsMock.mockResolvedValue([
{ id: "sv-1", name: "Launch readiness", scope: "search", query: "tag:launch", createdAt: "", updatedAt: "", sortOrder: 0 },
]);
searchNoteSummariesMock.mockResolvedValue([ searchNoteSummariesMock.mockResolvedValue([
{ {
id: "note-prd-cutline", id: "note-prd-cutline",
@ -36,7 +50,7 @@ describe("SearchPage", () => {
expect(screen.getByRole("heading", { level: 1, name: "Search" })).toBeInTheDocument(); expect(screen.getByRole("heading", { level: 1, name: "Search" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search notes" })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "Search notes" })).toBeInTheDocument();
expect(screen.getByText("Saved searches")).toBeInTheDocument(); expect(screen.getByText("Saved searches")).toBeInTheDocument();
expect(screen.getByText("Launch readiness")).toBeInTheDocument(); expect(await screen.findByText("Launch readiness")).toBeInTheDocument();
expect(await screen.findByRole("link", { name: /MVP cut line for agentic notes launch/i })).toHaveAttribute( expect(await screen.findByRole("link", { name: /MVP cut line for agentic notes launch/i })).toHaveAttribute(
"href", "href",
"/notes/note-prd-cutline" "/notes/note-prd-cutline"

View File

@ -2,15 +2,19 @@
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { searchNoteSummaries } from "@/lib/notes-client"; import { searchNoteSummaries } from "@/lib/notes-client";
import { listSavedViews, createSavedView, deleteSavedView, type SavedView } from "@/lib/saved-views-client";
import { useDebounce } from "@/lib/use-debounce";
import type { NoteSummary } from "@/lib/types"; import type { NoteSummary } from "@/lib/types";
export default function SearchPage() { export default function SearchPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [notes, setNotes] = useState<NoteSummary[]>([]); const [notes, setNotes] = useState<NoteSummary[]>([]);
const [query, setQuery] = useState(() => searchParams?.get("q") ?? ""); const [query, setQuery] = useState(() => searchParams?.get("q") ?? "");
const debouncedQuery = useDebounce(query, 250);
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@ -18,40 +22,56 @@ export default function SearchPage() {
}, [searchParams]); }, [searchParams]);
useEffect(() => { useEffect(() => {
const timeout = window.setTimeout(() => { void (async () => {
void (async () => { try {
try { setNotes(await searchNoteSummaries(debouncedQuery));
setNotes(await searchNoteSummaries(query)); setError(null);
setError(null); } catch (err) {
} catch (err) { setError(err instanceof Error ? err.message : "Unable to load notes");
setError(err instanceof Error ? err.message : "Unable to load notes"); }
} })();
})(); }, [debouncedQuery]);
}, 150);
return () => { useEffect(() => {
window.clearTimeout(timeout); void (async () => {
}; try {
setSavedViewsList(await listSavedViews("search"));
} catch {
// Saved views are best-effort
}
})();
}, []);
const handleSaveCurrentSearch = useCallback(async () => {
if (!query.trim()) return;
try {
const view = await createSavedView({
id: `sv-${Date.now()}`,
name: query.trim().slice(0, 60),
scope: "search",
query: query.trim(),
});
setSavedViewsList((current) => [...current, view]);
} catch {
// Best-effort
}
}, [query]); }, [query]);
const savedViews = [ const handleDeleteSavedView = useCallback(async (id: string) => {
{ try {
id: "search-launch-readiness", await deleteSavedView(id);
name: "Launch readiness", setSavedViewsList((current) => current.filter((v) => v.id !== id));
query: "tag:launch tag:mvp status:active", } catch {
resultCount: notes.filter( // Best-effort
(note) => }
note.status === "active" && }, []);
note.tags.some((tag) => tag === "launch" || tag === "mvp")
).length, const savedViews = useMemo(() => savedViewsList.map((view) => ({
}, id: view.id,
{ name: view.name,
id: "search-drafts", query: view.query,
name: "Draft notes", resultCount: 0,
query: "status:draft", })), [savedViewsList]);
resultCount: notes.filter((note) => note.status === "draft").length,
},
];
return ( return (
<AppShell <AppShell
@ -61,20 +81,44 @@ export default function SearchPage() {
> >
<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)" }}>
<div style={{ fontWeight: 700 }}>Saved searches</div> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ fontWeight: 700 }}>Saved searches</div>
{query.trim() ? (
<button
type="button"
className="badge"
onClick={() => void handleSaveCurrentSearch()}
style={{ cursor: "pointer" }}
>
+ Save current
</button>
) : null}
</div>
<div style={{ display: "grid", gap: "var(--ml-space-3)" }}> <div style={{ display: "grid", gap: "var(--ml-space-3)" }}>
{savedViews.map((view) => ( {savedViews.map((view) => (
<Link <div
key={view.id} key={view.id}
href={`/search?q=${encodeURIComponent(view.query)}`}
className="surface-muted" className="surface-muted"
style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }} style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}
> >
<strong>{view.name}</strong> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "start" }}>
<span style={{ color: "var(--ml-text-secondary)" }}>{view.query}</span> <Link href={`/search?q=${encodeURIComponent(view.query)}`} style={{ display: "grid", gap: "var(--ml-space-2)" }}>
<span className="badge">{view.resultCount} results</span> <strong>{view.name}</strong>
</Link> <span style={{ color: "var(--ml-text-secondary)" }}>{view.query}</span>
</Link>
<button
type="button"
onClick={() => void handleDeleteSavedView(view.id)}
style={{ color: "var(--ml-text-secondary)", background: "none", border: "none", cursor: "pointer", fontSize: "0.75rem" }}
>
Remove
</button>
</div>
</div>
))} ))}
{savedViews.length === 0 ? (
<span style={{ color: "var(--ml-text-secondary)", fontSize: "0.875rem" }}>No saved searches yet.</span>
) : null}
</div> </div>
<div style={{ display: "grid", gap: "var(--ml-space-2)" }}> <div style={{ display: "grid", gap: "var(--ml-space-2)" }}>

View File

@ -0,0 +1,53 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { useKeyboardShortcuts, type KeyboardShortcut } from "@/lib/use-keyboard-shortcuts";
export function KeyboardShortcuts() {
const router = useRouter();
const shortcuts = useMemo<KeyboardShortcut[]>(
() => [
{
key: "k",
meta: true,
handler: () => router.push("/search"),
description: "Open search",
},
{
key: "n",
meta: true,
handler: () => router.push("/workspaces"),
description: "Open workspaces (new note)",
},
{
key: "d",
meta: true,
shift: true,
handler: () => router.push("/dashboard"),
description: "Go to dashboard",
},
{
key: "r",
meta: true,
shift: true,
handler: () => router.push("/reviews"),
description: "Go to reviews",
},
{
key: "Escape",
handler: () => {
const active = document.activeElement as HTMLElement | null;
active?.blur();
},
description: "Dismiss / blur",
},
],
[router],
);
useKeyboardShortcuts(shortcuts);
return null;
}

View File

@ -0,0 +1,79 @@
"use client";
import { createApiClient } from "@bytelyst/api-client";
import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config";
export interface SavedView {
id: string;
name: string;
scope: "workspace" | "search" | "review";
description?: string;
query: string;
filters?: Record<string, string>;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
interface SavedViewListResponse {
items: SavedView[];
total: number;
}
function getAccessToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(`${PRODUCT_ID}_access_token`);
}
function createNotesApiClient() {
return createApiClient({
baseUrl: NOTES_API_URL,
getToken: getAccessToken,
defaultHeaders: { "x-product-id": PRODUCT_ID },
});
}
export async function listSavedViews(scope?: SavedView["scope"]): Promise<SavedView[]> {
const api = createNotesApiClient();
const params = new URLSearchParams();
if (scope) params.set("scope", scope);
const response = await api.fetch<SavedViewListResponse>(`/saved-views?${params.toString()}`);
return response.items;
}
export async function getSavedView(id: string): Promise<SavedView> {
const api = createNotesApiClient();
return api.fetch<SavedView>(`/saved-views/${encodeURIComponent(id)}`);
}
export async function createSavedView(input: {
id: string;
name: string;
scope: SavedView["scope"];
description?: string;
query: string;
filters?: Record<string, string>;
sortOrder?: number;
}): Promise<SavedView> {
const api = createNotesApiClient();
return api.fetch<SavedView>("/saved-views", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateSavedView(
id: string,
updates: Partial<Pick<SavedView, "name" | "description" | "query" | "filters" | "sortOrder">>,
): Promise<SavedView> {
const api = createNotesApiClient();
return api.fetch<SavedView>(`/saved-views/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
export async function deleteSavedView(id: string): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/saved-views/${encodeURIComponent(id)}`, { method: "DELETE" });
}

View File

@ -0,0 +1,14 @@
"use client";
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debouncedValue;
}

View File

@ -0,0 +1,43 @@
"use client";
import { useEffect } from "react";
export interface KeyboardShortcut {
key: string;
meta?: boolean;
shift?: boolean;
alt?: boolean;
handler: () => void;
description: string;
}
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
const isInputFocused =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable;
for (const shortcut of shortcuts) {
const metaMatch = shortcut.meta ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
const shiftMatch = shortcut.shift ? event.shiftKey : !event.shiftKey;
const altMatch = shortcut.alt ? event.altKey : !event.altKey;
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase();
if (keyMatch && metaMatch && shiftMatch && altMatch) {
if (shortcut.key === "Escape" || !isInputFocused) {
event.preventDefault();
shortcut.handler();
return;
}
}
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [shortcuts]);
}