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>
This commit is contained in:
João Salavisa
2026-05-26 16:00:55 +01:00
committed by GitHub
parent 2cb3f4a384
commit cf76a27488
18 changed files with 2048 additions and 558 deletions
+320 -311
View File
@@ -1,215 +1,286 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import {
IconBrandGitlab,
IconBug,
IconCheck,
IconClock,
IconExternalLink,
IconGitMerge,
IconRefresh,
IconX,
} from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { useCallback, useEffect, useMemo, useState } from "react";
import { IconBrandGitlab, IconMenu2 } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import { Card, CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Spinner } from "@kandev/ui/spinner";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@kandev/ui/tabs";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { PageTopbar } from "@/components/page-topbar";
import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api";
import { useGitLabUserIssues, useGitLabUserMRs } from "@/hooks/domains/gitlab/use-user-search";
import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab";
import { MRList } from "@/components/gitlab/my-gitlab/mr-list";
import { IssueList } from "@/components/gitlab/my-gitlab/issue-list";
import {
PresetsSidebar,
type SidebarSelection,
} from "@/components/gitlab/my-gitlab/presets-sidebar";
import { MR_PRESETS, ISSUE_PRESETS } from "@/components/gitlab/my-gitlab/presets";
import { useGitLabSearch } from "@/components/gitlab/my-gitlab/use-gitlab-search";
import { useSavedPresets, type SavedPreset } from "@/components/gitlab/my-gitlab/use-saved-presets";
import {
useKnownProjects,
resetKnownProjectsStore,
} from "@/components/gitlab/my-gitlab/use-known-projects";
import { useCommittedQuery } from "@/components/gitlab/my-gitlab/use-committed-query";
import { ListToolbar } from "@/components/gitlab/my-gitlab/list-toolbar";
import { ResultsPagination } from "@/components/gitlab/my-gitlab/results-pagination";
import { SavePresetDialog } from "@/components/gitlab/my-gitlab/save-preset-dialog";
// Filter values map to GitLab's "scope" query param semantics.
const MR_FILTERS = [
{ value: "assigned_to_me", label: "Assigned" },
{ value: "created_by_me", label: "Authored" },
{ value: "review_requested", label: "Review requested" },
] as const;
const ISSUE_FILTERS = [
{ value: "assigned_to_me", label: "Assigned" },
{ value: "created_by_me", label: "Created" },
] as const;
function MRStateBadge({ mr }: { mr: MR }) {
const state = mr.state === "opened" ? "open" : mr.state;
if (state === "merged") {
return (
<Badge variant="secondary" className="gap-1 bg-purple-500/10 text-purple-500">
<IconCheck className="h-3 w-3" /> Merged
</Badge>
);
}
if (state === "closed") {
return (
<Badge variant="outline" className="gap-1">
<IconX className="h-3 w-3" /> Closed
</Badge>
);
}
if (mr.draft) {
return (
<Badge variant="outline" className="gap-1 text-muted-foreground">
<IconClock className="h-3 w-3" /> Draft
</Badge>
);
}
function PageHeader({
host,
onOpenMobileSidebar,
}: {
host: string;
onOpenMobileSidebar?: () => void;
}) {
return (
<Badge variant="outline" className="gap-1 text-emerald-500">
<IconGitMerge className="h-3 w-3" /> Open
</Badge>
);
}
function IssueStateBadge({ issue }: { issue: Issue }) {
if (issue.state === "closed") {
return (
<Badge variant="outline" className="gap-1">
<IconCheck className="h-3 w-3" /> Closed
</Badge>
);
}
return (
<Badge variant="outline" className="gap-1 text-emerald-500">
<IconBug className="h-3 w-3" /> Open
</Badge>
<PageTopbar
title="GitLab"
subtitle={`${host} · merge requests and issues`}
icon={<IconBrandGitlab className="h-4 w-4" />}
actions={
onOpenMobileSidebar && (
<Button
variant="outline"
size="icon-lg"
onClick={onOpenMobileSidebar}
className="md:hidden cursor-pointer"
data-testid="gitlab-mobile-menu-button"
aria-label="Open GitLab filters"
>
<IconMenu2 className="h-4 w-4" />
</Button>
)
}
/>
);
}
function NotConnectedNotice() {
return (
<Card>
<CardContent className="py-10 text-center space-y-2">
<IconBrandGitlab className="h-10 w-10 mx-auto text-muted-foreground" />
<p className="text-sm font-medium">GitLab not connected</p>
<p className="text-xs text-muted-foreground">
Connect your GitLab account to browse merge requests and issues from this page.
</p>
<Button asChild size="sm" variant="outline" className="cursor-pointer mt-2">
<Link href="/settings/integrations/gitlab">Open settings</Link>
</Button>
</CardContent>
</Card>
<Alert>
<AlertDescription>
GitLab is not connected. Configure GitLab authentication in{" "}
<Link href="/settings/integrations/gitlab" className="underline font-medium cursor-pointer">
Settings → GitLab
</Link>{" "}
to see your merge requests and issues.
</AlertDescription>
</Alert>
);
}
function MRList({ filter, query }: { filter: string; query: string }) {
const { items: mrs, loading, error } = useGitLabUserMRs(filter, query);
if (loading) {
return (
<div className="flex justify-center py-6">
<Spinner className="h-4 w-4" />
</div>
);
}
if (error) {
return <p className="text-sm text-destructive py-4">{error}</p>;
}
if (mrs.length === 0) {
return (
<p className="text-sm text-muted-foreground py-6 text-center">No merge requests found.</p>
);
function resolveTitle(selection: SidebarSelection, saved: SavedPreset[]): string {
if (selection.source === "saved") {
return saved.find((p) => p.id === selection.id)?.label ?? "Saved query";
}
const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
return (
<ul className="space-y-1.5">
{mrs.map((mr) => (
<li key={`${mr.project_path}-${mr.iid}`}>
<Link
href={mr.web_url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-md border hover:border-primary/40 transition-colors cursor-pointer"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium truncate">
<span className="truncate">{mr.title}</span>
<IconExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-1 truncate">
{mr.project_path} !{mr.iid} · {mr.head_branch} → {mr.base_branch}
</p>
</div>
<MRStateBadge mr={mr} />
</div>
</Link>
</li>
))}
</ul>
presets.find((p) => p.value === selection.id)?.label ??
(selection.kind === "mr" ? "Merge requests" : "Issues")
);
}
function IssueList({ filter, query }: { filter: string; query: string }) {
const { items: issues, loading, error } = useGitLabUserIssues(filter, query);
function ResultsList({
selection,
items,
loading,
error,
}: {
selection: SidebarSelection;
items: Array<MR | Issue>;
loading: boolean;
error: string | null;
}) {
if (selection.kind === "mr") {
return <MRList items={items as MR[]} loading={loading} error={error} />;
}
return <IssueList items={items as Issue[]} loading={loading} error={error} />;
}
if (loading) {
return (
<div className="flex justify-center py-6">
<Spinner className="h-4 w-4" />
</div>
);
}
if (error) {
return <p className="text-sm text-destructive py-4">{error}</p>;
}
if (issues.length === 0) {
return <p className="text-sm text-muted-foreground py-6 text-center">No issues found.</p>;
}
type GitLabPageState = ReturnType<typeof useGitLabPageState>;
function useProjectOptions(
selection: SidebarSelection,
committedQuery: string,
items: Array<MR | Issue>,
projectFilter: string,
): string[] {
const pageProjects = useMemo(
() => items.filter((it) => !!it.project_path).map((it) => it.project_path),
[items],
);
const resetKey = `${selection.kind}:${selection.source}:${selection.id}:${committedQuery.trim()}`;
const knownProjects = useKnownProjects(resetKey, pageProjects);
return useMemo(() => {
const set = new Set(knownProjects);
if (projectFilter) set.add(projectFilter);
return Array.from(set).sort();
}, [knownProjects, projectFilter]);
}
function useGitLabPageState() {
const [selection, setSelection] = useState<SidebarSelection>(() => ({
kind: "mr",
source: "preset",
id: MR_PRESETS[0]?.value ?? "",
}));
const {
draft: customQuery,
committed: committedQuery,
setDraft: setCustomQuery,
setImmediate: setQueryImmediate,
commit: commitCustomQuery,
} = useCommittedQuery("");
const [projectFilter, setProjectFilter] = useState("");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const {
presets: savedPresets,
save: saveSavedPreset,
remove: removeSavedPreset,
} = useSavedPresets();
const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
const search = useGitLabSearch(
selection.kind,
presets,
selection.source === "preset" ? selection.id : "",
committedQuery,
projectFilter,
);
const projectOptions = useProjectOptions(
selection,
committedQuery,
search.rawItems,
projectFilter,
);
const title = useMemo(() => resolveTitle(selection, savedPresets), [selection, savedPresets]);
const onSelect = useCallback(
(s: SidebarSelection) => {
setSelection(s);
if (s.source === "saved") {
const found = savedPresets.find((p) => p.id === s.id);
setQueryImmediate(found?.customQuery ?? "");
setProjectFilter(found?.projectFilter ?? "");
return;
}
setQueryImmediate("");
setProjectFilter("");
},
[savedPresets, setQueryImmediate],
);
// Use committedQuery (not the unflushed draft) so the saved preset always
// matches what is currently displayed in the list.
const canSaveCurrent = committedQuery.trim().length > 0 || projectFilter.length > 0;
const suggestedLabel =
committedQuery.trim() || (projectFilter ? `In ${projectFilter}` : "Saved query");
const onOpenSaveDialog = () => {
if (canSaveCurrent) setSaveDialogOpen(true);
};
const onConfirmSave = (label: string) => {
const created = saveSavedPreset({
kind: selection.kind,
label,
customQuery: committedQuery,
projectFilter,
});
setSelection({ kind: selection.kind, source: "saved", id: created.id });
};
const onDeleteSaved = (id: string) => {
removeSavedPreset(id);
if (selection.source === "saved" && selection.id === id) {
const fallbackPresets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
setSelection({ kind: selection.kind, source: "preset", id: fallbackPresets[0]?.value ?? "" });
setQueryImmediate("");
setProjectFilter("");
}
};
return {
selection,
customQuery,
committedQuery,
setCustomQuery,
commitCustomQuery,
projectFilter,
setProjectFilter,
savedPresets,
search,
projectOptions,
title,
onSelect,
canSaveCurrent,
suggestedLabel,
saveDialogOpen,
setSaveDialogOpen,
onOpenSaveDialog,
onConfirmSave,
onDeleteSaved,
};
}
function AuthenticatedLayout({ state }: { state: GitLabPageState }) {
const { selection, search, projectOptions, title } = state;
// When the user narrows by project, the toolbar badge shows how many of the
// current page actually match (so it never reads "47" while three rows
// render). Pagination still uses search.total so the user can navigate to
// later pages that may contain more matches.
const displayedCount = state.projectFilter ? search.items.length : search.total;
return (
<ul className="space-y-1.5">
{issues.map((issue) => (
<li key={`${issue.project_path}-${issue.iid}`}>
<Link
href={issue.web_url}
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-md border hover:border-primary/40 transition-colors cursor-pointer"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium truncate">
<span className="truncate">{issue.title}</span>
<IconExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-1 truncate">
{issue.project_path} #{issue.iid}
{issue.labels.length > 0 && ` · ${issue.labels.slice(0, 3).join(", ")}`}
</p>
</div>
<IssueStateBadge issue={issue} />
</div>
</Link>
</li>
))}
</ul>
<div className="flex-1 flex min-h-0">
<aside
className="hidden md:flex md:flex-col w-60 border-r overflow-y-auto shrink-0"
data-testid="gitlab-presets-sidebar-inline"
>
<PresetsSidebar
selected={selection}
onSelect={state.onSelect}
savedPresets={state.savedPresets}
onDeleteSaved={state.onDeleteSaved}
canSaveCurrent={state.canSaveCurrent}
onSaveCurrent={state.onOpenSaveDialog}
/>
</aside>
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
<ListToolbar
title={title}
count={displayedCount}
loading={search.loading}
lastFetchedAt={search.lastFetchedAt}
customQuery={state.customQuery}
committedQuery={state.committedQuery}
onCustomQueryChange={state.setCustomQuery}
onCommitCustomQuery={state.commitCustomQuery}
projectFilter={state.projectFilter}
onProjectFilterChange={state.setProjectFilter}
projectOptions={projectOptions}
onRefresh={search.refresh}
/>
<div className="flex-1 overflow-auto px-6 py-4">
<ResultsList
selection={selection}
items={search.items}
loading={search.loading}
error={search.error}
/>
</div>
<ResultsPagination
page={search.page}
pageSize={search.pageSize}
total={search.total}
onPageChange={search.setPage}
/>
</main>
</div>
);
}
export function GitLabPageClient({ workspaceId: _workspaceId }: { workspaceId?: string }) {
function useGitLabStatusFetch() {
const [status, setStatus] = useState<GitLabStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [mrFilter, setMrFilter] = useState<string>(MR_FILTERS[0].value);
const [issueFilter, setIssueFilter] = useState<string>(ISSUE_FILTERS[0].value);
const [search, setSearch] = useState("");
const [debounced, setDebounced] = useState("");
useEffect(() => {
const t = setTimeout(() => setDebounced(search), 200);
return () => clearTimeout(t);
}, [search]);
const reloadStatus = useCallback(() => {
setStatusLoading(true);
fetchGitLabStatus({ cache: "no-store" })
.then(setStatus)
.catch(() => setStatus(null))
.finally(() => setStatusLoading(false));
}, []);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
@@ -221,145 +292,83 @@ export function GitLabPageClient({ workspaceId: _workspaceId }: { workspaceId?:
if (!cancelled) setStatus(null);
})
.finally(() => {
if (!cancelled) setStatusLoading(false);
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
// Pass debounced search through as the custom_query. GitLab understands
// free-text search via the `search` URL param; mapping the raw text to it
// keeps the surface simple — power users can still type the param-style
// string like "labels=bug&author_username=me" and the backend forwards
// it as customQuery.
const customQuery = useMemo(() => {
const q = debounced.trim();
if (!q) return "";
if (q.includes("=")) return q; // assume raw key=value
return `search=${encodeURIComponent(q)}`;
}, [debounced]);
return { status, loading };
}
const connected = status?.authenticated || status?.token_configured;
export function GitLabPageClient(_props: { workspaceId?: string } = {}) {
const { status, loading: statusLoading } = useGitLabStatusFetch();
const state = useGitLabPageState();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
useEffect(() => resetKnownProjectsStore, []);
const connected = !!(status?.authenticated || status?.token_configured);
const host = status?.host ?? "https://gitlab.com";
const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []);
const { onSelect, onOpenSaveDialog } = state;
const onMobileSidebarSelect = useCallback(
(s: Parameters<typeof onSelect>[0]) => {
onSelect(s);
setMobileSidebarOpen(false);
},
[onSelect],
);
const onMobileSaveCurrent = useCallback(() => {
setMobileSidebarOpen(false);
onOpenSaveDialog();
}, [onOpenSaveDialog]);
return (
<div className="flex flex-col h-full">
<PageTopbar
title="GitLab"
subtitle={`${status?.host ?? "https://gitlab.com"} · merge requests and issues`}
icon={<IconBrandGitlab className="h-4 w-4" />}
actions={
<Button
variant="outline"
size="sm"
onClick={reloadStatus}
disabled={statusLoading}
className="gap-1 cursor-pointer"
>
<IconRefresh className="h-3 w-3" />
Refresh
</Button>
}
<div className="h-screen w-full flex flex-col bg-background">
<PageHeader
host={host}
onOpenMobileSidebar={!statusLoading && connected ? onOpenMobileSidebar : undefined}
/>
<div className="space-y-4 p-4 md:p-6">
{!statusLoading && !connected ? (
{statusLoading && (
<div className="p-6 text-sm text-muted-foreground">Checking GitLab status…</div>
)}
{!statusLoading && !connected && (
<div className="p-6 max-w-2xl">
<NotConnectedNotice />
) : (
<BrowseTabs
search={search}
setSearch={setSearch}
mrFilter={mrFilter}
setMrFilter={setMrFilter}
issueFilter={issueFilter}
setIssueFilter={setIssueFilter}
customQuery={customQuery}
/>
)}
</div>
</div>
);
}
function BrowseTabs({
search,
setSearch,
mrFilter,
setMrFilter,
issueFilter,
setIssueFilter,
customQuery,
}: {
search: string;
setSearch: (v: string) => void;
mrFilter: string;
setMrFilter: (v: string) => void;
issueFilter: string;
setIssueFilter: (v: string) => void;
customQuery: string;
}) {
return (
<>
<Input
placeholder="Search (free text, or paste GitLab query like labels=bug&state=opened)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="font-mono text-sm"
/>
<Tabs defaultValue="mrs">
<TabsList>
<TabsTrigger value="mrs" className="gap-1.5">
<IconGitMerge className="h-3.5 w-3.5" />
Merge requests
</TabsTrigger>
<TabsTrigger value="issues" className="gap-1.5">
<IconBug className="h-3.5 w-3.5" />
Issues
</TabsTrigger>
</TabsList>
<TabsContent value="mrs" className="space-y-3">
<FilterButtons
value={mrFilter}
onChange={setMrFilter}
options={MR_FILTERS as readonly { value: string; label: string }[]}
/>
<MRList filter={mrFilter} query={customQuery} />
</TabsContent>
<TabsContent value="issues" className="space-y-3">
<FilterButtons
value={issueFilter}
onChange={setIssueFilter}
options={ISSUE_FILTERS as readonly { value: string; label: string }[]}
/>
<IssueList filter={issueFilter} query={customQuery} />
</TabsContent>
</Tabs>
</>
);
}
function FilterButtons({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: readonly { value: string; label: string }[];
}) {
return (
<div className="flex gap-1.5 flex-wrap">
{options.map((f) => (
<Button
key={f.value}
size="sm"
variant={value === f.value ? "default" : "outline"}
onClick={() => onChange(f.value)}
className="cursor-pointer"
</div>
)}
{!statusLoading && connected && <AuthenticatedLayout state={state} />}
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetContent
side="right"
className="w-full sm:max-w-sm overflow-y-auto p-0"
data-testid="gitlab-mobile-sidebar"
>
{f.label}
</Button>
))}
<SheetHeader className="px-4 pt-4 pb-2">
<SheetTitle>Filters</SheetTitle>
</SheetHeader>
<PresetsSidebar
selected={state.selection}
onSelect={onMobileSidebarSelect}
savedPresets={state.savedPresets}
onDeleteSaved={state.onDeleteSaved}
canSaveCurrent={state.canSaveCurrent}
onSaveCurrent={onMobileSaveCurrent}
/>
</SheetContent>
</Sheet>
<SavePresetDialog
open={state.saveDialogOpen}
onOpenChange={state.setSaveDialogOpen}
kind={state.selection.kind}
customQuery={state.committedQuery}
projectFilter={state.projectFilter}
suggestedLabel={state.suggestedLabel}
onSave={state.onConfirmSave}
/>
</div>
);
}
@@ -0,0 +1,103 @@
"use client";
import Link from "next/link";
import { IconCircle, IconCircleCheck, IconExternalLink } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Spinner } from "@kandev/ui/spinner";
import { cn, formatRelativeTime } from "@/lib/utils";
import type { Issue } from "@/lib/types/gitlab";
type IssueListProps = {
items: Issue[];
loading: boolean;
error: string | null;
};
function IssueLabels({ labels }: { labels: string[] }) {
if (!labels?.length) return null;
return (
<>
{labels.slice(0, 4).map((l) => (
<Badge key={l} variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
{l}
</Badge>
))}
{labels.length > 4 && (
<span className="text-[10px] text-muted-foreground">+{labels.length - 4}</span>
)}
</>
);
}
function IssueRow({ issue }: { issue: Issue }) {
const isOpen = issue.state !== "closed";
const StateIcon = isOpen ? IconCircle : IconCircleCheck;
const stateClass = isOpen
? "text-emerald-600 dark:text-emerald-400"
: "text-purple-600 dark:text-purple-400";
return (
<div
className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors"
data-testid="issue-row"
data-issue-iid={issue.iid}
>
<StateIcon className={cn("h-4 w-4 mt-1 shrink-0", stateClass)} />
<div className="min-w-0 flex-1">
<Link
href={issue.web_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold hover:underline inline-flex items-center gap-1.5 truncate cursor-pointer"
>
<span className="truncate">{issue.title}</span>
<IconExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
</Link>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-0.5 text-xs text-muted-foreground">
<span className="whitespace-nowrap">
{issue.project_path}#{issue.iid}
</span>
<span>·</span>
<span className="whitespace-nowrap">
by {issue.author_username} · opened {formatRelativeTime(issue.created_at)}
</span>
<IssueLabels labels={issue.labels} />
</div>
</div>
</div>
);
}
function IssueListBody({ loading, error, items }: IssueListProps) {
if (loading) {
return (
<div className="flex justify-center py-10">
<Spinner />
</div>
);
}
if (error) {
return <div className="text-center py-10 text-destructive text-sm">{error}</div>;
}
if (items.length === 0) {
return (
<div className="text-center py-10 text-muted-foreground text-sm">
No issues match this filter.
</div>
);
}
return (
<div className="divide-y">
{items.map((issue) => (
<IssueRow key={`${issue.project_path}-${issue.iid}`} issue={issue} />
))}
</div>
);
}
export function IssueList(props: IssueListProps) {
return (
<div className="rounded-md border overflow-hidden">
<IssueListBody {...props} />
</div>
);
}
@@ -0,0 +1,150 @@
"use client";
import { IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { cn, formatRelativeTime } from "@/lib/utils";
const ALL_PROJECTS = "__all__";
type ListToolbarProps = {
title: string;
count: number;
loading: boolean;
lastFetchedAt: Date | null;
customQuery: string;
committedQuery: string;
onCustomQueryChange: (value: string) => void;
onCommitCustomQuery: () => void;
projectFilter: string;
onProjectFilterChange: (value: string) => void;
projectOptions: string[];
onRefresh: () => void;
};
function RefreshControls({
loading,
lastFetchedAt,
onRefresh,
showUpdatedPrefix,
}: {
loading: boolean;
lastFetchedAt: Date | null;
onRefresh: () => void;
showUpdatedPrefix: boolean;
}) {
return (
<>
{lastFetchedAt && !loading && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{showUpdatedPrefix ? "Updated " : ""}
{formatRelativeTime(lastFetchedAt.toISOString())}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={onRefresh}
disabled={loading}
title="Refresh"
data-testid="gitlab-list-toolbar-refresh"
>
<IconRefresh className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</>
);
}
export function ListToolbar({
title,
count,
loading,
lastFetchedAt,
customQuery,
committedQuery,
onCustomQueryChange,
onCommitCustomQuery,
projectFilter,
onProjectFilterChange,
projectOptions,
onRefresh,
}: ListToolbarProps) {
const selectValue = projectFilter || ALL_PROJECTS;
const dirty = customQuery !== committedQuery;
return (
<div className="px-4 sm:px-6 py-2.5 border-b shrink-0 flex flex-col md:flex-row md:items-center md:flex-wrap gap-2 md:gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="flex items-baseline gap-2 min-w-0 flex-1 md:flex-initial">
<h2 className="text-sm font-semibold truncate" data-testid="gitlab-list-toolbar-title">
{title}
</h2>
<span className="text-xs text-muted-foreground tabular-nums">
{loading ? "…" : count}
</span>
</div>
<div className="flex items-center gap-2 md:hidden">
<RefreshControls
loading={loading}
lastFetchedAt={lastFetchedAt}
onRefresh={onRefresh}
showUpdatedPrefix={false}
/>
</div>
</div>
<Select
value={selectValue}
onValueChange={(v) => onProjectFilterChange(v === ALL_PROJECTS ? "" : v)}
>
<SelectTrigger
className="w-full md:w-[220px] h-8 cursor-pointer"
data-testid="gitlab-project-filter-trigger"
>
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_PROJECTS} className="cursor-pointer">
All projects
</SelectItem>
{projectOptions.map((key) => (
<SelectItem key={key} value={key} className="cursor-pointer">
{key}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-full md:flex-1 md:min-w-[240px] relative">
<Input
value={customQuery}
onChange={(e) => onCustomQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onCommitCustomQuery();
}
}}
onBlur={() => {
if (dirty) onCommitCustomQuery();
}}
placeholder='Custom query — press Enter. e.g. "labels=bug&state=opened"'
className="h-8 pr-20"
data-testid="gitlab-list-toolbar-custom-query"
/>
{dirty && (
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[10px] uppercase tracking-wider text-muted-foreground hidden sm:inline">
Press Enter
</span>
)}
</div>
<div className="hidden md:flex items-center gap-2 md:ml-auto">
<RefreshControls
loading={loading}
lastFetchedAt={lastFetchedAt}
onRefresh={onRefresh}
showUpdatedPrefix
/>
</div>
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
import Link from "next/link";
import {
IconGitPullRequest,
IconGitPullRequestClosed,
IconGitMerge,
IconExternalLink,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { Spinner } from "@kandev/ui/spinner";
import { cn, formatRelativeTime } from "@/lib/utils";
import type { MR } from "@/lib/types/gitlab";
type MRListProps = {
items: MR[];
loading: boolean;
error: string | null;
};
function mrStateIcon(mr: MR): { Icon: Icon; className: string } {
const state = mr.state === "opened" ? "open" : mr.state;
if (state === "merged")
return { Icon: IconGitMerge, className: "text-purple-600 dark:text-purple-400" };
if (state === "closed")
return { Icon: IconGitPullRequestClosed, className: "text-red-600 dark:text-red-400" };
if (mr.draft) return { Icon: IconGitPullRequest, className: "text-muted-foreground" };
return { Icon: IconGitPullRequest, className: "text-emerald-600 dark:text-emerald-400" };
}
function MRRow({ mr }: { mr: MR }) {
const { Icon: StateIcon, className: stateIconClass } = mrStateIcon(mr);
return (
<div
className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors"
data-testid="mr-row"
data-mr-iid={mr.iid}
>
<StateIcon className={cn("h-4 w-4 mt-1 shrink-0", stateIconClass)} />
<div className="min-w-0 flex-1">
<Link
href={mr.web_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold hover:underline inline-flex items-center gap-1.5 truncate cursor-pointer"
>
<span className="truncate">{mr.title}</span>
<IconExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
</Link>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-0.5 text-xs text-muted-foreground">
<span className="whitespace-nowrap">
{mr.project_path}!{mr.iid}
</span>
<span>·</span>
<span className="whitespace-nowrap">
{mr.head_branch} → {mr.base_branch}
</span>
<span>·</span>
<span className="whitespace-nowrap">
by {mr.author_username} · opened {formatRelativeTime(mr.created_at)}
</span>
</div>
</div>
</div>
);
}
function MRListBody({ loading, error, items }: MRListProps) {
if (loading) {
return (
<div className="flex justify-center py-10">
<Spinner />
</div>
);
}
if (error) {
return <div className="text-center py-10 text-destructive text-sm">{error}</div>;
}
if (items.length === 0) {
return (
<div className="text-center py-10 text-muted-foreground text-sm">
No merge requests match this filter.
</div>
);
}
return (
<div className="divide-y">
{items.map((mr) => (
<MRRow key={`${mr.project_path}-${mr.iid}`} mr={mr} />
))}
</div>
);
}
export function MRList(props: MRListProps) {
return (
<div className="rounded-md border overflow-hidden">
<MRListBody {...props} />
</div>
);
}
@@ -0,0 +1,255 @@
"use client";
import { IconX, IconDeviceFloppy, IconBookmark } from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { MR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./presets";
import type { SavedPreset } from "./use-saved-presets";
export type SidebarSelection = {
kind: "mr" | "issue";
source: "preset" | "saved";
id: string;
};
type PresetsSidebarProps = {
selected: SidebarSelection;
onSelect: (s: SidebarSelection) => void;
savedPresets: SavedPreset[];
onDeleteSaved: (id: string) => void;
canSaveCurrent: boolean;
onSaveCurrent: () => void;
mrPresets?: PresetOption[];
issuePresets?: PresetOption[];
};
function KindToggle({
kind,
onChange,
}: {
kind: "mr" | "issue";
onChange: (k: "mr" | "issue") => void;
}) {
return (
<div
className="mx-2 mb-3 grid grid-cols-2 rounded-md border p-0.5 text-xs"
data-testid="gitlab-kind-toggle"
>
{(["mr", "issue"] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => onChange(value)}
className={cn(
"px-2 py-1 rounded cursor-pointer transition-colors",
kind === value
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
data-testid={`gitlab-kind-${value}`}
>
{value === "mr" ? "Merge requests" : "Issues"}
</button>
))}
</div>
);
}
function SectionHeader({ title }: { title: string }) {
return (
<div
className="px-2 mt-3 mb-1 text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
data-testid={`gitlab-section-${title.toLowerCase()}`}
>
{title}
</div>
);
}
function PresetItem({
label,
Icon: ItemIcon,
active,
onClick,
trailing,
}: {
label: string;
Icon: Icon;
active: boolean;
onClick: () => void;
trailing?: React.ReactNode;
}) {
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// Ignore keys originating from descendants (e.g. the nested delete button),
// otherwise Enter/Space on delete would also fire row selection.
if (e.currentTarget !== e.target) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
};
return (
<div
role="button"
tabIndex={0}
aria-pressed={active}
className={cn(
"group/item mx-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:bg-muted/50",
)}
onClick={onClick}
onKeyDown={onKeyDown}
>
<ItemIcon className="h-4 w-4 shrink-0" />
<span className="truncate flex-1">{label}</span>
{trailing}
</div>
);
}
function PresetGroupList({
presets,
group,
selected,
onSelect,
kind,
}: {
presets: PresetOption[];
group: PresetGroup;
selected: SidebarSelection;
onSelect: (s: SidebarSelection) => void;
kind: "mr" | "issue";
}) {
const items = presets.filter((p) => p.group === group);
if (items.length === 0) return null;
return (
<>
<SectionHeader title={group === "inbox" ? "Inbox" : "Created"} />
{items.map((p) => (
<PresetItem
key={`${kind}-${p.value}`}
label={p.label}
Icon={p.icon}
active={selected.source === "preset" && selected.id === p.value}
onClick={() => onSelect({ kind, source: "preset", id: p.value })}
/>
))}
</>
);
}
function SavedSection({
saved,
selected,
onSelect,
onDelete,
kind,
canSaveCurrent,
onSaveCurrent,
}: {
saved: SavedPreset[];
selected: SidebarSelection;
onSelect: (s: SidebarSelection) => void;
onDelete: (id: string) => void;
kind: "mr" | "issue";
canSaveCurrent: boolean;
onSaveCurrent: () => void;
}) {
return (
<>
<SectionHeader title="Saved" />
{saved.length === 0 && (
<div className="mx-2 px-2 py-1 text-xs text-muted-foreground/80 italic">
No saved queries yet.
</div>
)}
{saved.map((s) => (
<PresetItem
key={s.id}
label={s.label}
Icon={IconBookmark}
active={selected.source === "saved" && selected.id === s.id}
onClick={() => onSelect({ kind, source: "saved", id: s.id })}
trailing={
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(s.id);
}}
className="opacity-0 group-hover/item:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
title="Delete saved query"
aria-label={`Delete saved query ${s.label}`}
data-testid={`gitlab-saved-delete-${s.id}`}
>
<IconX className="h-3.5 w-3.5" />
</button>
}
/>
))}
<button
type="button"
onClick={onSaveCurrent}
disabled={!canSaveCurrent}
className={cn(
"mx-1 mt-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors",
canSaveCurrent
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
: "text-muted-foreground/50 cursor-not-allowed",
)}
title={canSaveCurrent ? "Save current query" : "Type a custom query first"}
data-testid="gitlab-save-current-query"
>
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
<span>Save current query</span>
</button>
</>
);
}
export function PresetsSidebar({
selected,
onSelect,
savedPresets,
onDeleteSaved,
canSaveCurrent,
onSaveCurrent,
mrPresets = MR_PRESETS,
issuePresets = ISSUE_PRESETS,
}: PresetsSidebarProps) {
const presets = selected.kind === "mr" ? mrPresets : issuePresets;
const saved = savedPresets.filter((p) => p.kind === selected.kind);
const onKindChange = (kind: "mr" | "issue") => {
const fallback = (kind === "mr" ? mrPresets : issuePresets)[0]?.value ?? "";
onSelect({ kind, source: "preset", id: fallback });
};
return (
<nav className="flex flex-col py-3">
<KindToggle kind={selected.kind} onChange={onKindChange} />
<PresetGroupList
presets={presets}
group="inbox"
selected={selected}
onSelect={onSelect}
kind={selected.kind}
/>
<PresetGroupList
presets={presets}
group="created"
selected={selected}
onSelect={onSelect}
kind={selected.kind}
/>
<SavedSection
saved={saved}
selected={selected}
onSelect={onSelect}
onDelete={onDeleteSaved}
kind={selected.kind}
canSaveCurrent={canSaveCurrent}
onSaveCurrent={onSaveCurrent}
/>
</nav>
);
}
@@ -0,0 +1,58 @@
import { IconInbox, IconUserPlus, IconGitMerge, IconPlus } from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
export type PresetGroup = "inbox" | "created";
export type PresetOption = {
value: string;
label: string;
// Backend filter token consumed by gitlab.translateUserSearchFilter.
// Accepted: "assigned_to_me", "created_by_me", "review_requested" (MRs only).
// Everything else is treated as a raw `key=value&...` filter and is parsed
// by appendFilter — keep this to the curated tokens; the custom-query input
// is the escape hatch for everything else.
filter: string;
group: PresetGroup;
icon: Icon;
};
export const MR_PRESETS: PresetOption[] = [
{
value: "review_requested",
label: "Review requested",
filter: "review_requested",
group: "inbox",
icon: IconInbox,
},
{
value: "assigned",
label: "Assigned",
filter: "assigned_to_me",
group: "inbox",
icon: IconUserPlus,
},
{
value: "authored",
label: "Authored",
filter: "created_by_me",
group: "created",
icon: IconGitMerge,
},
];
export const ISSUE_PRESETS: PresetOption[] = [
{
value: "assigned",
label: "Assigned",
filter: "assigned_to_me",
group: "inbox",
icon: IconInbox,
},
{
value: "created",
label: "Created",
filter: "created_by_me",
group: "created",
icon: IconPlus,
},
];
@@ -0,0 +1,101 @@
"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>
);
}
@@ -0,0 +1,134 @@
"use client";
import { useCallback, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Input } from "@kandev/ui/input";
import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
type SavePresetDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
kind: "mr" | "issue";
customQuery: string;
projectFilter: string;
suggestedLabel: string;
onSave: (label: string) => void;
};
function SavePresetForm({
kind,
customQuery,
projectFilter,
suggestedLabel,
onSave,
onClose,
}: {
kind: "mr" | "issue";
customQuery: string;
projectFilter: string;
suggestedLabel: string;
onSave: (label: string) => void;
onClose: () => void;
}) {
const [value, setValue] = useState(suggestedLabel);
const trimmed = value.trim();
const canSubmit = trimmed.length > 0;
const handleSubmit = useCallback(() => {
if (!trimmed) return;
onSave(trimmed);
onClose();
}, [trimmed, onSave, onClose]);
return (
<>
<DialogHeader>
<DialogTitle>Save GitLab query</DialogTitle>
<DialogDescription>
Save this {kind === "mr" ? "merge request" : "issue"} query to the sidebar for quick
access later.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="gitlab-preset-label" className="text-xs">
Name
</Label>
<Input
id="gitlab-preset-label"
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
onFocus={(e) => e.target.select()}
placeholder="e.g. Needs my review"
/>
</div>
<div className="flex flex-col gap-1.5 text-xs">
{customQuery && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0 w-16">Query</span>
<code className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 break-all">
{customQuery}
</code>
</div>
)}
{projectFilter && (
<div className="flex gap-2">
<span className="text-muted-foreground shrink-0 w-16">Project</span>
<code className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 break-all">
{projectFilter}
</code>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" className="cursor-pointer" onClick={onClose}>
Cancel
</Button>
<Button className="cursor-pointer" disabled={!canSubmit} onClick={handleSubmit}>
Save
</Button>
</DialogFooter>
</>
);
}
export function SavePresetDialog({
open,
onOpenChange,
kind,
customQuery,
projectFilter,
suggestedLabel,
onSave,
}: SavePresetDialogProps) {
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
{open && (
<SavePresetForm
kind={kind}
customQuery={customQuery}
projectFilter={projectFilter}
suggestedLabel={suggestedLabel}
onSave={onSave}
onClose={handleClose}
/>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useCommittedQuery } from "./use-committed-query";
const BUG = "labels=bug";
describe("useCommittedQuery (gitlab)", () => {
it("initializes draft and committed to the initial value", () => {
const { result } = renderHook(() => useCommittedQuery(BUG));
expect(result.current.draft).toBe(BUG);
expect(result.current.committed).toBe(BUG);
});
it("setDraft updates draft without touching committed", () => {
const { result } = renderHook(() => useCommittedQuery(""));
act(() => result.current.setDraft(BUG));
expect(result.current.draft).toBe(BUG);
expect(result.current.committed).toBe("");
});
it("commit syncs committed to the current draft", () => {
const { result } = renderHook(() => useCommittedQuery(""));
act(() => result.current.setDraft("scope=all"));
act(() => result.current.commit());
expect(result.current.committed).toBe("scope=all");
});
it("setImmediate updates draft and committed together", () => {
const { result } = renderHook(() => useCommittedQuery(""));
act(() => result.current.setImmediate("now"));
expect(result.current.draft).toBe("now");
expect(result.current.committed).toBe("now");
});
});
@@ -0,0 +1,20 @@
"use client";
import { useCallback, useState } from "react";
// Mirror of github's useCommittedQuery: the draft is what the input renders,
// the committed value is what drives the fetch. Typing only updates the draft;
// Enter / blur commits.
export function useCommittedQuery(initial: string) {
const [draft, setDraft] = useState(initial);
const [committed, setCommitted] = useState(initial);
const setImmediate = useCallback((value: string) => {
setDraft(value);
setCommitted(value);
}, []);
const commit = useCallback(() => setCommitted(draft), [draft]);
return { draft, committed, setDraft, setImmediate, commit };
}
@@ -0,0 +1,245 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, renderHook, waitFor, act } from "@testing-library/react";
import { IconInbox } from "@tabler/icons-react";
import type { Issue, MR, MRSearchPage, IssueSearchPage } from "@/lib/types/gitlab";
import type { PresetOption } from "./presets";
const searchUserMRsMock = vi.fn<[unknown], Promise<MRSearchPage | null>>();
const searchUserIssuesMock = vi.fn<[unknown], Promise<IssueSearchPage | null>>();
vi.mock("@/lib/api/domains/gitlab-api", () => ({
searchUserMRs: (args: unknown) => searchUserMRsMock(args),
searchUserIssues: (args: unknown) => searchUserIssuesMock(args),
}));
import { pickFilter, useGitLabSearch } from "./use-gitlab-search";
afterEach(() => cleanup());
const PRESET_REVIEW = "review_requested";
const PRESET_ASSIGNED = "assigned";
const CUSTOM_QUERY = "labels=bug";
const PRESETS: PresetOption[] = [
{
value: PRESET_REVIEW,
label: "Review requested",
filter: PRESET_REVIEW,
group: "inbox",
icon: IconInbox,
},
{
value: PRESET_ASSIGNED,
label: "Assigned",
filter: "assigned_to_me",
group: "inbox",
icon: IconInbox,
},
];
const EMPTY_PAGE: MRSearchPage = { mrs: [], total_count: 0, page: 1, per_page: 25 };
describe("pickFilter", () => {
it("uses preset filter when custom query is empty", () => {
expect(pickFilter(PRESETS, PRESET_REVIEW, "")).toEqual({
filter: PRESET_REVIEW,
customQuery: "",
});
});
it("routes custom query and drops preset filter", () => {
expect(pickFilter(PRESETS, PRESET_REVIEW, CUSTOM_QUERY)).toEqual({
filter: "",
customQuery: CUSTOM_QUERY,
});
});
it("trims whitespace-only custom query and falls back to preset", () => {
expect(pickFilter(PRESETS, PRESET_ASSIGNED, " ")).toEqual({
filter: "assigned_to_me",
customQuery: "",
});
});
it("falls back to empty filter when preset is unknown", () => {
expect(pickFilter(PRESETS, "missing", "")).toEqual({ filter: "", customQuery: "" });
});
});
function fakeMR(overrides: Partial<MR> = {}): MR {
return {
id: 1,
iid: 1,
project_id: 1,
title: "",
url: "",
web_url: "",
state: "opened",
head_branch: "feat",
head_sha: "",
base_branch: "main",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
body: "",
draft: false,
merge_status: "",
has_conflicts: false,
additions: 0,
deletions: 0,
reviewers: [],
assignees: [],
created_at: "",
updated_at: "",
...overrides,
};
}
function fakeIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: 1,
iid: 1,
project_id: 1,
title: "",
body: "",
url: "",
web_url: "",
state: "opened",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
labels: [],
assignees: [],
created_at: "",
updated_at: "",
...overrides,
};
}
function resetMocks() {
searchUserMRsMock.mockReset();
searchUserIssuesMock.mockReset();
}
describe("useGitLabSearch — fetch wiring", () => {
beforeEach(resetMocks);
it("forwards preset filter to MR API", async () => {
searchUserMRsMock.mockResolvedValue(EMPTY_PAGE);
renderHook(() => useGitLabSearch("mr", PRESETS, PRESET_REVIEW, ""));
await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalled());
const args = searchUserMRsMock.mock.calls[0][0] as Record<string, unknown>;
expect(args.filter).toBe(PRESET_REVIEW);
expect(args.customQuery).toBe("");
expect(args.page).toBe(1);
});
it("forwards custom query and drops preset filter", async () => {
searchUserMRsMock.mockResolvedValue(EMPTY_PAGE);
renderHook(() => useGitLabSearch("mr", PRESETS, PRESET_REVIEW, CUSTOM_QUERY));
await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalled());
const args = searchUserMRsMock.mock.calls[0][0] as Record<string, unknown>;
expect(args.filter).toBe("");
expect(args.customQuery).toBe(CUSTOM_QUERY);
});
it("dispatches to issues endpoint when kind is issue", async () => {
const issue = fakeIssue();
searchUserIssuesMock.mockResolvedValue({
issues: [issue],
total_count: 1,
page: 1,
per_page: 25,
});
const { result } = renderHook(() => useGitLabSearch("issue", PRESETS, PRESET_ASSIGNED, ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([issue]);
expect(searchUserMRsMock).not.toHaveBeenCalled();
});
});
describe("useGitLabSearch — state", () => {
beforeEach(resetMocks);
it("populates items and total on success", async () => {
const mr = fakeMR();
searchUserMRsMock.mockResolvedValue({ mrs: [mr], total_count: 1, page: 1, per_page: 25 });
const { result } = renderHook(() => useGitLabSearch("mr", PRESETS, PRESET_ASSIGNED, ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([mr]);
expect(result.current.total).toBe(1);
expect(result.current.error).toBeNull();
});
it("surfaces error message without items", async () => {
searchUserMRsMock.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useGitLabSearch("mr", PRESETS, PRESET_ASSIGNED, ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("boom");
expect(result.current.items).toEqual([]);
});
it("filters items by project_path client-side, total stays at server total", async () => {
const a = fakeMR({ project_path: "acme/api" });
const b = fakeMR({ project_path: "acme/web" });
searchUserMRsMock.mockResolvedValue({ mrs: [a, b], total_count: 99, page: 1, per_page: 25 });
const { result } = renderHook(() =>
useGitLabSearch("mr", PRESETS, PRESET_ASSIGNED, "", "acme/web"),
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items.map((m) => m.project_path)).toEqual(["acme/web"]);
expect(result.current.rawItems.length).toBe(2);
// The hook returns the raw server total so pagination can still navigate to
// later pages that may contain more matches. Consumers compute a filtered
// display count from items.length when they want one.
expect(result.current.total).toBe(99);
});
});
describe("useGitLabSearch — pagination & sequencing", () => {
beforeEach(resetMocks);
it("resets page to 1 when preset changes", async () => {
searchUserMRsMock.mockResolvedValue(EMPTY_PAGE);
const { result, rerender } = renderHook(
({ p }: { p: string }) => useGitLabSearch("mr", PRESETS, p, ""),
{ initialProps: { p: PRESET_ASSIGNED } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.setPage(3));
await waitFor(() => expect(result.current.page).toBe(3));
rerender({ p: PRESET_REVIEW });
await waitFor(() => expect(result.current.page).toBe(1));
});
it("drops stale responses when filter changes mid-flight", async () => {
let resolveFirst: (v: MRSearchPage) => void = () => {};
searchUserMRsMock.mockReturnValueOnce(
new Promise<MRSearchPage>((res) => {
resolveFirst = res;
}),
);
const second = fakeMR({ title: "second" });
searchUserMRsMock.mockResolvedValueOnce({
mrs: [second],
total_count: 1,
page: 1,
per_page: 25,
});
const { result, rerender } = renderHook(
({ p }: { p: string }) => useGitLabSearch("mr", PRESETS, p, ""),
{ initialProps: { p: PRESET_ASSIGNED } },
);
rerender({ p: PRESET_REVIEW });
await waitFor(() => expect(result.current.items).toEqual([second]));
resolveFirst({
mrs: [fakeMR({ title: "first" }), fakeMR()],
total_count: 2,
page: 1,
per_page: 25,
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current.items).toEqual([second]);
});
});
@@ -0,0 +1,140 @@
"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,
};
}
@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach } from "vitest";
import { __getSnapshotForTests, recordForKey, resetKnownProjectsStore } from "./use-known-projects";
describe("recordForKey (gitlab)", () => {
beforeEach(() => {
resetKnownProjectsStore();
});
it("accumulates unique projects under the same key", () => {
recordForKey("k1", ["acme/api", "acme/web"]);
recordForKey("k1", ["acme/infra"]);
expect(__getSnapshotForTests()).toEqual(["acme/api", "acme/infra", "acme/web"]);
});
it("returns sorted output", () => {
recordForKey("k2", ["z/z", "a/a", "m/m"]);
expect(__getSnapshotForTests()).toEqual(["a/a", "m/m", "z/z"]);
});
it("deduplicates across successive calls", () => {
recordForKey("k3", ["acme/api"]);
recordForKey("k3", ["acme/api", "acme/web"]);
expect(__getSnapshotForTests()).toEqual(["acme/api", "acme/web"]);
});
it("clears accumulator when key changes", () => {
recordForKey("k4a", ["acme/api", "acme/web"]);
recordForKey("k4b", ["other/repo"]);
expect(__getSnapshotForTests()).toEqual(["other/repo"]);
});
it("ignores empty strings", () => {
recordForKey("k5", ["", "acme/api", ""]);
expect(__getSnapshotForTests()).toEqual(["acme/api"]);
});
it("resetKnownProjectsStore wipes the snapshot", () => {
recordForKey("k6", ["acme/api"]);
expect(__getSnapshotForTests()).toEqual(["acme/api"]);
resetKnownProjectsStore();
expect(__getSnapshotForTests()).toEqual([]);
});
});
@@ -0,0 +1,68 @@
"use client";
import { useEffect, useSyncExternalStore } from "react";
// Module-level accumulator: project_paths seen in the current query context.
// Scoped by a reset key (kind + selection + custom query). Within a context
// the set only grows so narrowing the project filter doesn't hide the rest of
// the options the user already saw.
let currentKey: string | null = null;
const seen = new Set<string>();
let snapshot: string[] = [];
const emptySnapshot: string[] = [];
const listeners = new Set<() => void>();
function subscribe(cb: () => void) {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
function getSnapshot(): string[] {
return snapshot;
}
function getServerSnapshot(): string[] {
return emptySnapshot;
}
export function recordForKey(key: string, projects: readonly string[]) {
let changed = false;
if (currentKey !== key) {
currentKey = key;
if (seen.size > 0) {
seen.clear();
snapshot = [];
changed = true;
}
}
for (const p of projects) {
if (p && !seen.has(p)) {
seen.add(p);
changed = true;
}
}
if (!changed) return;
snapshot = Array.from(seen).sort();
for (const l of listeners) l();
}
export function resetKnownProjectsStore() {
currentKey = null;
if (seen.size === 0 && snapshot.length === 0) return;
seen.clear();
snapshot = [];
for (const l of listeners) l();
}
export function useKnownProjects(resetKey: string, fromItems: readonly string[]): string[] {
useEffect(() => {
recordForKey(resetKey, fromItems);
}, [resetKey, fromItems]);
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function __getSnapshotForTests(): string[] {
return snapshot;
}
@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import {
__resetSnapshotForTests,
readStorage,
useSavedPresets,
type SavedPreset,
} from "./use-saved-presets";
const STORAGE_KEY = "kandev:gitlab-presets:v1";
function makeLocalStorageMock() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
get length() {
return store.size;
},
key: (index: number) => Array.from(store.keys())[index] ?? null,
};
}
const localStorageMock = makeLocalStorageMock();
vi.stubGlobal("localStorage", localStorageMock);
function set(raw: string | null) {
if (raw === null) localStorageMock.removeItem(STORAGE_KEY);
else localStorageMock.setItem(STORAGE_KEY, raw);
}
const valid: SavedPreset = {
id: "g_1",
kind: "mr",
label: "My MRs",
customQuery: "scope=created_by_me",
projectFilter: "",
createdAt: "2026-01-01T00:00:00Z",
};
describe("readStorage (gitlab)", () => {
beforeEach(() => {
localStorageMock.clear();
});
it("returns empty array when no value is stored", () => {
expect(readStorage()).toEqual([]);
});
it("returns empty array for malformed JSON", () => {
set("not-json{");
expect(readStorage()).toEqual([]);
});
it("returns empty array when parsed value is not an array", () => {
set(JSON.stringify({ id: "g_1" }));
expect(readStorage()).toEqual([]);
});
it("keeps valid entries", () => {
set(JSON.stringify([valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops entries missing an id", () => {
const missingId = { ...valid } as Partial<SavedPreset>;
delete missingId.id;
set(JSON.stringify([missingId, valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops entries with invalid kind", () => {
set(JSON.stringify([{ ...valid, kind: "pr" }, valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops non-object entries", () => {
set(JSON.stringify(["string", 42, null, valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops entries with non-string label", () => {
set(JSON.stringify([{ ...valid, label: 123 }, valid]));
expect(readStorage()).toEqual([valid]);
});
it("accepts issue kind", () => {
const issue: SavedPreset = { ...valid, kind: "issue" };
set(JSON.stringify([issue]));
expect(readStorage()).toEqual([issue]);
});
it("drops entries missing customQuery", () => {
const broken = { ...valid } as Partial<SavedPreset>;
delete broken.customQuery;
set(JSON.stringify([broken, valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops entries missing projectFilter", () => {
const broken = { ...valid } as Partial<SavedPreset>;
delete broken.projectFilter;
set(JSON.stringify([broken, valid]));
expect(readStorage()).toEqual([valid]);
});
it("drops entries missing createdAt", () => {
const broken = { ...valid } as Partial<SavedPreset>;
delete broken.createdAt;
set(JSON.stringify([broken, valid]));
expect(readStorage()).toEqual([valid]);
});
});
describe("useSavedPresets (gitlab)", () => {
beforeEach(() => {
localStorageMock.clear();
__resetSnapshotForTests();
});
it("save appends a preset and assigns id + createdAt", () => {
const { result } = renderHook(() => useSavedPresets());
act(() => {
result.current.save({
kind: "mr",
label: "My MRs",
customQuery: "scope=created_by_me",
projectFilter: "",
});
});
expect(result.current.presets).toHaveLength(1);
const [p] = result.current.presets;
expect(p.label).toBe("My MRs");
expect(p.kind).toBe("mr");
expect(p.id).toMatch(/^g_/);
expect(p.createdAt).toBeTruthy();
});
it("remove deletes the preset", () => {
const { result } = renderHook(() => useSavedPresets());
let created!: SavedPreset;
act(() => {
created = result.current.save({
kind: "issue",
label: "Triage",
customQuery: "labels=bug",
projectFilter: "",
});
});
expect(result.current.presets).toHaveLength(1);
act(() => result.current.remove(created.id));
expect(result.current.presets).toHaveLength(0);
});
it("two hook instances stay in sync via the module-level store", () => {
const a = renderHook(() => useSavedPresets());
const b = renderHook(() => useSavedPresets());
act(() => {
a.result.current.save({
kind: "mr",
label: "Shared",
customQuery: "q",
projectFilter: "",
});
});
expect(a.result.current.presets).toHaveLength(1);
expect(b.result.current.presets).toHaveLength(1);
expect(b.result.current.presets[0].label).toBe("Shared");
});
});
@@ -0,0 +1,104 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
const STORAGE_KEY = "kandev:gitlab-presets:v1";
export type SavedPreset = {
id: string;
kind: "mr" | "issue";
label: string;
customQuery: string;
projectFilter: string;
createdAt: string;
};
export function readStorage(): SavedPreset[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(p): p is SavedPreset =>
typeof p === "object" &&
p !== null &&
typeof (p as SavedPreset).id === "string" &&
((p as SavedPreset).kind === "mr" || (p as SavedPreset).kind === "issue") &&
typeof (p as SavedPreset).label === "string" &&
typeof (p as SavedPreset).customQuery === "string" &&
typeof (p as SavedPreset).projectFilter === "string" &&
typeof (p as SavedPreset).createdAt === "string",
);
} catch {
return [];
}
}
function writeStorage(presets: SavedPreset[]) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
} catch {
/* ignore quota / access errors */
}
}
const listeners = new Set<() => void>();
let snapshot: SavedPreset[] | null = null;
const emptySnapshot: SavedPreset[] = [];
function getSnapshot(): SavedPreset[] {
if (snapshot === null) snapshot = readStorage();
return snapshot;
}
function getServerSnapshot(): SavedPreset[] {
return emptySnapshot;
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
function publish(next: SavedPreset[]) {
snapshot = next;
writeStorage(next);
for (const l of listeners) l();
}
// Test-only: drop the module-level snapshot so the next read goes through
// readStorage again. Used by the hook tests so each `it` starts from a known
// empty state independent of test execution order.
export function __resetSnapshotForTests() {
snapshot = null;
for (const l of listeners) l();
}
export function useSavedPresets() {
const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
// Always merge against the latest localStorage read instead of the in-memory
// snapshot. With two tabs open, snapshot in tab A is stale the moment tab B
// writes — appending to it would silently drop B's preset. readStorage is
// cheap (small JSON, single key) so this is fine on the save/remove paths.
const save = useCallback((input: Omit<SavedPreset, "id" | "createdAt">) => {
const preset: SavedPreset = {
...input,
id: `g_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
};
publish([...readStorage(), preset]);
return preset;
}, []);
const remove = useCallback((id: string) => {
publish(readStorage().filter((p) => p.id !== id));
}, []);
return { presets, save, remove };
}
@@ -1,158 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import type { Issue, MR, MRSearchPage, IssueSearchPage } from "@/lib/types/gitlab";
const searchUserMRsMock = vi.fn<[unknown], Promise<MRSearchPage | null>>();
const searchUserIssuesMock = vi.fn<[unknown], Promise<IssueSearchPage | null>>();
vi.mock("@/lib/api/domains/gitlab-api", () => ({
searchUserMRs: (args: unknown) => searchUserMRsMock(args),
searchUserIssues: (args: unknown) => searchUserIssuesMock(args),
}));
import { useGitLabUserIssues, useGitLabUserMRs } from "./use-user-search";
afterEach(() => cleanup());
function fakeMR(): MR {
return {
id: 1,
iid: 1,
project_id: 1,
title: "",
url: "",
web_url: "",
state: "opened",
head_branch: "feat",
head_sha: "",
base_branch: "main",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
body: "",
draft: false,
merge_status: "",
has_conflicts: false,
additions: 0,
deletions: 0,
reviewers: [],
assignees: [],
created_at: "",
updated_at: "",
};
}
function fakeIssue(): Issue {
return {
id: 1,
iid: 1,
project_id: 1,
title: "",
body: "",
url: "",
web_url: "",
state: "opened",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
labels: [],
assignees: [],
created_at: "",
updated_at: "",
};
}
describe("useGitLabUserMRs", () => {
beforeEach(() => {
searchUserMRsMock.mockReset();
});
it("forwards filter, query, and perPage to the API", async () => {
searchUserMRsMock.mockResolvedValueOnce({ mrs: [], total_count: 0, page: 1, per_page: 25 });
renderHook(() => useGitLabUserMRs("authored", "labels=bug", 25));
await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalledTimes(1));
expect(searchUserMRsMock).toHaveBeenCalledWith({
filter: "authored",
customQuery: "labels=bug",
perPage: 25,
});
});
it("populates items on success and clears loading", async () => {
const mr = fakeMR();
searchUserMRsMock.mockResolvedValueOnce({ mrs: [mr], total_count: 1, page: 1, per_page: 50 });
const { result } = renderHook(() => useGitLabUserMRs("a", ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([mr]);
expect(result.current.error).toBeNull();
});
it("surfaces an error message and empty items on rejection", async () => {
searchUserMRsMock.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => useGitLabUserMRs("a", ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("boom");
expect(result.current.items).toEqual([]);
});
it("ignores stale responses when inputs change mid-flight", async () => {
let resolveFirst: (v: MRSearchPage) => void = () => {};
searchUserMRsMock.mockReturnValueOnce(
new Promise<MRSearchPage>((res) => {
resolveFirst = res;
}),
);
const second = fakeMR();
searchUserMRsMock.mockResolvedValueOnce({
mrs: [second],
total_count: 1,
page: 1,
per_page: 50,
});
const { result, rerender } = renderHook(
({ filter }: { filter: string }) => useGitLabUserMRs(filter, ""),
{ initialProps: { filter: "a" } },
);
rerender({ filter: "b" });
await waitFor(() => expect(result.current.items).toEqual([second]));
// The first request resolves *after* the swap — it must NOT clobber
// the newer items list.
resolveFirst({
mrs: [fakeMR(), fakeMR()],
total_count: 2,
page: 1,
per_page: 50,
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current.items).toEqual([second]);
});
});
describe("useGitLabUserIssues", () => {
beforeEach(() => {
searchUserIssuesMock.mockReset();
});
it("forwards args and returns items", async () => {
const issue = fakeIssue();
searchUserIssuesMock.mockResolvedValueOnce({
issues: [issue],
total_count: 1,
page: 1,
per_page: 50,
});
const { result } = renderHook(() => useGitLabUserIssues("assigned_to_me", ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([issue]);
});
it("handles rejection without populating items", async () => {
searchUserIssuesMock.mockRejectedValueOnce(new Error("net"));
const { result } = renderHook(() => useGitLabUserIssues("a", ""));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("net");
expect(result.current.items).toEqual([]);
});
});
@@ -1,89 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { searchUserIssues, searchUserMRs } from "@/lib/api/domains/gitlab-api";
import type { Issue, MR } from "@/lib/types/gitlab";
type SearchState<T> = {
items: T[];
loading: boolean;
error: string | null;
};
function initial<T>(): SearchState<T> {
return { items: [], loading: true, error: null };
}
async function fetchMRs(
filter: string,
query: string,
perPage: number,
onResult: (s: SearchState<MR>) => void,
) {
onResult({ items: [], loading: true, error: null });
try {
const res = await searchUserMRs({ filter, customQuery: query, perPage });
onResult({ items: res?.mrs ?? [], loading: false, error: null });
} catch (err) {
onResult({
items: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to load MRs",
});
}
}
async function fetchIssues(
filter: string,
query: string,
perPage: number,
onResult: (s: SearchState<Issue>) => void,
) {
onResult({ items: [], loading: true, error: null });
try {
const res = await searchUserIssues({ filter, customQuery: query, perPage });
onResult({ items: res?.issues ?? [], loading: false, error: null });
} catch (err) {
onResult({
items: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to load issues",
});
}
}
/** Fetch the current user's MRs from GitLab. Re-runs whenever filter or query change. */
export function useGitLabUserMRs(filter: string, query: string, perPage = 50): SearchState<MR> {
const [state, setState] = useState<SearchState<MR>>(initial);
const requestRef = useRef(0);
useEffect(() => {
const requestId = ++requestRef.current;
void fetchMRs(filter, query, perPage, (next) => {
if (requestRef.current !== requestId) return;
setState(next);
});
}, [filter, query, perPage]);
return state;
}
/** Fetch the current user's issues from GitLab. */
export function useGitLabUserIssues(
filter: string,
query: string,
perPage = 50,
): SearchState<Issue> {
const [state, setState] = useState<SearchState<Issue>>(initial);
const requestRef = useRef(0);
useEffect(() => {
const requestId = ++requestRef.current;
void fetchIssues(filter, query, perPage, (next) => {
if (requestRef.current !== requestId) return;
setState(next);
});
}, [filter, query, perPage]);
return state;
}