"use client"; import { type OrderByState } from "@langfuse/shared"; import React, { useState, useMemo, useCallback, type CSSProperties, } from "react"; import DocPopup from "@/src/components/layouts/doc-popup"; import { DataTablePagination } from "@/src/components/table/data-table-pagination"; import { type CustomHeights, type RowHeight, getRowHeightTailwindClass, } from "@/src/components/table/data-table-row-height-switch"; import { type LangfuseColumnDef } from "@/src/components/table/types"; import { type ModelTableRow } from "@/src/components/table/use-cases/models"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/src/components/ui/table"; import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture"; import { cn } from "@/src/utils/tailwind"; import { type ColumnOrderState, type ColumnPinningState, type Column, flexRender, getCoreRowModel, getFilteredRowModel, useReactTable, type ColumnFiltersState, type OnChangeFn, type PaginationState, type RowSelectionState, type VisibilityState, type Row, } from "@tanstack/react-table"; import { type DataTablePeekViewProps, TablePeekView, } from "@/src/components/table/peek"; import isEqual from "lodash/isEqual"; import { useRouter } from "next/router"; import { useColumnSizing } from "@/src/components/table/hooks/useColumnSizing"; interface DataTableProps { columns: LangfuseColumnDef[]; data: AsyncTableData; pagination?: { totalCount: number | null; // null if loading onChange: OnChangeFn; state: PaginationState; options?: number[]; hideTotalCount?: boolean; canJumpPages?: boolean; }; rowSelection?: RowSelectionState; setRowSelection?: OnChangeFn; columnVisibility?: VisibilityState; onColumnVisibilityChange?: OnChangeFn; columnOrder?: ColumnOrderState; onColumnOrderChange?: OnChangeFn; orderBy?: OrderByState; setOrderBy?: (s: OrderByState) => void; help?: { description: string; href: string }; rowHeight?: RowHeight; customRowHeights?: CustomHeights; className?: string; shouldRenderGroupHeaders?: boolean; onRowClick?: (row: TData, event?: React.MouseEvent) => void; peekView?: DataTablePeekViewProps; hidePagination?: boolean; tableName: string; getRowClassName?: (row: TData) => string; } export interface AsyncTableData { isLoading: boolean; isError: boolean; data?: T; error?: string; } function insertArrayAfterKey(array: string[], toInsert: Map) { return array.reduce((acc, key) => { if (toInsert.has(key)) { acc.push(...toInsert.get(key)!); } else { acc.push(key); } return acc; }, []); } function isValidCssVariableName({ name, includesHyphens = true, }: { name: string; includesHyphens?: boolean; }) { const regex = includesHyphens ? /^--(?![0-9])([a-zA-Z][a-zA-Z0-9-_]*)$/ : /^(?![0-9])([a-zA-Z][a-zA-Z0-9-_]*)$/; return regex.test(name); } // These are the important styles to make sticky column pinning work! const getCommonPinningStyles = ( column: Column, ): CSSProperties => { const isPinned = column.getIsPinned(); return { left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, position: isPinned ? "sticky" : "relative", width: column.getSize(), zIndex: isPinned ? 10 : 0, backgroundColor: isPinned ? "hsl(var(--background))" : undefined, }; }; // Get additional CSS classes for pinned columns const getPinningClasses = (column: Column): string => { const isPinned = column.getIsPinned(); const isLastLeftPinnedColumn = isPinned === "left" && column.getIsLastColumn("left"); const isFirstRightPinnedColumn = isPinned === "right" && column.getIsFirstColumn("right"); return cn( isLastLeftPinnedColumn && "border-r border-border", isFirstRightPinnedColumn && "border-l border-border", ); }; export function DataTable({ columns, data, pagination, rowSelection, setRowSelection, columnVisibility, onColumnVisibilityChange, columnOrder, onColumnOrderChange, help, orderBy, setOrderBy, rowHeight, customRowHeights, className, shouldRenderGroupHeaders = false, onRowClick, peekView, hidePagination = false, tableName, getRowClassName, }: DataTableProps) { const [columnFilters, setColumnFilters] = useState([]); const rowheighttw = getRowHeightTailwindClass(rowHeight, customRowHeights); const capture = usePostHogClientCapture(); const flattedColumnsByGroup = useMemo(() => { const flatColumnsByGroup = new Map(); columns.forEach((col) => { if (col.columns && Boolean(col.columns.length)) { const children = col.columns.map((child) => child.accessorKey); flatColumnsByGroup.set(col.accessorKey, children); } }); return flatColumnsByGroup; }, [columns]); const { columnSizing, setColumnSizing } = useColumnSizing(tableName); // Infer column pinning state from column properties const columnPinning = useMemo( () => ({ left: columns .filter((col) => col.isPinnedLeft) .map((col) => col.id || col.accessorKey), right: [], }), [columns], ); const table = useReactTable({ data: data.data ?? [], columns, onColumnFiltersChange: setColumnFilters, onColumnOrderChange: onColumnOrderChange, getFilteredRowModel: getFilteredRowModel(), getCoreRowModel: getCoreRowModel(), manualPagination: pagination !== undefined, pageCount: pagination?.totalCount === null || pagination?.state.pageSize === undefined ? -1 : Math.ceil( Number(pagination?.totalCount) / pagination?.state.pageSize, ), onPaginationChange: pagination?.onChange, onRowSelectionChange: setRowSelection, onColumnVisibilityChange: onColumnVisibilityChange, getRowId: (row, index) => { if ("id" in row && typeof row.id === "string") { return row.id; } else { return index.toString(); } }, state: { columnFilters, pagination: pagination?.state, columnVisibility, columnOrder: columnOrder ? insertArrayAfterKey(columnOrder, flattedColumnsByGroup) : undefined, rowSelection, columnSizing, columnPinning, }, onColumnSizingChange: setColumnSizing, manualFiltering: true, defaultColumn: { minSize: 20, size: 150, maxSize: Number.MAX_SAFE_INTEGER, }, columnResizeMode: "onChange", autoResetPageIndex: false, }); const handleOnRowClick = useCallback( (row: TData, event?: React.MouseEvent) => { // Call the table-specific onRowClick first (for modifier key handling) onRowClick?.(row, event); // If the table handler didn't prevent default, handle peek view if (peekView && !event?.defaultPrevented) { const rowId = "id" in row && typeof row.id === "string" ? row.id : undefined; peekView.openPeek?.(rowId, row); } }, [onRowClick, peekView], ); const hasRowClickAction = !!onRowClick || !!peekView?.openPeek; // memo column sizes for performance // https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance const columnSizeVars = useMemo(() => { const headers = table.getFlatHeaders(); const colSizes: { [key: string]: number } = {}; for (let i = 0; i < headers.length; i++) { const header = headers[i]!; colSizes[`--header-${header.id}-size`] = header.getSize(); colSizes[`--col-${header.column.id}-size`] = header.column.getSize(); } return colSizes; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ // eslint-disable-next-line react-hooks/exhaustive-deps table.getState().columnSizingInfo, // eslint-disable-next-line react-hooks/exhaustive-deps table.getState().columnSizing, // eslint-disable-next-line react-hooks/exhaustive-deps table.getFlatHeaders(), columnVisibility, ]); const tableHeaders = shouldRenderGroupHeaders ? table.getHeaderGroups() : [table.getHeaderGroups().slice(-1)[0]]; return ( <>
{tableHeaders.map((headerGroup) => ( {headerGroup.headers.map((header) => { const columnDef = header.column .columnDef as LangfuseColumnDef; const sortingEnabled = columnDef.enableSorting; // if the header id does not translate to a valid css variable name, default to 150px as width // may only happen for dynamic columns, as column names are user defined const width = isValidCssVariableName({ name: header.id, includesHyphens: false, }) ? `calc(var(--header-${header.id}-size) * 1px)` : 150; return header.column.getIsVisible() ? ( { event.preventDefault(); if (!setOrderBy || !columnDef.id || !sortingEnabled) { return; } if (orderBy?.column === columnDef.id) { if (orderBy.order === "DESC") { capture("table:column_sorting_header_click", { column: columnDef.id, order: "ASC", }); setOrderBy({ column: columnDef.id, order: "ASC", }); } else { capture("table:column_sorting_header_click", { column: columnDef.id, order: "Disabled", }); setOrderBy(null); } } else { capture("table:column_sorting_header_click", { column: columnDef.id, order: "DESC", }); setOrderBy({ column: columnDef.id, order: "DESC", }); } }} > {header.isPlaceholder ? null : (
{flexRender( header.column.columnDef.header, header.getContext(), )} {columnDef.headerTooltip && ( )} {orderBy?.column === columnDef.id ? renderOrderingIndicator(orderBy) : null}
{ e.preventDefault(); e.stopPropagation(); }} onDoubleClick={() => header.column.resetSize()} onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()} className={cn( "absolute right-0 top-0 h-full w-1.5 cursor-col-resize touch-none select-none bg-secondary opacity-0 group-hover:opacity-100", header.column.getIsResizing() && "bg-primary-accent opacity-100", )} />
)} ) : null; })} ))} {table.getState().columnSizingInfo.isResizingColumn || !!peekView ? ( ) : ( )}
{peekView && } {!hidePagination && pagination !== undefined ? (
) : null} ); } function renderOrderingIndicator(orderBy?: OrderByState) { if (!orderBy) return null; if (orderBy.order === "ASC") return ; else return ( ); } interface TableBodyComponentProps { table: ReturnType>; rowheighttw?: string; rowHeight?: RowHeight; columns: LangfuseColumnDef[]; data: AsyncTableData; help?: { description: string; href: string }; onRowClick?: (row: TData, event?: React.MouseEvent) => void; getRowClassName?: (row: TData) => string; tableSnapshot?: { tableDataUpdatedAt?: number; columnVisibility?: VisibilityState; columnOrder?: ColumnOrderState; rowSelection?: RowSelectionState; }; } function TableRowComponent({ row, onRowClick, getRowClassName, children, }: { row: Row; onRowClick?: (row: TData, event?: React.MouseEvent) => void; getRowClassName?: (row: TData) => string; children: React.ReactNode; }) { const router = useRouter(); const selectedRowId = router.query.peek as string | undefined; return ( onRowClick?.(row.original, e)} onKeyDown={(e) => { if (e.key === "Enter") { onRowClick?.(row.original); } }} className={cn( "hover:bg-accent", !!onRowClick ? "cursor-pointer" : "cursor-default", selectedRowId && selectedRowId === row.id ? "bg-accent" : undefined, getRowClassName?.(row.original), )} > {children} ); } function TableBodyComponent({ table, rowheighttw, rowHeight, columns, data, help, onRowClick, getRowClassName, }: TableBodyComponentProps) { return ( {data.isLoading || !data.data ? ( Loading... ) : table.getRowModel().rows.length ? ( table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => { const cellValue = cell.getValue(); const isStringCell = typeof cellValue === "string"; const isSmallRowHeight = (rowHeight ?? "s") === "s"; return (
{isStringCell && isSmallRowHeight ? (
{flexRender( cell.column.columnDef.cell, cell.getContext(), )}
) : isStringCell && !isSmallRowHeight ? (
{flexRender( cell.column.columnDef.cell, cell.getContext(), )}
) : ( flexRender(cell.column.columnDef.cell, cell.getContext()) )}
); })}
)) ) : (
No results.{" "} {help && ( )}
)}
); } // Optimize table rendering performance by memoizing the table body // This is critical for two high-frequency re-render scenarios: // 1. During column resizing: When users drag column headers, it can trigger // many state updates that would otherwise cause the entire table to re-render. // 2. When using peek views: URL/state changes from peek view navigation would // otherwise cause unnecessary table re-renders. // // We need to ensure the table re-renders when: // - The actual data changes (including metrics loaded asynchronously and pagination state) // - The loading state changes // - The new column widths are computed // - The row height changes // - The number of visible cells changes // - The column order changes // // See: https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance const MemoizedTableBody = React.memo(TableBodyComponent, (prev, next) => { if (!prev.tableSnapshot || !next.tableSnapshot) return !prev.tableSnapshot && !next.tableSnapshot; // Check reference equality first (faster) if ( prev.tableSnapshot.tableDataUpdatedAt !== next.tableSnapshot.tableDataUpdatedAt ) { return false; } if (prev.table.options.data !== next.table.options.data) return false; if (prev.data.isLoading !== next.data.isLoading) return false; if (prev.rowheighttw !== next.rowheighttw) return false; if (prev.rowHeight !== next.rowHeight) return false; // Then do more expensive deep equality checks if ( !isEqual(prev.tableSnapshot.rowSelection, next.tableSnapshot.rowSelection) ) return false; if ( !isEqual( prev.tableSnapshot.columnVisibility, next.tableSnapshot.columnVisibility, ) ) return false; if (!isEqual(prev.tableSnapshot.columnOrder, next.tableSnapshot.columnOrder)) return false; // If all checks pass, components are equal return true; }) as typeof TableBodyComponent;