cf76a27488
* 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>
256 lines
6.9 KiB
TypeScript
256 lines
6.9 KiB
TypeScript
"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>
|
|
);
|
|
}
|