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:
parent
ca3cdbad4e
commit
12d90098eb
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)" }}>
|
||||
|
||||
53
web/src/components/KeyboardShortcuts.tsx
Normal file
53
web/src/components/KeyboardShortcuts.tsx
Normal 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;
|
||||
}
|
||||
79
web/src/lib/saved-views-client.ts
Normal file
79
web/src/lib/saved-views-client.ts
Normal 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" });
|
||||
}
|
||||
14
web/src/lib/use-debounce.ts
Normal file
14
web/src/lib/use-debounce.ts
Normal 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;
|
||||
}
|
||||
43
web/src/lib/use-keyboard-shortcuts.ts
Normal file
43
web/src/lib/use-keyboard-shortcuts.ts
Normal 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]);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user