feat: add bulk actions for repository management with selection support

This commit is contained in:
Arunavo Ray
2025-06-17 17:22:38 +05:30
parent 13d4257c4f
commit 1d27bd31d8
2 changed files with 311 additions and 15 deletions

View File

@@ -18,7 +18,7 @@ import {
SelectValue, SelectValue,
} from "../ui/select"; } from "../ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal } from "lucide-react"; import { Search, RefreshCw, FlipHorizontal, RotateCcw, X } from "lucide-react";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror"; import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import { useSSE } from "@/hooks/useSEE"; import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams"; import { useFilterParams } from "@/hooks/useFilterParams";
@@ -46,6 +46,7 @@ export default function Repository() {
owner: "", owner: "",
}); });
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false); const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const [selectedRepoIds, setSelectedRepoIds] = useState<Set<string>>(new Set());
// Read organization filter from URL when component mounts // Read organization filter from URL when component mounts
useEffect(() => { useEffect(() => {
@@ -254,6 +255,143 @@ export default function Repository() {
} }
}; };
// Bulk action handlers
const handleBulkMirror = async () => {
if (selectedRepoIds.size === 0) return;
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const eligibleRepos = selectedRepos.filter(
repo => repo.status === "imported" || repo.status === "failed"
);
if (eligibleRepos.length === 0) {
toast.info("No eligible repositories to mirror in selection");
return;
}
const repoIds = eligibleRepos.map(repo => repo.id as string);
setLoadingRepoIds(prev => {
const newSet = new Set(prev);
repoIds.forEach(id => newSet.add(id));
return newSet;
});
try {
const response = await apiRequest<MirrorRepoResponse>("/job/mirror-repo", {
method: "POST",
data: { userId: user?.id, repositoryIds: repoIds }
});
if (response.success) {
toast.success(`Mirroring started for ${repoIds.length} repositories`);
setRepositories(prevRepos =>
prevRepos.map(repo => {
const updated = response.repositories.find(r => r.id === repo.id);
return updated ? updated : repo;
})
);
setSelectedRepoIds(new Set());
} else {
showErrorToast(response.error || "Error starting mirror jobs", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setLoadingRepoIds(new Set());
}
};
const handleBulkSync = async () => {
if (selectedRepoIds.size === 0) return;
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const eligibleRepos = selectedRepos.filter(
repo => repo.status === "mirrored" || repo.status === "synced"
);
if (eligibleRepos.length === 0) {
toast.info("No eligible repositories to sync in selection");
return;
}
const repoIds = eligibleRepos.map(repo => repo.id as string);
setLoadingRepoIds(prev => {
const newSet = new Set(prev);
repoIds.forEach(id => newSet.add(id));
return newSet;
});
try {
const response = await apiRequest<SyncRepoResponse>("/job/sync-repo", {
method: "POST",
data: { userId: user?.id, repositoryIds: repoIds }
});
if (response.success) {
toast.success(`Syncing started for ${repoIds.length} repositories`);
setRepositories(prevRepos =>
prevRepos.map(repo => {
const updated = response.repositories.find(r => r.id === repo.id);
return updated ? updated : repo;
})
);
setSelectedRepoIds(new Set());
} else {
showErrorToast(response.error || "Error starting sync jobs", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setLoadingRepoIds(new Set());
}
};
const handleBulkRetry = async () => {
if (selectedRepoIds.size === 0) return;
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const eligibleRepos = selectedRepos.filter(repo => repo.status === "failed");
if (eligibleRepos.length === 0) {
toast.info("No failed repositories in selection to retry");
return;
}
const repoIds = eligibleRepos.map(repo => repo.id as string);
setLoadingRepoIds(prev => {
const newSet = new Set(prev);
repoIds.forEach(id => newSet.add(id));
return newSet;
});
try {
const response = await apiRequest<RetryRepoResponse>("/job/retry-repo", {
method: "POST",
data: { userId: user?.id, repositoryIds: repoIds }
});
if (response.success) {
toast.success(`Retrying ${repoIds.length} repositories`);
setRepositories(prevRepos =>
prevRepos.map(repo => {
const updated = response.repositories.find(r => r.id === repo.id);
return updated ? updated : repo;
})
);
setSelectedRepoIds(new Set());
} else {
showErrorToast(response.error || "Error retrying jobs", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setLoadingRepoIds(new Set());
}
};
const handleSyncRepo = async ({ repoId }: { repoId: string }) => { const handleSyncRepo = async ({ repoId }: { repoId: string }) => {
try { try {
if (!user || !user.id) { if (!user || !user.id) {
@@ -392,6 +530,35 @@ export default function Repository() {
) )
).sort(); ).sort();
// Determine what actions are available for selected repositories
const getAvailableActions = () => {
if (selectedRepoIds.size === 0) return [];
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
const statuses = new Set(selectedRepos.map(repo => repo.status));
const actions = [];
// Check if any selected repos can be mirrored
if (selectedRepos.some(repo => repo.status === "imported" || repo.status === "failed")) {
actions.push('mirror');
}
// Check if any selected repos can be synced
if (selectedRepos.some(repo => repo.status === "mirrored" || repo.status === "synced")) {
actions.push('sync');
}
// Check if any selected repos are failed
if (selectedRepos.some(repo => repo.status === "failed")) {
actions.push('retry');
}
return actions;
};
const availableActions = getAvailableActions();
return ( return (
<div className="flex flex-col gap-y-8"> <div className="flex flex-col gap-y-8">
{/* Combine search and actions into a single flex row */} {/* Combine search and actions into a single flex row */}
@@ -459,6 +626,8 @@ export default function Repository() {
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
{/* Context-aware action buttons */}
{selectedRepoIds.size === 0 ? (
<Button <Button
variant="default" variant="default"
onClick={handleMirrorAllRepos} onClick={handleMirrorAllRepos}
@@ -467,6 +636,59 @@ export default function Repository() {
<FlipHorizontal className="h-4 w-4 mr-2" /> <FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All Mirror All
</Button> </Button>
) : (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setSelectedRepoIds(new Set())}
>
<X className="h-4 w-4" />
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="sm"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</div>
)}
</div> </div>
{!isGitHubConfigured ? ( {!isGitHubConfigured ? (
@@ -497,6 +719,8 @@ export default function Repository() {
onSync={handleSyncRepo} onSync={handleSyncRepo}
onRetry={handleRetryRepoAction} onRetry={handleRetryRepoAction}
loadingRepoIds={loadingRepoIds} loadingRepoIds={loadingRepoIds}
selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds}
/> />
)} )}

View File

@@ -9,6 +9,13 @@ import { formatDate, getStatusColor } from "@/lib/utils";
import type { FilterParams } from "@/types/filter"; import type { FilterParams } from "@/types/filter";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { useGiteaConfig } from "@/hooks/useGiteaConfig";
import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface RepositoryTableProps { interface RepositoryTableProps {
repositories: Repository[]; repositories: Repository[];
@@ -20,6 +27,8 @@ interface RepositoryTableProps {
onSync: ({ repoId }: { repoId: string }) => Promise<void>; onSync: ({ repoId }: { repoId: string }) => Promise<void>;
onRetry: ({ repoId }: { repoId: string }) => Promise<void>; onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
loadingRepoIds: Set<string>; loadingRepoIds: Set<string>;
selectedRepoIds: Set<string>;
onSelectionChange: (selectedIds: Set<string>) => void;
} }
export default function RepositoryTable({ export default function RepositoryTable({
@@ -32,6 +41,8 @@ export default function RepositoryTable({
onSync, onSync,
onRetry, onRetry,
loadingRepoIds, loadingRepoIds,
selectedRepoIds,
onSelectionChange,
}: RepositoryTableProps) { }: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null); const tableParentRef = useRef<HTMLDivElement>(null);
const { giteaConfig } = useGiteaConfig(); const { giteaConfig } = useGiteaConfig();
@@ -105,9 +116,36 @@ export default function RepositoryTable({
overscan: 5, overscan: 5,
}); });
// Selection handlers
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allIds = new Set(filteredRepositories.map(repo => repo.id).filter((id): id is string => !!id));
onSelectionChange(allIds);
} else {
onSelectionChange(new Set());
}
};
const handleSelectRepo = (repoId: string, checked: boolean) => {
const newSelection = new Set(selectedRepoIds);
if (checked) {
newSelection.add(repoId);
} else {
newSelection.delete(repoId);
}
onSelectionChange(newSelection);
};
const isAllSelected = filteredRepositories.length > 0 &&
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
return isLoading ? ( return isLoading ? (
<div className="border rounded-md"> <div className="border rounded-md">
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50"> <div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]"> <div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository Repository
</div> </div>
@@ -132,6 +170,9 @@ export default function RepositoryTable({
key={i} key={i}
className="h-[65px] flex items-center justify-between border-b bg-transparent" className="h-[65px] flex items-center justify-between border-b bg-transparent"
> >
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]"> <div className="h-full p-3 text-sm font-medium flex-[2.5]">
<Skeleton className="h-full w-full" /> <Skeleton className="h-full w-full" />
</div> </div>
@@ -187,6 +228,14 @@ export default function RepositoryTable({
<div className="flex flex-col border rounded-md"> <div className="flex flex-col border rounded-md">
{/* table header */} {/* table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50"> <div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onCheckedChange={handleSelectAll}
aria-label="Select all repositories"
/>
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]"> <div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository Repository
</div> </div>
@@ -235,6 +284,15 @@ export default function RepositoryTable({
data-index={virtualRow.index} data-index={virtualRow.index}
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
> >
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
aria-label={`Select ${repo.name}`}
/>
</div>
{/* Repository */} {/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]"> <div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" /> <GitFork className="h-4 w-4 text-muted-foreground" />
@@ -277,12 +335,26 @@ export default function RepositoryTable({
{/* Status */} {/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]"> <div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
<div {repo.status === "failed" && repo.errorMessage ? (
className={`h-2 w-2 rounded-full ${getStatusColor( <TooltipProvider>
repo.status <Tooltip>
)}`} <TooltipTrigger asChild>
/> <div className="flex items-center gap-x-2 cursor-help">
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-sm">{repo.errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<>
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize">{repo.status}</span> <span className="text-sm capitalize">{repo.status}</span>
</>
)}
</div> </div>
{/* Actions */} {/* Actions */}