From cf76a2748857bef3d2266418c5ec34e0140573cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 26 May 2026 16:00:55 +0100 Subject: [PATCH] feat(web): normalize /gitlab page to match /github layout (#1082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 --- apps/web/app/gitlab/gitlab-page-client.tsx | 631 +++++++++--------- .../gitlab/my-gitlab/issue-list.tsx | 103 +++ .../gitlab/my-gitlab/list-toolbar.tsx | 150 +++++ .../components/gitlab/my-gitlab/mr-list.tsx | 101 +++ .../gitlab/my-gitlab/presets-sidebar.tsx | 255 +++++++ .../components/gitlab/my-gitlab/presets.ts | 58 ++ .../gitlab/my-gitlab/results-pagination.tsx | 101 +++ .../gitlab/my-gitlab/save-preset-dialog.tsx | 134 ++++ .../my-gitlab/use-committed-query.test.ts | 34 + .../gitlab/my-gitlab/use-committed-query.ts | 20 + .../my-gitlab/use-gitlab-search.test.ts | 245 +++++++ .../gitlab/my-gitlab/use-gitlab-search.ts | 140 ++++ .../my-gitlab/use-known-projects.test.ts | 43 ++ .../gitlab/my-gitlab/use-known-projects.ts | 68 ++ .../my-gitlab/use-saved-presets.test.ts | 172 +++++ .../gitlab/my-gitlab/use-saved-presets.ts | 104 +++ .../domains/gitlab/use-user-search.test.ts | 158 ----- .../hooks/domains/gitlab/use-user-search.ts | 89 --- 18 files changed, 2048 insertions(+), 558 deletions(-) create mode 100644 apps/web/components/gitlab/my-gitlab/issue-list.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/list-toolbar.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/mr-list.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/presets.ts create mode 100644 apps/web/components/gitlab/my-gitlab/results-pagination.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/save-preset-dialog.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/use-committed-query.test.ts create mode 100644 apps/web/components/gitlab/my-gitlab/use-committed-query.ts create mode 100644 apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts create mode 100644 apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts create mode 100644 apps/web/components/gitlab/my-gitlab/use-known-projects.test.ts create mode 100644 apps/web/components/gitlab/my-gitlab/use-known-projects.ts create mode 100644 apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts create mode 100644 apps/web/components/gitlab/my-gitlab/use-saved-presets.ts delete mode 100644 apps/web/hooks/domains/gitlab/use-user-search.test.ts delete mode 100644 apps/web/hooks/domains/gitlab/use-user-search.ts diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx index 45521b439a..7cc177847c 100644 --- a/apps/web/app/gitlab/gitlab-page-client.tsx +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -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 ( - - Merged - - ); - } - if (state === "closed") { - return ( - - Closed - - ); - } - if (mr.draft) { - return ( - - Draft - - ); - } +function PageHeader({ + host, + onOpenMobileSidebar, +}: { + host: string; + onOpenMobileSidebar?: () => void; +}) { return ( - - Open - - ); -} - -function IssueStateBadge({ issue }: { issue: Issue }) { - if (issue.state === "closed") { - return ( - - Closed - - ); - } - return ( - - Open - + } + actions={ + onOpenMobileSidebar && ( + + ) + } + /> ); } function NotConnectedNotice() { return ( - - - -

GitLab not connected

-

- Connect your GitLab account to browse merge requests and issues from this page. -

- -
-
+ + + GitLab is not connected. Configure GitLab authentication in{" "} + + Settings → GitLab + {" "} + to see your merge requests and issues. + + ); } -function MRList({ filter, query }: { filter: string; query: string }) { - const { items: mrs, loading, error } = useGitLabUserMRs(filter, query); - - if (loading) { - return ( -
- -
- ); - } - if (error) { - return

{error}

; - } - if (mrs.length === 0) { - return ( -

No merge requests found.

- ); +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 ( -
    - {mrs.map((mr) => ( -
  • - -
    -
    -
    - {mr.title} - -
    -

    - {mr.project_path} !{mr.iid} · {mr.head_branch} → {mr.base_branch} -

    -
    - -
    - -
  • - ))} -
+ 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; + loading: boolean; + error: string | null; +}) { + if (selection.kind === "mr") { + return ; + } + return ; +} - if (loading) { - return ( -
- -
- ); - } - if (error) { - return

{error}

; - } - if (issues.length === 0) { - return

No issues found.

; - } +type GitLabPageState = ReturnType; + +function useProjectOptions( + selection: SidebarSelection, + committedQuery: string, + items: Array, + 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(() => ({ + 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 ( -
    - {issues.map((issue) => ( -
  • - -
    -
    -
    - {issue.title} - -
    -

    - {issue.project_path} #{issue.iid} - {issue.labels.length > 0 && ` · ${issue.labels.slice(0, 3).join(", ")}`} -

    -
    - -
    - -
  • - ))} -
+
+ +
+ +
+ +
+ +
+
); } -export function GitLabPageClient({ workspaceId: _workspaceId }: { workspaceId?: string }) { +function useGitLabStatusFetch() { const [status, setStatus] = useState(null); - const [statusLoading, setStatusLoading] = useState(true); - const [mrFilter, setMrFilter] = useState(MR_FILTERS[0].value); - const [issueFilter, setIssueFilter] = useState(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[0]) => { + onSelect(s); + setMobileSidebarOpen(false); + }, + [onSelect], + ); + const onMobileSaveCurrent = useCallback(() => { + setMobileSidebarOpen(false); + onOpenSaveDialog(); + }, [onOpenSaveDialog]); return ( -
- } - actions={ - - } +
+ -
- {!statusLoading && !connected ? ( + {statusLoading && ( +
Checking GitLab status…
+ )} + {!statusLoading && !connected && ( +
- ) : ( - - )} -
-
- ); -} - -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 ( - <> - setSearch(e.target.value)} - className="font-mono text-sm" - /> - - - - - - Merge requests - - - - Issues - - - - - - - - - - - - - ); -} - -function FilterButtons({ - value, - onChange, - options, -}: { - value: string; - onChange: (v: string) => void; - options: readonly { value: string; label: string }[]; -}) { - return ( -
- {options.map((f) => ( -
+ )} + {!statusLoading && connected && } + + - {f.label} - - ))} + + Filters + + + + +
); } diff --git a/apps/web/components/gitlab/my-gitlab/issue-list.tsx b/apps/web/components/gitlab/my-gitlab/issue-list.tsx new file mode 100644 index 0000000000..7a1f72c8ac --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/issue-list.tsx @@ -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) => ( + + {l} + + ))} + {labels.length > 4 && ( + +{labels.length - 4} + )} + + ); +} + +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 ( +
+ +
+ + {issue.title} + + +
+ + {issue.project_path}#{issue.iid} + + · + + by {issue.author_username} · opened {formatRelativeTime(issue.created_at)} + + +
+
+
+ ); +} + +function IssueListBody({ loading, error, items }: IssueListProps) { + if (loading) { + return ( +
+ +
+ ); + } + if (error) { + return
{error}
; + } + if (items.length === 0) { + return ( +
+ No issues match this filter. +
+ ); + } + return ( +
+ {items.map((issue) => ( + + ))} +
+ ); +} + +export function IssueList(props: IssueListProps) { + return ( +
+ +
+ ); +} diff --git a/apps/web/components/gitlab/my-gitlab/list-toolbar.tsx b/apps/web/components/gitlab/my-gitlab/list-toolbar.tsx new file mode 100644 index 0000000000..5856a17462 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/list-toolbar.tsx @@ -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 && ( + + {showUpdatedPrefix ? "Updated " : ""} + {formatRelativeTime(lastFetchedAt.toISOString())} + + )} + + + ); +} + +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 ( +
+
+
+

+ {title} +

+ + {loading ? "…" : count} + +
+
+ +
+
+ +
+ 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 && ( + + Press Enter + + )} +
+
+ +
+
+ ); +} diff --git a/apps/web/components/gitlab/my-gitlab/mr-list.tsx b/apps/web/components/gitlab/my-gitlab/mr-list.tsx new file mode 100644 index 0000000000..b0d0520a02 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/mr-list.tsx @@ -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 ( +
+ +
+ + {mr.title} + + +
+ + {mr.project_path}!{mr.iid} + + · + + {mr.head_branch} → {mr.base_branch} + + · + + by {mr.author_username} · opened {formatRelativeTime(mr.created_at)} + +
+
+
+ ); +} + +function MRListBody({ loading, error, items }: MRListProps) { + if (loading) { + return ( +
+ +
+ ); + } + if (error) { + return
{error}
; + } + if (items.length === 0) { + return ( +
+ No merge requests match this filter. +
+ ); + } + return ( +
+ {items.map((mr) => ( + + ))} +
+ ); +} + +export function MRList(props: MRListProps) { + return ( +
+ +
+ ); +} diff --git a/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx new file mode 100644 index 0000000000..e4da8bb325 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx @@ -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 ( +
+ {(["mr", "issue"] as const).map((value) => ( + + ))} +
+ ); +} + +function SectionHeader({ title }: { title: string }) { + return ( +
+ {title} +
+ ); +} + +function PresetItem({ + label, + Icon: ItemIcon, + active, + onClick, + trailing, +}: { + label: string; + Icon: Icon; + active: boolean; + onClick: () => void; + trailing?: React.ReactNode; +}) { + const onKeyDown = (e: React.KeyboardEvent) => { + // 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 ( +
+ + {label} + {trailing} +
+ ); +} + +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 ( + <> + + {items.map((p) => ( + 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 ( + <> + + {saved.length === 0 && ( +
+ No saved queries yet. +
+ )} + {saved.map((s) => ( + onSelect({ kind, source: "saved", id: s.id })} + trailing={ + + } + /> + ))} + + + ); +} + +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 ( + + ); +} diff --git a/apps/web/components/gitlab/my-gitlab/presets.ts b/apps/web/components/gitlab/my-gitlab/presets.ts new file mode 100644 index 0000000000..54437fdd6c --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/presets.ts @@ -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, + }, +]; diff --git a/apps/web/components/gitlab/my-gitlab/results-pagination.tsx b/apps/web/components/gitlab/my-gitlab/results-pagination.tsx new file mode 100644 index 0000000000..a5a614f89f --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/results-pagination.tsx @@ -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([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 ( +
+
+ {start}–{end} of {total} +
+ + + + { + e.preventDefault(); + if (safePage > 1) onPageChange(safePage - 1); + }} + aria-disabled={safePage <= 1} + className={safePage <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + {windowPages.map((p, i) => { + const prev = windowPages[i - 1]; + const needsGap = prev !== undefined && p - prev > 1; + return ( + + {needsGap && ( + + + + )} + + { + e.preventDefault(); + onPageChange(p); + }} + className="cursor-pointer" + > + {p} + + + + ); + })} + + { + e.preventDefault(); + if (safePage < totalPages) onPageChange(safePage + 1); + }} + aria-disabled={safePage >= totalPages} + className={ + safePage >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer" + } + /> + + + +
+ ); +} diff --git a/apps/web/components/gitlab/my-gitlab/save-preset-dialog.tsx b/apps/web/components/gitlab/my-gitlab/save-preset-dialog.tsx new file mode 100644 index 0000000000..e26080ba48 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/save-preset-dialog.tsx @@ -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 ( + <> + + Save GitLab query + + Save this {kind === "mr" ? "merge request" : "issue"} query to the sidebar for quick + access later. + + +
+
+ + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(); + }} + onFocus={(e) => e.target.select()} + placeholder="e.g. Needs my review" + /> +
+
+ {customQuery && ( +
+ Query + + {customQuery} + +
+ )} + {projectFilter && ( +
+ Project + + {projectFilter} + +
+ )} +
+
+ + + + + + ); +} + +export function SavePresetDialog({ + open, + onOpenChange, + kind, + customQuery, + projectFilter, + suggestedLabel, + onSave, +}: SavePresetDialogProps) { + const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]); + return ( + + + {open && ( + + )} + + + ); +} diff --git a/apps/web/components/gitlab/my-gitlab/use-committed-query.test.ts b/apps/web/components/gitlab/my-gitlab/use-committed-query.test.ts new file mode 100644 index 0000000000..c6029871ee --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/use-committed-query.test.ts @@ -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"); + }); +}); diff --git a/apps/web/components/gitlab/my-gitlab/use-committed-query.ts b/apps/web/components/gitlab/my-gitlab/use-committed-query.ts new file mode 100644 index 0000000000..5310d19a4d --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/use-committed-query.ts @@ -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 }; +} diff --git a/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts new file mode 100644 index 0000000000..c0d80e88f5 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts @@ -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>(); +const searchUserIssuesMock = vi.fn<[unknown], Promise>(); + +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 { + 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 { + 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; + 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; + 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((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]); + }); +}); diff --git a/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts new file mode 100644 index 0000000000..ff190a6977 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts @@ -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({ + 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, + }; +} diff --git a/apps/web/components/gitlab/my-gitlab/use-known-projects.test.ts b/apps/web/components/gitlab/my-gitlab/use-known-projects.test.ts new file mode 100644 index 0000000000..2a7fb73269 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/use-known-projects.test.ts @@ -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([]); + }); +}); diff --git a/apps/web/components/gitlab/my-gitlab/use-known-projects.ts b/apps/web/components/gitlab/my-gitlab/use-known-projects.ts new file mode 100644 index 0000000000..9db71d5239 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/use-known-projects.ts @@ -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(); +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; +} diff --git a/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts b/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts new file mode 100644 index 0000000000..aa9b1b3a65 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts @@ -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(); + 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; + 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; + delete broken.customQuery; + set(JSON.stringify([broken, valid])); + expect(readStorage()).toEqual([valid]); + }); + + it("drops entries missing projectFilter", () => { + const broken = { ...valid } as Partial; + delete broken.projectFilter; + set(JSON.stringify([broken, valid])); + expect(readStorage()).toEqual([valid]); + }); + + it("drops entries missing createdAt", () => { + const broken = { ...valid } as Partial; + 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"); + }); +}); diff --git a/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts b/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts new file mode 100644 index 0000000000..3cf6948132 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts @@ -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) => { + 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 }; +} diff --git a/apps/web/hooks/domains/gitlab/use-user-search.test.ts b/apps/web/hooks/domains/gitlab/use-user-search.test.ts deleted file mode 100644 index 8fc6fd94b9..0000000000 --- a/apps/web/hooks/domains/gitlab/use-user-search.test.ts +++ /dev/null @@ -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>(); -const searchUserIssuesMock = vi.fn<[unknown], Promise>(); - -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((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([]); - }); -}); diff --git a/apps/web/hooks/domains/gitlab/use-user-search.ts b/apps/web/hooks/domains/gitlab/use-user-search.ts deleted file mode 100644 index 6ab6e96dbc..0000000000 --- a/apps/web/hooks/domains/gitlab/use-user-search.ts +++ /dev/null @@ -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 = { - items: T[]; - loading: boolean; - error: string | null; -}; - -function initial(): SearchState { - return { items: [], loading: true, error: null }; -} - -async function fetchMRs( - filter: string, - query: string, - perPage: number, - onResult: (s: SearchState) => 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) => 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 { - const [state, setState] = useState>(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 { - const [state, setState] = useState>(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; -}