Files
kandev/apps/web/app/gitlab/gitlab-page-client.tsx
T
2026-09-12 14:58:49 +02:00

436 lines
14 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 { PageShell } from "@/components/page-shell";
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 type { SidebarSelection } from "@/components/gitlab/my-gitlab/presets-sidebar";
import { PresetsSidebar } from "@/components/gitlab/my-gitlab/presets-sidebar";
import { PresetsScopeBar } from "@/components/gitlab/my-gitlab/presets-scope-bar";
import { resetKnownProjectsStore } from "@/components/gitlab/my-gitlab/use-known-projects";
import { ListToolbar } from "@/components/gitlab/my-gitlab/list-toolbar";
import { useGitLabPageState, type GitLabPageState } from "./use-gitlab-page-state";
import { ResultsPagination } from "@/components/gitlab/my-gitlab/results-pagination";
import { SavePresetDialog } from "@/components/gitlab/my-gitlab/save-preset-dialog";
import { useMRKeyToTasks } from "@/hooks/domains/gitlab/use-mr-key-to-tasks";
import { useGitLabActionPresets } from "@/hooks/domains/gitlab/use-gitlab-action-presets";
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http";
import {
QuickTaskLauncher,
type GitLabLaunchPayload,
type GitLabTaskPreset,
} from "@/components/gitlab/my-gitlab/quick-task-launcher";
import { toGitLabTaskPreset } from "@/components/gitlab/my-gitlab/task-presets";
import { useAppStore } from "@/components/state-provider";
import { Trans, useTranslation } from "react-i18next";
import { IntegrationFiltersSheet } from "@/components/integrations/integration-filters-sheet";
type GitLabPageClientProps = {
workspaceId?: string;
workflows?: Workflow[];
steps?: WorkflowStep[];
repositories?: Repository[];
};
function NotConnectedNotice({ reconnect }: { reconnect?: boolean }) {
const { t } = useTranslation();
return (
<Alert>
<AlertDescription>
{reconnect
? t("gitlab:gitlabCredentialsAreConfiguredButAuthentication")
: t("gitlab:gitlabIsNotConnectedConfigureGitlab")}
<Trans i18nKey="gitlab:openSettingsToSeeMrsAndIssues">
<Link
href="/settings/integrations/gitlab"
className="underline font-medium cursor-pointer"
/>
to see your merge requests and issues.
</Trans>
</AlertDescription>
</Alert>
);
}
function ResultsList({
selection,
items,
loading,
error,
mrPresets,
issuePresets,
onStartTask,
mrKeyToTasks,
workspaceId,
host,
}: {
selection: SidebarSelection;
items: Array<MR | Issue>;
loading: boolean;
error: string | null;
mrPresets: GitLabTaskPreset[];
issuePresets: GitLabTaskPreset[];
onStartTask: (payload: GitLabLaunchPayload) => void;
mrKeyToTasks: ReturnType<typeof useMRKeyToTasks>;
workspaceId?: string;
host: string;
}) {
if (selection.kind === "mr") {
return (
<MRList
items={items as MR[]}
loading={loading}
error={error}
presets={mrPresets}
onStartTask={onStartTask}
mrKeyToTasks={mrKeyToTasks}
/>
);
}
return (
<IssueList
items={items as Issue[]}
loading={loading}
error={error}
presets={issuePresets}
onStartTask={onStartTask}
workspaceId={workspaceId}
host={host}
/>
);
}
function AuthenticatedLayout({
workspaceId,
state,
mrPresets,
issuePresets,
onStartTask,
host,
}: {
workspaceId?: string;
state: GitLabPageState;
mrPresets: GitLabTaskPreset[];
issuePresets: GitLabTaskPreset[];
onStartTask: (payload: GitLabLaunchPayload) => void;
host: string;
}) {
const { selection, search, projectOptions, title } = state;
const mrKeyToTasks = useMRKeyToTasks(workspaceId ?? null);
useAllWorkflowSnapshots(workspaceId ?? null);
// 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 (
// Not a <main>: AppShell owns that landmark, one per page.
<div 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}
showMilestoneFilter={state.showMilestoneFilter}
milestone={state.milestone}
committedMilestone={state.committedMilestone}
onMilestoneChange={state.setMilestone}
onCommitMilestone={state.onCommitMilestone}
/>
<div className="flex-1 overflow-auto px-6 py-4">
<ResultsList
selection={selection}
items={search.items}
loading={search.loading}
error={search.error}
mrPresets={mrPresets}
issuePresets={issuePresets}
onStartTask={onStartTask}
mrKeyToTasks={mrKeyToTasks}
workspaceId={workspaceId}
host={host}
/>
</div>
<ResultsPagination
page={search.page}
pageSize={search.pageSize}
total={search.total}
onPageChange={search.setPage}
/>
</div>
);
}
function useGitLabStatusFetch(workspaceId: string | undefined, enabled: boolean) {
const [result, setResult] = useState<{ workspaceId?: string; status: GitLabStatus | null }>({
workspaceId,
status: null,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!enabled || !workspaceId) {
setLoading(true);
return;
}
let cancelled = false;
setLoading(true);
fetchGitLabStatus({ cache: "no-store", workspaceId })
.then((s) => {
if (!cancelled) setResult({ workspaceId, status: s });
})
.catch(() => {
if (!cancelled) setResult({ workspaceId, status: null });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled, workspaceId]);
const current = enabled && result.workspaceId === workspaceId;
return { status: current ? result.status : null, loading: !current || loading };
}
function GitLabPageBody({
statusLoading,
connected,
reconnect,
workspaceId,
state,
mrPresets,
issuePresets,
onStartTask,
host,
}: {
statusLoading: boolean;
connected: boolean;
reconnect: boolean;
workspaceId?: string;
state: GitLabPageState;
mrPresets: GitLabTaskPreset[];
issuePresets: GitLabTaskPreset[];
onStartTask: (payload: GitLabLaunchPayload) => void;
host: string;
}) {
const { t } = useTranslation();
if (statusLoading) {
return (
<div className="p-6 text-sm text-muted-foreground">{t("gitlab:checkingGitlabStatus")}</div>
);
}
if (!connected) {
return (
<div className="p-6 max-w-2xl">
<NotConnectedNotice reconnect={reconnect} />
</div>
);
}
return (
<AuthenticatedLayout
workspaceId={workspaceId}
state={state}
mrPresets={mrPresets}
issuePresets={issuePresets}
onStartTask={onStartTask}
host={host}
/>
);
}
function GitLabPageOverlays({
state,
mobileSidebarOpen,
setMobileSidebarOpen,
workspaceId,
workflows,
steps,
repositories,
configuredHost,
launchPayload,
setLaunchPayload,
}: {
state: GitLabPageState;
mobileSidebarOpen: boolean;
setMobileSidebarOpen: (open: boolean) => void;
workspaceId?: string;
workflows: Workflow[];
steps: WorkflowStep[];
repositories: Repository[];
configuredHost: string;
launchPayload: GitLabLaunchPayload | null;
setLaunchPayload: (payload: GitLabLaunchPayload | null) => void;
}) {
const { t } = useTranslation();
const onMobileSidebarSelect = (selection: Parameters<typeof state.onSelect>[0]) => {
state.onSelect(selection);
setMobileSidebarOpen(false);
};
const onMobileSaveCurrent = () => {
setMobileSidebarOpen(false);
state.onOpenSaveDialog();
};
return (
<>
<IntegrationFiltersSheet
key={workspaceId}
open={mobileSidebarOpen}
onOpenChange={setMobileSidebarOpen}
title={t("gitlab:filters")}
testId="gitlab-mobile-sidebar"
>
<PresetsSidebar
selected={state.selection}
onSelect={onMobileSidebarSelect}
savedPresets={state.savedPresets}
onDeleteSaved={state.onDeleteSaved}
canSaveCurrent={state.canSaveCurrent}
onSaveCurrent={onMobileSaveCurrent}
/>
</IntegrationFiltersSheet>
<SavePresetDialog
open={state.saveDialogOpen}
onOpenChange={state.setSaveDialogOpen}
kind={state.selection.kind}
customQuery={state.committedQuery}
projectFilter={state.projectFilter}
suggestedLabel={state.suggestedLabel}
onSave={state.onConfirmSave}
/>
<QuickTaskLauncher
workspaceId={workspaceId ?? null}
configuredHost={configuredHost}
workflows={workflows}
steps={steps}
repositories={repositories}
payload={launchPayload}
onClose={() => setLaunchPayload(null)}
/>
</>
);
}
function useGitLabWorkspaceScope(serverWorkspaceId?: string) {
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const workspaceId = activeWorkspaceId ?? serverWorkspaceId;
const switching = Boolean(serverWorkspaceId && workspaceId && serverWorkspaceId !== workspaceId);
const { status, loading } = useGitLabStatusFetch(workspaceId, !switching);
useEffect(() => {
if (switching) window.location.reload();
}, [switching]);
return {
workspaceId,
switching,
status,
statusLoading: switching || loading,
connected: Boolean(status?.authenticated),
reconnect: Boolean(status?.token_configured && !status.authenticated),
};
}
function useGitLabTaskPresets(workspaceId: string | undefined, switching: boolean) {
const { presets } = useGitLabActionPresets(switching ? null : workspaceId);
const mrPresets = useMemo(() => (presets?.mr ?? []).map(toGitLabTaskPreset), [presets?.mr]);
const issuePresets = useMemo(
() => (presets?.issue ?? []).map(toGitLabTaskPreset),
[presets?.issue],
);
return { mrPresets, issuePresets };
}
export function GitLabPageClient({
workspaceId,
workflows = [],
steps = [],
repositories = [],
}: GitLabPageClientProps = {}) {
const { t } = useTranslation();
const scope = useGitLabWorkspaceScope(workspaceId);
const { status, statusLoading, connected, reconnect } = scope;
const host = status?.host ?? "https://gitlab.com";
const state = useGitLabPageState(!statusLoading && connected, scope.workspaceId);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [launchPayload, setLaunchPayload] = useState<GitLabLaunchPayload | null>(null);
const { mrPresets, issuePresets } = useGitLabTaskPresets(scope.workspaceId, scope.switching);
useEffect(() => resetKnownProjectsStore, []);
const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []);
return (
<PageShell
title="GitLab"
subtitle={t("gitlab:mergeRequestsAndIssues", { host })}
icon={<IconBrandGitlab className="h-4 w-4" />}
scroll="none"
actions={
!statusLoading &&
connected && (
<Button
variant="outline"
size="icon-lg"
onClick={onOpenMobileSidebar}
className="h-11 w-11 md:hidden cursor-pointer"
data-testid="gitlab-mobile-menu-button"
aria-label={t("gitlab:openGitlabFilters")}
>
<IconMenu2 className="h-4 w-4" />
</Button>
)
}
>
<div className="flex min-h-0 w-full flex-1 flex-col bg-background">
<GitLabPageBody
statusLoading={statusLoading}
connected={connected}
reconnect={reconnect}
workspaceId={scope.workspaceId}
state={state}
mrPresets={mrPresets}
issuePresets={issuePresets}
onStartTask={setLaunchPayload}
host={host}
/>
</div>
<GitLabPageOverlays
state={state}
mobileSidebarOpen={mobileSidebarOpen}
setMobileSidebarOpen={setMobileSidebarOpen}
workspaceId={scope.workspaceId}
workflows={workflows}
steps={steps}
repositories={repositories}
configuredHost={host}
launchPayload={launchPayload}
setLaunchPayload={setLaunchPayload}
/>
</PageShell>
);
}