mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
Debounce issues search input
This commit is contained in:
parent
c6779b570f
commit
93355bae6b
2 changed files with 107 additions and 14 deletions
|
|
@ -148,11 +148,13 @@ describe("IssuesList", () => {
|
||||||
mockIssuesApi.list.mockReset();
|
mockIssuesApi.list.mockReset();
|
||||||
mockIssuesApi.listLabels.mockReset();
|
mockIssuesApi.listLabels.mockReset();
|
||||||
mockAuthApi.getSession.mockReset();
|
mockAuthApi.getSession.mockReset();
|
||||||
|
mockIssuesApi.list.mockResolvedValue([]);
|
||||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,4 +186,56 @@ describe("IssuesList", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("debounces search updates so typing does not notify the page on every keystroke", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const onSearchChange = vi.fn();
|
||||||
|
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||||
|
|
||||||
|
const { root } = renderWithQueryClient(
|
||||||
|
<IssuesList
|
||||||
|
issues={[localIssue]}
|
||||||
|
agents={[]}
|
||||||
|
projects={[]}
|
||||||
|
viewStateKey="paperclip:test-issues"
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
onUpdateIssue={() => undefined}
|
||||||
|
/>,
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||||
|
expect(valueSetter).toBeTypeOf("function");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
if (!input || !valueSetter) return;
|
||||||
|
valueSetter.call(input, "a");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
valueSetter.call(input, "ab");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSearchChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(149);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSearchChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSearchChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSearchChange).toHaveBeenCalledWith("ab");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
|
|
@ -30,6 +30,7 @@ import type { Issue } from "@paperclipai/shared";
|
||||||
|
|
||||||
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||||
const priorityOrder = ["critical", "high", "medium", "low"];
|
const priorityOrder = ["critical", "high", "medium", "low"];
|
||||||
|
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
@ -177,6 +178,50 @@ interface IssuesListProps {
|
||||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IssueSearchInput({
|
||||||
|
value,
|
||||||
|
onDebouncedChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onDebouncedChange?: (search: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
|
const lastCommittedValueRef = useRef(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftValue(value);
|
||||||
|
lastCommittedValueRef.current = value;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onDebouncedChange || draftValue === lastCommittedValueRef.current) return;
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
lastCommittedValueRef.current = draftValue;
|
||||||
|
startTransition(() => {
|
||||||
|
onDebouncedChange(draftValue);
|
||||||
|
});
|
||||||
|
}, ISSUE_SEARCH_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [draftValue, onDebouncedChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-48 sm:w-64 md:w-80">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={draftValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraftValue(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Search issues..."
|
||||||
|
className="pl-7 text-xs sm:text-sm"
|
||||||
|
aria-label="Search issues"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function IssuesList({
|
export function IssuesList({
|
||||||
issues,
|
issues,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -394,19 +439,13 @@ export function IssuesList({
|
||||||
<Plus className="h-4 w-4 sm:mr-1" />
|
<Plus className="h-4 w-4 sm:mr-1" />
|
||||||
<span className="hidden sm:inline">New Issue</span>
|
<span className="hidden sm:inline">New Issue</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative w-48 sm:w-64 md:w-80">
|
<IssueSearchInput
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
value={issueSearch}
|
||||||
<Input
|
onDebouncedChange={(nextSearch) => {
|
||||||
value={issueSearch}
|
setIssueSearch(nextSearch);
|
||||||
onChange={(e) => {
|
onSearchChange?.(nextSearch);
|
||||||
setIssueSearch(e.target.value);
|
}}
|
||||||
onSearchChange?.(e.target.value);
|
/>
|
||||||
}}
|
|
||||||
placeholder="Search issues..."
|
|
||||||
className="pl-7 text-xs sm:text-sm"
|
|
||||||
aria-label="Search issues"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue