sourcebot/packages/web/src/app/[domain]/settings/connections/components/connectionsTable.tsx

295 lines
12 KiB
TypeScript

"use client"
import { DisplayDate } from "@/app/[domain]/components/DisplayDate"
import { NotificationDot } from "@/app/[domain]/components/notificationDot"
import { useToast } from "@/components/hooks/use-toast"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
import { getCodeHostIcon } from "@/lib/utils"
import { ConnectionType } from "@sourcebot/db"
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { cva } from "class-variance-authority"
import { ArrowUpDown, RefreshCwIcon } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useMemo, useState } from "react"
export type Connection = {
id: number
name: string
syncedAt: Date | null
connectionType: ConnectionType
latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null
isFirstTimeSync: boolean
}
const statusBadgeVariants = cva("", {
variants: {
status: {
PENDING: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
IN_PROGRESS: "bg-primary text-primary-foreground hover:bg-primary/90",
COMPLETED: "bg-green-600 text-white hover:bg-green-700",
FAILED: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
},
})
const getStatusBadge = (status: Connection["latestJobStatus"]) => {
if (!status) {
return "-";
}
const labels = {
PENDING: "Pending",
IN_PROGRESS: "In Progress",
COMPLETED: "Completed",
FAILED: "Failed",
}
return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
}
export const columns: ColumnDef<Connection>[] = [
{
accessorKey: "name",
size: 400,
header: ({ column }) => {
return (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const connection = row.original;
const codeHostIcon = getCodeHostIcon(connection.connectionType);
return (
<div className="flex flex-row gap-2 items-center">
<Image
src={codeHostIcon.src}
alt={`${connection.connectionType} logo`}
className={codeHostIcon.className}
width={20}
height={20}
/>
<Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections/${connection.id}`} className="font-medium hover:underline">
{connection.name}
</Link>
{connection.isFirstTimeSync && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<NotificationDot className="ml-1.5" />
</span>
</TooltipTrigger>
<TooltipContent>
<span>This is the first time Sourcebot is syncing this connection. It may take a few minutes to complete.</span>
</TooltipContent>
</Tooltip>
)}
</div>
)
},
},
{
accessorKey: "latestJobStatus",
size: 150,
header: "Lastest status",
cell: ({ row }) => getStatusBadge(row.getValue("latestJobStatus")),
},
{
accessorKey: "syncedAt",
size: 200,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Last synced
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const syncedAt = row.getValue("syncedAt") as Date | null;
if (!syncedAt) {
return "-";
}
return (
<DisplayDate date={syncedAt} className="ml-3" />
)
}
},
]
export const ConnectionsTable = ({ data }: { data: Connection[] }) => {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const router = useRouter();
const { toast } = useToast();
const {
numCompleted,
numInProgress,
numPending,
numFailed,
numNoJobs,
} = useMemo(() => {
return {
numCompleted: data.filter((connection) => connection.latestJobStatus === "COMPLETED").length,
numInProgress: data.filter((connection) => connection.latestJobStatus === "IN_PROGRESS").length,
numPending: data.filter((connection) => connection.latestJobStatus === "PENDING").length,
numFailed: data.filter((connection) => connection.latestJobStatus === "FAILED").length,
numNoJobs: data.filter((connection) => connection.latestJobStatus === null).length,
}
}, [data]);
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
columnResizeMode: 'onChange',
enableColumnResizing: false,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
})
return (
<div className="w-full">
<div className="flex items-center gap-4 py-4">
<Input
placeholder="Filter connections..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
className="max-w-sm"
/>
<Select
value={(table.getColumn("latestJobStatus")?.getFilterValue() as string) ?? "all"}
onValueChange={(value) => {
table.getColumn("latestJobStatus")?.setFilterValue(value === "all" ? "" : value)
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Filter by status</SelectItem>
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem>
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem>
<SelectItem value="PENDING">Pending ({numPending})</SelectItem>
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem>
<SelectItem value="null">No status ({numNoJobs})</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
className="ml-auto"
onClick={() => {
router.refresh();
toast({
description: "Page refreshed",
});
}}
>
<RefreshCwIcon className="w-3 h-3" />
Refresh
</Button>
</div>
<div className="rounded-md border">
<Table style={{ width: '100%' }}>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{ width: `${header.getSize()}px` }}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ width: `${cell.column.getSize()}px` }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} {data.length > 1 ? 'connections' : 'connection'} total
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</Button>
</div>
</div>
</div>
)
}