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 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)" }}>
|
||||||
|
|||||||
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