Files
kandev/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts
T
João Salavisa cf76a27488 feat(web): normalize /gitlab page to match /github layout (#1082)
* feat(web): normalize /gitlab page to match /github layout

Adds a presets sidebar (MR/Issue toggle, Inbox/Created groups, Saved
section), a top toolbar with project filter and custom-query input,
paginated MR/Issue lists, and a save-preset dialog backed by a
localStorage store at kandev:gitlab-presets:v1.

Replaces the bespoke tabbed layout and supersedes
hooks/domains/gitlab/use-user-search with the pagination-aware
useGitLabSearch under components/gitlab/my-gitlab/. Project filtering is
applied client-side since GitLab's global MR/issue endpoints have no
path_with_namespace qualifier. No backend changes — the controller
already accepts filter / custom_query / page / per_page.

* fix(web): address /gitlab review feedback

- Merge duplicate cn/formatRelativeTime imports in list-toolbar.
- Drop the generic T from useGitLabSearch — it always resolved to MR | Issue,
  so the as-unknown-as double-cast was just hiding that. Now returns
  Array<MR | Issue> directly and exposes rawTotal alongside total.
- When projectFilter is active, total reflects the client-side filtered
  count instead of the server's unfiltered total so the toolbar badge and
  pagination don't claim "47 results" while only 3 rows render.
- Save flow uses committedQuery, not the draft customQuery, so a preset
  saved without pressing Enter matches what's actually displayed.
- Wrap mobile sidebar handlers in useCallback to keep props stable.
- Drop the unused refresh callback in useGitLabStatusFetch (no caller).
- Add hook-level tests for useSavedPresets save/remove/sync; use
  resetKnownProjectsStore directly in the known-projects test.

* fix(web): address round-2 /gitlab review feedback

- useSavedPresets save/remove now read from localStorage on every call so a
  concurrent write from another tab can't be silently overwritten by a stale
  in-memory snapshot.
- Tighten persisted shape validation: customQuery, projectFilter, and
  createdAt must all be strings (plus 3 regression tests).
- ResultsPagination clamps page to [1, totalPages] before deriving start/end
  and prev/next state, so a stale out-of-range page doesn't render an
  invalid range like "101–30 of 30" after a refresh that shrinks results.
- PresetsSidebar: ignore keydown bubbling from the nested delete button so
  Enter/Space on delete doesn't also trigger row selection, and add an
  aria-label to the icon-only delete control.
- Revert the hook's filtered-total policy: useGitLabSearch always returns
  the raw server total now (so pagination can still navigate to later
  pages). The page client owns the toolbar's displayedCount, computing
  filtered.length only when a project filter is active.

* fix(web): drop dead gitlab preset re-exports, tighten save dialog deps

- Remove unused IconBug / IconPencil imports and the re-export from
  presets.ts — no consumer imports them from this module.
- save-preset-dialog: inline the trimmed check inside handleSubmit so
  canSubmit no longer needs to be in the deps array. ESLint exhaustive-
  deps forced the previous duplication; inlining removes it cleanly.

* fix(web): null-safe response unwrap in useGitLabSearch

If the API returns null, the cast `(response as {mrs: ...}).mrs` would
crash before the `?? []` fallback could run. Optional-chain the cast so a
null payload yields an empty list, matching the existing total_count guard.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-26 16:00:55 +01:00

141 lines
4.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { searchUserIssues, searchUserMRs } from "@/lib/api/domains/gitlab-api";
import type { Issue, MR } from "@/lib/types/gitlab";
import type { PresetOption } from "./presets";
type SearchKind = "mr" | "issue";
type Item = MR | Issue;
export const SEARCH_PAGE_SIZE = 25;
type SearchState = {
items: Item[];
loading: boolean;
error: string | null;
lastFetchedAt: Date | null;
total: number;
};
type FetchArgs = {
filter: string;
customQuery: string;
page: number;
};
export function pickFilter(
presets: PresetOption[],
preset: string,
customQuery: string,
): { filter: string; customQuery: string } {
const trimmed = customQuery.trim();
if (trimmed) {
return { filter: "", customQuery: trimmed };
}
const found = presets.find((p) => p.value === preset);
return { filter: found?.filter ?? "", customQuery: "" };
}
// Apply project filter client-side. GitLab's global /merge_requests and
// /issues endpoints have no path_with_namespace qualifier — filtering server-
// side would require a project_id lookup. The dropdown is populated from the
// page's own results (useKnownProjects), so what's offered is always what's
// already shown — making this a UX narrowing more than a server query.
function filterByProject(items: Item[], project: string): Item[] {
if (!project) return items;
return items.filter((it) => it.project_path === project);
}
export function useGitLabSearch(
kind: SearchKind,
presets: PresetOption[],
preset: string,
customQuery: string,
projectFilter: string = "",
) {
const [state, setState] = useState<SearchState>({
items: [],
loading: false,
error: null,
lastFetchedAt: null,
total: 0,
});
const [page, setPage] = useState(1);
const requestSeq = useRef(0);
useEffect(() => {
setPage(1);
}, [preset, customQuery, kind]);
const fetchData = useCallback(
async ({ filter: ef, customQuery: ec, page: epage }: FetchArgs) => {
const seq = ++requestSeq.current;
setState((s) => ({ ...s, loading: true, error: null }));
try {
const params = { filter: ef, customQuery: ec, page: epage, perPage: SEARCH_PAGE_SIZE };
const response =
kind === "mr" ? await searchUserMRs(params) : await searchUserIssues(params);
if (seq !== requestSeq.current) return;
const items: Item[] =
kind === "mr"
? ((response as { mrs: MR[] | null } | null)?.mrs ?? [])
: ((response as { issues: Issue[] | null } | null)?.issues ?? []);
setState({
items,
loading: false,
error: null,
lastFetchedAt: new Date(),
total: response?.total_count ?? items.length,
});
} catch (err) {
if (seq !== requestSeq.current) return;
setState((s) => ({
items: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to search GitLab",
lastFetchedAt: s.lastFetchedAt,
total: 0,
}));
}
},
[kind],
);
const resolved = useMemo(
() => pickFilter(presets, preset, customQuery),
[presets, preset, customQuery],
);
useEffect(() => {
void fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page });
}, [fetchData, resolved.filter, resolved.customQuery, page]);
const refresh = useCallback(
() => fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page }),
[fetchData, resolved.filter, resolved.customQuery, page],
);
const filtered = useMemo(
() => filterByProject(state.items, projectFilter),
[state.items, projectFilter],
);
// `total` is the server-side count (used by pagination so the user can still
// navigate to later pages that may contain more matches when projectFilter
// is active). The consumer decides what to *display* as a count — see the
// page client, which shows filtered.length next to the title when narrowing.
return {
items: filtered,
rawItems: state.items,
loading: state.loading,
error: state.error,
lastFetchedAt: state.lastFetchedAt,
total: state.total,
page,
setPage,
pageSize: SEARCH_PAGE_SIZE,
refresh,
};
}