Speed up issues page search responsiveness
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5ee4cd98e8
commit
dd8c1ca3b2
2 changed files with 60 additions and 43 deletions
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue