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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user