Pipeline/frontend/src/components/git/BoardForgejoRepositoryLinks...

394 lines
14 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AlertCircle, GitBranch, Loader2, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { Input } from "@/components/ui/input";
import {
type BoardForgejoRepositoriesResponse,
type BoardForgejoRepositoryLink,
type ForgejoRepository,
getBoardForgejoRepositories,
getForgejoRepositories,
linkBoardForgejoRepository,
unlinkBoardForgejoRepository,
} from "@/lib/api-forgejo";
interface BoardForgejoRepositoryLinksProps {
boardId: string;
canWrite?: boolean;
}
type LinkedRepository = BoardForgejoRepositoryLink & {
repository: ForgejoRepository;
};
const normalizeBoardLinks = (
result: BoardForgejoRepositoriesResponse,
): BoardForgejoRepositoryLink[] =>
Array.isArray(result) ? result : (result.repositories ?? []);
const repositoryDisplayName = (repository: ForgejoRepository): string =>
repository.display_name || `${repository.owner}/${repository.repo}`;
export function BoardForgejoRepositoryLinks({
boardId,
canWrite = false,
}: BoardForgejoRepositoryLinksProps) {
const [linkedLinks, setLinkedLinks] = useState<BoardForgejoRepositoryLink[]>(
[],
);
const [allRepos, setAllRepos] = useState<ForgejoRepository[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [linkError, setLinkError] = useState<string | null>(null);
const [unlinkError, setUnlinkError] = useState<string | null>(null);
const [isLinking, setIsLinking] = useState(false);
const [isUnlinking, setIsUnlinking] = useState(false);
const [unlinkTarget, setUnlinkTarget] = useState<LinkedRepository | null>(
null,
);
const fetchLinkedRepos = useCallback(async () => {
try {
const result = await getBoardForgejoRepositories(boardId);
setLinkedLinks(normalizeBoardLinks(result));
setLinkError(null);
} catch (err) {
const message =
err instanceof Error
? err.message
: "Unable to load linked Git Project repositories.";
setLinkError(message);
}
}, [boardId]);
const fetchAllRepositories = useCallback(async () => {
try {
const repos = await getForgejoRepositories();
setAllRepos(repos);
setLinkError(null);
} catch (err) {
const message =
err instanceof Error
? err.message
: "Unable to load available Git Project repositories.";
setLinkError(message);
}
}, []);
useEffect(() => {
let isMounted = true;
const loadRepositories = async () => {
setIsLoading(true);
await Promise.all([fetchLinkedRepos(), fetchAllRepositories()]);
if (isMounted) {
setIsLoading(false);
}
};
loadRepositories();
return () => {
isMounted = false;
};
}, [fetchAllRepositories, fetchLinkedRepos]);
const linkedRepoIds = useMemo(
() => new Set(linkedLinks.map((link) => link.repository_id)),
[linkedLinks],
);
const repoById = useMemo(
() => new Map(allRepos.map((repository) => [repository.id, repository])),
[allRepos],
);
const linkedRepos = useMemo(
() =>
linkedLinks
.map((link) => ({
...link,
repository: link.repository ?? repoById.get(link.repository_id),
}))
.filter(
(link): link is LinkedRepository => link.repository !== undefined,
),
[linkedLinks, repoById],
);
const availableRepos = useMemo(() => {
const query = searchTerm.toLowerCase().trim();
return allRepos
.filter((repository) => !linkedRepoIds.has(repository.id))
.filter((repository) => {
if (!query) {
return true;
}
const haystack = [
repositoryDisplayName(repository),
repository.owner,
repository.repo,
]
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [allRepos, linkedRepoIds, searchTerm]);
const handleLinkRepo = async (repositoryId: string) => {
if (!canWrite) {
return;
}
setIsLinking(true);
setLinkError(null);
try {
await linkBoardForgejoRepository(boardId, repositoryId);
await fetchLinkedRepos();
} catch (err) {
const message =
err instanceof Error
? err.message
: "Unable to link this repository to the board.";
setLinkError(message);
} finally {
setIsLinking(false);
}
};
const handleUnlinkRepo = async () => {
if (!unlinkTarget || !canWrite) {
return;
}
setIsUnlinking(true);
setUnlinkError(null);
try {
await unlinkBoardForgejoRepository(boardId, unlinkTarget.repository_id);
await fetchLinkedRepos();
setUnlinkTarget(null);
} catch (err) {
const message =
err instanceof Error
? err.message
: "Unable to unlink this repository from the board.";
setUnlinkError(message);
} finally {
setIsUnlinking(false);
}
};
return (
<>
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush sm:p-5">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-strong">
Linked Git Project Repositories
</h3>
<p className="mt-1 text-sm text-muted">
Choose which synced repositories appear on this Pipeline board.
</p>
</div>
<Badge variant="outline" className="w-fit">
{linkedRepos.length} linked
</Badge>
</div>
{linkError && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-[color:var(--danger)]/35 bg-[color:var(--danger-soft)] px-3 py-2 text-sm text-[color:var(--danger)]">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{linkError}</span>
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-6 text-center text-sm text-muted">
<Loader2 className="mx-auto mb-2 h-4 w-4 animate-spin" />
Loading Git Project repositories...
</div>
) : (
<div className="space-y-5">
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-muted">
On This Board
</div>
{linkedRepos.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-5 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] text-muted">
<GitBranch className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-strong">
No repositories linked yet
</p>
<p className="mt-1 text-sm text-muted">
{canWrite
? "Link a Git Project repository below to bring its issues onto this board."
: "No Git Project repositories are linked to this board yet."}
</p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{linkedRepos.map((link) => {
const repository = link.repository;
return (
<div
key={link.id}
className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2"
>
<div className="min-w-0">
<p
className="truncate text-sm font-medium text-strong"
title={repositoryDisplayName(repository)}
>
{repositoryDisplayName(repository)}
</p>
<p
className="truncate text-xs text-muted"
title={`${repository.owner}/${repository.repo}`}
>
{repository.owner}/{repository.repo}
</p>
</div>
{canWrite ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 shrink-0 p-0 text-[color:var(--danger)] hover:bg-[color:var(--danger-soft)]"
onClick={() => {
setUnlinkError(null);
setUnlinkTarget(link);
}}
aria-label={`Unlink ${repositoryDisplayName(repository)}`}
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
);
})}
</div>
)}
</div>
{canWrite ? (
<div>
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
Available Repositories
</div>
<p className="mt-1 text-sm text-muted">
Link repositories that are already configured in Git
Projects.
</p>
</div>
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search repositories..."
className="w-full sm:w-72"
/>
</div>
{availableRepos.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-5 text-center">
<p className="text-sm font-medium text-strong">
{allRepos.length === 0
? "No Git Project repositories configured"
: "No matching repositories"}
</p>
<p className="mt-1 text-sm text-muted">
{allRepos.length === 0
? "Add repositories in Git Projects before linking them to boards."
: "Adjust the search or unlink a repository from this board."}
</p>
</div>
) : (
<div className="grid gap-2 lg:grid-cols-2">
{availableRepos.slice(0, 9).map((repository) => (
<div
key={repository.id}
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-3 transition-colors hover:bg-[color:var(--accent-soft)] sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p
className="truncate text-sm font-medium text-strong"
title={repositoryDisplayName(repository)}
>
{repositoryDisplayName(repository)}
</p>
<Badge
variant={
repository.active ? "success" : "outline"
}
>
{repository.active ? "Active" : "Paused"}
</Badge>
</div>
<p
className="mt-1 truncate text-xs text-muted"
title={`${repository.owner}/${repository.repo}`}
>
{repository.owner}/{repository.repo}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full shrink-0 sm:w-auto"
onClick={() => handleLinkRepo(repository.id)}
disabled={isLinking}
>
Link
</Button>
</div>
))}
</div>
)}
</div>
) : null}
</div>
)}
</section>
<ConfirmActionDialog
open={unlinkTarget !== null}
onOpenChange={(open) => {
if (!open && !isUnlinking) {
setUnlinkTarget(null);
setUnlinkError(null);
}
}}
title="Unlink Git Project repository"
description={
unlinkTarget
? `Remove "${repositoryDisplayName(unlinkTarget.repository)}" from this board? Issues from this repository will no longer appear on the board.`
: "Remove this repository from the board?"
}
onConfirm={handleUnlinkRepo}
isConfirming={isUnlinking}
errorMessage={unlinkError}
confirmLabel="Unlink Repository"
confirmingLabel="Unlinking..."
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
cancelLabel="Keep Linked"
/>
</>
);
}