Files
kandev/apps/web/components/gitlab/my-gitlab/results-pagination.tsx
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

102 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Fragment } from "react";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@kandev/ui/pagination";
type ResultsPaginationProps = {
page: number;
pageSize: number;
total: number;
onPageChange: (page: number) => void;
};
function pageWindow(page: number, totalPages: number): number[] {
const pages = new Set<number>([1, totalPages, page - 1, page, page + 1]);
return Array.from(pages)
.filter((p) => p >= 1 && p <= totalPages)
.sort((a, b) => a - b);
}
export function ResultsPagination({ page, pageSize, total, onPageChange }: ResultsPaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
// Clamp the incoming page so a stale out-of-range value (e.g. user on page 5,
// refresh returns fewer results → totalPages = 2) doesn't render "101–30 of
// 30" and doesn't break the prev/next button enabled state.
const safePage = Math.min(Math.max(1, page), totalPages);
const windowPages = pageWindow(safePage, totalPages);
const start = (safePage - 1) * pageSize + 1;
const end = Math.min(safePage * pageSize, total);
return (
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
<div className="text-xs text-muted-foreground tabular-nums">
{start}–{end} of {total}
</div>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (safePage > 1) onPageChange(safePage - 1);
}}
aria-disabled={safePage <= 1}
className={safePage <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{windowPages.map((p, i) => {
const prev = windowPages[i - 1];
const needsGap = prev !== undefined && p - prev > 1;
return (
<Fragment key={p}>
{needsGap && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
href="#"
isActive={p === safePage}
onClick={(e) => {
e.preventDefault();
onPageChange(p);
}}
className="cursor-pointer"
>
{p}
</PaginationLink>
</PaginationItem>
</Fragment>
);
})}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (safePage < totalPages) onPageChange(safePage + 1);
}}
aria-disabled={safePage >= totalPages}
className={
safePage >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}