79a4534449
* fix: gate gitlab/linear fetches on integration being configured The GitLab and Linear pages fired merge-request/issue searches on mount regardless of connection state, producing console errors when the integration was unconfigured (two 500s on /gitlab, one 503 on /linear) even though the UI already rendered the "not connected/configured" notice. Gate the search fetch on the same connected/configured condition that decides whether the results list renders: - useGitLabSearch takes an `enabled` flag (converted to an options object to stay within the 5-param lint limit, matching useGitHubSearch); the page enables it only when status is loaded and GitLab is connected. - Linear's inline issue-search hook is extracted to useLinearIssueSearch with an `enabled` flag, enabled only when the workspace is loaded, configured, and available. Adds gating/wiring tests for both hooks. * refactor: gate gitlab search at the effect level too Mirror the Linear hook (which guards both its run callback and its debounce effect) by adding the enabled guard to useGitLabSearch's fetch effect and enabled to its dep array, so a disabled integration never schedules a fetch. The callback keeps its own guard to also cover the exported refresh(). Addresses PR review feedback on the enabled-gate asymmetry. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import Link from "@/components/routing/app-link";
|
|
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 { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
|
|
import { PageTopbar } from "@/components/page-topbar";
|
|
import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api";
|
|
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 { PresetsScopeBar } from "@/components/gitlab/my-gitlab/presets-scope-bar";
|
|
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";
|
|
|
|
function PageHeader({
|
|
host,
|
|
onOpenMobileSidebar,
|
|
}: {
|
|
host: string;
|
|
onOpenMobileSidebar?: () => void;
|
|
}) {
|
|
return (
|
|
<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 (
|
|
<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 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 (
|
|
presets.find((p) => p.value === selection.id)?.label ??
|
|
(selection.kind === "mr" ? "Merge requests" : "Issues")
|
|
);
|
|
}
|
|
|
|
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} />;
|
|
}
|
|
|
|
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(searchEnabled: boolean) {
|
|
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({
|
|
kind: selection.kind,
|
|
presets,
|
|
preset: selection.source === "preset" ? selection.id : "",
|
|
customQuery: committedQuery,
|
|
projectFilter,
|
|
enabled: searchEnabled,
|
|
});
|
|
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 (
|
|
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
<PresetsScopeBar
|
|
className="hidden md:flex"
|
|
selected={selection}
|
|
onSelect={state.onSelect}
|
|
savedPresets={state.savedPresets}
|
|
onDeleteSaved={state.onDeleteSaved}
|
|
canSaveCurrent={state.canSaveCurrent}
|
|
onSaveCurrent={state.onOpenSaveDialog}
|
|
/>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function useGitLabStatusFetch() {
|
|
const [status, setStatus] = useState<GitLabStatus | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
fetchGitLabStatus({ cache: "no-store" })
|
|
.then((s) => {
|
|
if (!cancelled) setStatus(s);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setStatus(null);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return { status, loading };
|
|
}
|
|
|
|
export function GitLabPageClient(_props: { workspaceId?: string } = {}) {
|
|
const { status, loading: statusLoading } = useGitLabStatusFetch();
|
|
const connected = !!(status?.authenticated || status?.token_configured);
|
|
const host = status?.host ?? "https://gitlab.com";
|
|
const state = useGitLabPageState(!statusLoading && connected);
|
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
|
|
useEffect(() => resetKnownProjectsStore, []);
|
|
|
|
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="h-screen w-full flex flex-col bg-background">
|
|
<PageHeader
|
|
host={host}
|
|
onOpenMobileSidebar={!statusLoading && connected ? onOpenMobileSidebar : undefined}
|
|
/>
|
|
{statusLoading && (
|
|
<div className="p-6 text-sm text-muted-foreground">Checking GitLab status…</div>
|
|
)}
|
|
{!statusLoading && !connected && (
|
|
<div className="p-6 max-w-2xl">
|
|
<NotConnectedNotice />
|
|
</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"
|
|
>
|
|
<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>
|
|
);
|
|
}
|