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 { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
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";
const searchNoteSummariesMock = vi.fn();
const listSavedViewsMock = vi.fn();
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: React.ComponentProps<"a"> & { href: string }) => (
@ -16,8 +17,21 @@ vi.mock("@/lib/notes-client", () => ({
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", () => {
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([
{
id: "note-prd-cutline",
@ -36,7 +50,7 @@ describe("SearchPage", () => {
expect(screen.getByRole("heading", { level: 1, name: "Search" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Search notes" })).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(
"href",
"/notes/note-prd-cutline"

View File

@ -2,15 +2,19 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
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";
export default function SearchPage() {
const searchParams = useSearchParams();
const [notes, setNotes] = useState<NoteSummary[]>([]);
const [query, setQuery] = useState(() => searchParams?.get("q") ?? "");
const debouncedQuery = useDebounce(query, 250);
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@ -18,40 +22,56 @@ export default function SearchPage() {
}, [searchParams]);
useEffect(() => {
const timeout = window.setTimeout(() => {
void (async () => {
try {
setNotes(await searchNoteSummaries(query));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load notes");
}
})();
}, 150);
void (async () => {
try {
setNotes(await searchNoteSummaries(debouncedQuery));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load notes");
}
})();
}, [debouncedQuery]);
return () => {
window.clearTimeout(timeout);
};
useEffect(() => {
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]);
const savedViews = [
{
id: "search-launch-readiness",
name: "Launch readiness",
query: "tag:launch tag:mvp status:active",
resultCount: notes.filter(
(note) =>
note.status === "active" &&
note.tags.some((tag) => tag === "launch" || tag === "mvp")
).length,
},
{
id: "search-drafts",
name: "Draft notes",
query: "status:draft",
resultCount: notes.filter((note) => note.status === "draft").length,
},
];
const handleDeleteSavedView = useCallback(async (id: string) => {
try {
await deleteSavedView(id);
setSavedViewsList((current) => current.filter((v) => v.id !== id));
} catch {
// Best-effort
}
}, []);
const savedViews = useMemo(() => savedViewsList.map((view) => ({
id: view.id,
name: view.name,
query: view.query,
resultCount: 0,
})), [savedViewsList]);
return (
<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)" }}>
<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)" }}>
{savedViews.map((view) => (
<Link
<div
key={view.id}
href={`/search?q=${encodeURIComponent(view.query)}`}
className="surface-muted"
style={{ padding: "var(--ml-space-4)", display: "grid", gap: "var(--ml-space-2)" }}
>
<strong>{view.name}</strong>
<span style={{ color: "var(--ml-text-secondary)" }}>{view.query}</span>
<span className="badge">{view.resultCount} results</span>
</Link>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "start" }}>
<Link href={`/search?q=${encodeURIComponent(view.query)}`} style={{ display: "grid", gap: "var(--ml-space-2)" }}>
<strong>{view.name}</strong>
<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 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]);
}