394 lines
14 KiB
TypeScript
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"
|
|
/>
|
|
</>
|
|
);
|
|
}
|