Files
kandev/apps/web/components/linear/use-linear-issue-search.ts
T
João Salavisa 79a4534449 fix: gate gitlab/linear fetches on integration being configured (#1789)
* fix: gate gitlab/linear fetches on integration being configured

The GitLab and Linear pages fired merge-request/issue searches on mount
regardless of connection state, producing console errors when the
integration was unconfigured (two 500s on /gitlab, one 503 on /linear)
even though the UI already rendered the "not connected/configured" notice.

Gate the search fetch on the same connected/configured condition that
decides whether the results list renders:
- useGitLabSearch takes an `enabled` flag (converted to an options object
  to stay within the 5-param lint limit, matching useGitHubSearch); the
  page enables it only when status is loaded and GitLab is connected.
- Linear's inline issue-search hook is extracted to
  useLinearIssueSearch with an `enabled` flag, enabled only when the
  workspace is loaded, configured, and available.

Adds gating/wiring tests for both hooks.

* refactor: gate gitlab search at the effect level too

Mirror the Linear hook (which guards both its run callback and its debounce
effect) by adding the enabled guard to useGitLabSearch's fetch effect and
enabled to its dep array, so a disabled integration never schedules a fetch.
The callback keeps its own guard to also cover the exported refresh().

Addresses PR review feedback on the enabled-gate asymmetry.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-19 13:39:58 +01:00

112 lines
3.3 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { searchLinearIssues } from "@/lib/api/domains/linear-api";
import type { LinearIssue } from "@/lib/types/linear";
export const LINEAR_PAGE_SIZE = 25;
export type LinearSearchState = {
items: LinearIssue[];
loading: boolean;
error: string | null;
page: number;
pageSize: number;
isLast: boolean;
goNext: () => void;
goPrev: () => void;
};
// useLinearIssueSearch is page-based: it caches each page's cursor in
// `tokensRef` so the user can step backward without re-querying from the first
// page. Linear returns no total count, so the UI shows a row range plus a
// Page N indicator.
//
// `enabled` gates the network fetch on the integration being configured and
// available. When Linear is not configured the page renders the connect notice
// instead of the list, so firing the search would only produce failing (503)
// requests in the console.
export function useLinearIssueSearch(
workspaceId: string | undefined,
query: string,
teamKey: string,
assigned: string,
enabled: boolean,
): LinearSearchState {
const [items, setItems] = useState<LinearIssue[]>([]);
const [page, setPage] = useState(1);
const [isLast, setIsLast] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// tokens[i] is the page_token for page i+1; tokens[0] is always "".
const tokensRef = useRef<string[]>([""]);
const reqRef = useRef(0);
const fetchPage = useCallback(
(p: number) =>
searchLinearIssues(
{
query: query || undefined,
teamKey: teamKey || undefined,
assigned: assigned || undefined,
pageToken: tokensRef.current[p - 1] || undefined,
maxResults: LINEAR_PAGE_SIZE,
},
{ workspaceId },
),
[workspaceId, query, teamKey, assigned],
);
const run = useCallback(
async (p: number) => {
if (!workspaceId || !enabled) return;
const reqId = ++reqRef.current;
setLoading(true);
setError(null);
try {
const res = await fetchPage(p);
if (reqId !== reqRef.current) return;
setItems(res.issues ?? []);
setIsLast(res.isLast ?? true);
if (!res.isLast && res.nextPageToken) {
tokensRef.current[p] = res.nextPageToken;
}
} catch (err) {
if (reqId !== reqRef.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (reqId === reqRef.current) setLoading(false);
}
},
[workspaceId, enabled, fetchPage],
);
// Reset cursor stack and snap back to page 1 when filters change.
useEffect(() => {
tokensRef.current = [""];
setPage(1);
}, [workspaceId, query, teamKey, assigned]);
// 250ms debounce keeps the keyboard input from firing a request per char.
useEffect(() => {
if (!workspaceId || !enabled) return;
const id = setTimeout(() => void run(page), 250);
return () => clearTimeout(id);
}, [run, page, workspaceId, enabled]);
return {
items,
loading,
error,
page,
pageSize: LINEAR_PAGE_SIZE,
isLast,
goNext: () => {
if (!isLast) setPage((p) => p + 1);
},
goPrev: () => {
setPage((p) => Math.max(1, p - 1));
},
};
}