repositories: migrate table to tanstack

This commit is contained in:
Arunavo Ray
2026-03-15 08:41:50 +05:30
parent cf8c5dd8cb
commit a544b29e6d
3 changed files with 152 additions and 42 deletions

View File

@@ -31,6 +31,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.22", "@tanstack/react-virtual": "^3.13.22",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
@@ -576,8 +577,12 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.22", "", { "dependencies": { "@tanstack/virtual-core": "3.13.22" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.22", "", { "dependencies": { "@tanstack/virtual-core": "3.13.22" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.22", "", {}, "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.22", "", {}, "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g=="],
"@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="], "@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="],

View File

@@ -77,6 +77,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.22", "@tanstack/react-virtual": "^3.13.22",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",

View File

@@ -1,11 +1,19 @@
import { useMemo, useRef } from "react"; import { useMemo, useRef, useState } from "react";
import Fuse from "fuse.js"; import {
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2, X } from "lucide-react"; import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2, X } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si"; import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema"; import type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { formatDate, formatLastSyncTime, getStatusColor } from "@/lib/utils"; import { formatLastSyncTime } 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";
@@ -66,6 +74,7 @@ export default function RepositoryTable({
}: RepositoryTableProps) { }: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null); const tableParentRef = useRef<HTMLDivElement>(null);
const { giteaConfig } = useGiteaConfig(); const { giteaConfig } = useGiteaConfig();
const [sorting, setSorting] = useState<SortingState>([]);
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => { const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
// Call API to update repository destination // Call API to update repository destination
@@ -120,40 +129,90 @@ export default function RepositoryTable({
return `${baseUrl}/${repoPath}`; return `${baseUrl}/${repoPath}`;
}; };
const hasAnyFilter = Object.values(filter).some( const hasAnyFilter = [filter.searchTerm, filter.status, filter.organization, filter.owner].some(
(val) => val?.toString().trim() !== "" (val) => val?.toString().trim() !== ""
); );
const filteredRepositories = useMemo(() => { const columnFilters = useMemo<ColumnFiltersState>(() => {
let result = repositories; const next: ColumnFiltersState = [];
if (filter.status) { if (filter.status) {
result = result.filter((repo) => repo.status === filter.status); next.push({ id: "status", value: filter.status });
} }
if (filter.owner) { if (filter.owner) {
result = result.filter((repo) => repo.owner === filter.owner); next.push({ id: "owner", value: filter.owner });
} }
if (filter.organization) { if (filter.organization) {
result = result.filter( next.push({ id: "organization", value: filter.organization });
(repo) => repo.organization === filter.organization
);
} }
return next;
}, [filter.status, filter.owner, filter.organization]);
if (filter.searchTerm) { const columns = useMemo<ColumnDef<Repository>[]>(
const fuse = new Fuse(result, { () => [
keys: ["name", "fullName", "owner", "organization"], {
threshold: 0.3, id: "fullName",
}); accessorFn: (row) => row.fullName,
result = fuse.search(filter.searchTerm).map((res) => res.item); },
} {
id: "owner",
accessorFn: (row) => row.owner,
filterFn: "equalsString",
},
{
id: "organization",
accessorFn: (row) => row.organization ?? "",
filterFn: "equalsString",
},
{
id: "status",
accessorFn: (row) => row.status,
filterFn: "equalsString",
},
{
id: "lastMirrored",
accessorFn: (row) =>
row.lastMirrored ? new Date(row.lastMirrored).getTime() : 0,
enableGlobalFilter: false,
enableColumnFilter: false,
},
],
[]
);
return result; const table = useReactTable({
}, [repositories, filter]); data: repositories,
columns,
state: {
globalFilter: filter.searchTerm ?? "",
columnFilters,
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const visibleRepositories = table
.getRowModel()
.rows.map((row) => row.original);
const getSortDirection = (columnId: string) =>
table.getColumn(columnId)?.getIsSorted() ?? false;
const getSortLabel = (columnId: string) => {
const direction = getSortDirection(columnId);
if (direction === "asc") return " ↑";
if (direction === "desc") return " ↓";
return "";
};
const toggleSort = (columnId: string) => {
table.getColumn(columnId)?.toggleSorting();
};
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: filteredRepositories.length, count: visibleRepositories.length,
getScrollElement: () => tableParentRef.current, getScrollElement: () => tableParentRef.current,
estimateSize: () => 65, estimateSize: () => 65,
overscan: 5, overscan: 5,
@@ -162,7 +221,11 @@ export default function RepositoryTable({
// Selection handlers // Selection handlers
const handleSelectAll = (checked: boolean) => { const handleSelectAll = (checked: boolean) => {
if (checked) { if (checked) {
const allIds = new Set(filteredRepositories.map(repo => repo.id).filter((id): id is string => !!id)); const allIds = new Set(
visibleRepositories
.map((repo) => repo.id)
.filter((id): id is string => !!id)
);
onSelectionChange(allIds); onSelectionChange(allIds);
} else { } else {
onSelectionChange(new Set()); onSelectionChange(new Set());
@@ -179,8 +242,9 @@ export default function RepositoryTable({
onSelectionChange(newSelection); onSelectionChange(newSelection);
}; };
const isAllSelected = filteredRepositories.length > 0 && const isAllSelected =
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id)); visibleRepositories.length > 0 &&
visibleRepositories.every((repo) => repo.id && selectedRepoIds.has(repo.id));
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected; const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
// Mobile card layout for repository // Mobile card layout for repository
@@ -510,7 +574,7 @@ export default function RepositoryTable({
{hasAnyFilter && ( {hasAnyFilter && (
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Showing {filteredRepositories.length} of {repositories.length} repositories Showing {visibleRepositories.length} of {repositories.length} repositories
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
@@ -529,7 +593,7 @@ export default function RepositoryTable({
</div> </div>
)} )}
{filteredRepositories.length === 0 ? ( {visibleRepositories.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{hasAnyFilter {hasAnyFilter
@@ -550,12 +614,12 @@ export default function RepositoryTable({
className="h-5 w-5" className="h-5 w-5"
/> />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Select All ({filteredRepositories.length}) Select All ({visibleRepositories.length})
</span> </span>
</div> </div>
{/* Repository cards */} {/* Repository cards */}
{filteredRepositories.map((repo) => ( {visibleRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} /> <RepositoryCard key={repo.id} repo={repo} />
))} ))}
</div> </div>
@@ -572,16 +636,55 @@ export default function RepositoryTable({
/> />
</div> </div>
<div className="h-full py-3 text-sm font-medium flex-[2.3]"> <div className="h-full py-3 text-sm font-medium flex-[2.3]">
Repository <button
</div> type="button"
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div> className="hover:text-foreground text-left"
<div className="h-full p-3 text-sm font-medium flex-[1]"> onClick={() => toggleSort("fullName")}
Organization title="Sort by repository"
>
Repository{getSortLabel("fullName")}
</button>
</div> </div>
<div className="h-full p-3 text-sm font-medium flex-[1]"> <div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored <button
type="button"
className="hover:text-foreground text-left"
onClick={() => toggleSort("owner")}
title="Sort by owner"
>
Owner{getSortLabel("owner")}
</button>
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<button
type="button"
className="hover:text-foreground text-left"
onClick={() => toggleSort("organization")}
title="Sort by organization"
>
Organization{getSortLabel("organization")}
</button>
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<button
type="button"
className="hover:text-foreground text-left"
onClick={() => toggleSort("lastMirrored")}
title="Sort by last mirrored"
>
Last Mirrored{getSortLabel("lastMirrored")}
</button>
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<button
type="button"
className="hover:text-foreground text-left"
onClick={() => toggleSort("status")}
title="Sort by status"
>
Status{getSortLabel("status")}
</button>
</div> </div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]"> <div className="h-full p-3 text-sm font-medium flex-[1]">
Actions Actions
</div> </div>
@@ -601,13 +704,14 @@ export default function RepositoryTable({
position: "relative", position: "relative",
}} }}
> >
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => { {rowVirtualizer.getVirtualItems().map((virtualRow) => {
const repo = filteredRepositories[virtualRow.index]; const repo = visibleRepositories[virtualRow.index];
if (!repo) return null;
const isLoading = loadingRepoIds.has(repo.id ?? ""); const isLoading = loadingRepoIds.has(repo.id ?? "");
return ( return (
<div <div
key={index} key={virtualRow.key}
ref={rowVirtualizer.measureElement} ref={rowVirtualizer.measureElement}
style={{ style={{
position: "absolute", position: "absolute",
@@ -784,7 +888,7 @@ export default function RepositoryTable({
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} /> <div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{hasAnyFilter {hasAnyFilter
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories` ? `Showing ${visibleRepositories.length} of ${repositories.length} repositories`
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`} : `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
</span> </span>
</div> </div>