Speed up issues page search responsiveness

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 11:01:09 -05:00
parent 5ee4cd98e8
commit dd8c1ca3b2
2 changed files with 60 additions and 43 deletions

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useCallback, useRef } from "react"; import { startTransition, 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";
@ -68,6 +68,7 @@ const quickFilterPresets = [
{ label: "Backlog", statuses: ["backlog"] }, { label: "Backlog", statuses: ["backlog"] },
{ label: "Done", statuses: ["done", "cancelled"] }, { label: "Done", statuses: ["done", "cancelled"] },
]; ];
const ISSUE_SEARCH_COMMIT_DELAY_MS = 150;
function getViewState(key: string): IssueViewState { function getViewState(key: string): IssueViewState {
try { try {
@ -174,6 +175,39 @@ interface IssuesListProps {
onUpdateIssue: (id: string, data: Record<string, unknown>) => void; onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
} }
interface IssuesSearchInputProps {
initialValue: string;
onValueCommitted: (value: string) => void;
}
function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) {
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
onValueCommitted(value);
}, ISSUE_SEARCH_COMMIT_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [value, onValueCommitted]);
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={value}
onChange={(e) => setValue(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,
@ -210,20 +244,12 @@ export function IssuesList({
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null); const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState(""); const [assigneeSearch, setAssigneeSearch] = useState("");
const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
const [debouncedIssueSearch, setDebouncedIssueSearch] = useState(issueSearch); const normalizedIssueSearch = issueSearch.trim();
const normalizedIssueSearch = debouncedIssueSearch.trim();
useEffect(() => { useEffect(() => {
setIssueSearch(initialSearch ?? ""); setIssueSearch(initialSearch ?? "");
}, [initialSearch]); }, [initialSearch]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedIssueSearch(issueSearch);
}, 300);
return () => window.clearTimeout(timeoutId);
}, [issueSearch]);
// Reload view state from localStorage when company changes (scopedKey changes). // Reload view state from localStorage when company changes (scopedKey changes).
const prevScopedKey = useRef(scopedKey); const prevScopedKey = useRef(scopedKey);
useEffect(() => { useEffect(() => {
@ -235,6 +261,13 @@ export function IssuesList({
} }
}, [scopedKey, initialAssignees]); }, [scopedKey, initialAssignees]);
const handleIssueSearchCommit = useCallback((nextSearch: string) => {
startTransition(() => {
setIssueSearch(nextSearch);
});
onSearchChange?.(nextSearch);
}, [onSearchChange]);
const updateView = useCallback((patch: Partial<IssueViewState>) => { const updateView = useCallback((patch: Partial<IssueViewState>) => {
setViewState((prev) => { setViewState((prev) => {
const next = { ...prev, ...patch }; const next = { ...prev, ...patch };
@ -250,6 +283,7 @@ export function IssuesList({
], ],
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
placeholderData: (previousData) => previousData,
}); });
const agentName = useCallback((id: string | null) => { const agentName = useCallback((id: string | null) => {
@ -333,19 +367,10 @@ 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"> <IssuesSearchInput
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" /> initialValue={initialSearch ?? ""}
<Input onValueCommitted={handleIssueSearchCommit}
value={issueSearch} />
onChange={(e) => {
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">

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useCallback, useRef } from "react"; import { useEffect, useMemo, useCallback } from "react";
import { useLocation, useSearchParams } from "@/lib/router"; import { useLocation, useSearchParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
@ -22,28 +22,20 @@ export function Issues() {
const initialSearch = searchParams.get("q") ?? ""; const initialSearch = searchParams.get("q") ?? "";
const participantAgentId = searchParams.get("participantAgentId") ?? undefined; const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleSearchChange = useCallback((search: string) => { const handleSearchChange = useCallback((search: string) => {
clearTimeout(debounceRef.current); const trimmedSearch = search.trim();
debounceRef.current = setTimeout(() => { const currentSearch = new URLSearchParams(window.location.search).get("q") ?? "";
const trimmedSearch = search.trim(); if (currentSearch === trimmedSearch) return;
const currentSearch = new URLSearchParams(window.location.search).get("q") ?? "";
if (currentSearch === trimmedSearch) return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (trimmedSearch) { if (trimmedSearch) {
url.searchParams.set("q", trimmedSearch); url.searchParams.set("q", trimmedSearch);
} else { } else {
url.searchParams.delete("q"); url.searchParams.delete("q");
} }
const nextUrl = `${url.pathname}${url.search}${url.hash}`; const nextUrl = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(window.history.state, "", nextUrl); window.history.replaceState(window.history.state, "", nextUrl);
}, 300);
}, []);
useEffect(() => {
return () => clearTimeout(debounceRef.current);
}, []); }, []);
const { data: agents } = useQuery({ const { data: agents } = useQuery({