/** * @fileoverview PivotTable Chart Component * * A configurable pivot table widget component that displays data in a tabular format * with support for multiple dimensions (currently up to 2), metrics as columns, * subtotals, and grand totals. * * Features: * - Dynamic dimension support (0-N dimensions, currently limited to 2) * - Proper indentation for nested dimension levels * - Subtotal and grand total calculations * - Responsive design within dashboard grid * - Consistent styling with Langfuse design system * - Row limiting to prevent performance issues * - Interactive sorting with hierarchical behavior * * Usage: * Used as part of the dashboard widget system to display tabular data * visualizations with grouping and aggregation capabilities. */ import React, { useMemo, useCallback, useState, useEffect } from "react"; import { cn } from "@/src/utils/tailwind"; import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell, } from "@/src/components/ui/table"; import { transformToPivotTable, extractDimensionValues, extractMetricValues, sortPivotTableRows, getNextSortState, type PivotTableRow, type PivotTableConfig, type DatabaseRow, DEFAULT_ROW_LIMIT, } from "@/src/features/widgets/utils/pivot-table-utils"; import { type ChartProps } from "@/src/features/widgets/chart-library/chart-props"; import { numberFormatter } from "@/src/utils/numbers"; import { formatMetricName } from "@/src/features/widgets/utils"; import { type OrderByState } from "@langfuse/shared"; import { Loader2 } from "lucide-react"; /** * Props interface for the PivotTable component * Uses standard chart data structure with pivot-specific configuration */ export interface PivotTableProps { /** Array of data points from the chart query */ data: ChartProps["data"]; /** Pivot table specific configuration */ config?: PivotTableConfig; /** Chart configuration from shadcn/ui (for consistency with other charts) */ chartConfig?: ChartProps["config"]; /** Accessibility layer flag */ accessibilityLayer?: boolean; /** Current sort state */ sortState?: OrderByState; /** Callback for sort state changes */ onSortChange?: (sortState: OrderByState | null) => void; /** Loading state for when data is being refreshed */ isLoading?: boolean; } /** * Non-sortable column header component * Simple header without sorting functionality */ const StaticHeader: React.FC<{ label: string; className?: string; }> = ({ label, className }) => { return (
{label}
); }; /** * Sortable column header component * Handles click events and visual indicators for sorting */ const SortableHeader: React.FC<{ column: string; label: string; sortState?: OrderByState; onSort: (column: string) => void; className?: string; rightAlign?: boolean; }> = ({ column, label, sortState, onSort, className, rightAlign = false }) => { const isSorted = sortState?.column === column; const sortDirection = isSorted ? sortState.order : null; const handleClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); onSort(column); }, [column, onSort], ); return (
{label} {isSorted && ( {sortDirection === "ASC" ? "▲" : "▼"} )} {/* Visual indicator that appears on hover - matches traces table behavior */}
); }; /** * Individual row component for the pivot table * Handles styling, indentation, and content display for each row type */ const PivotTableRowComponent: React.FC<{ row: PivotTableRow; metrics: string[]; }> = ({ row, metrics }) => { return ( {/* Dimension column with indentation and styling */} 2 ? `${row.level * 1.5 + 0.5}rem` : undefined, }} > {row.label} {/* Metric columns */} {metrics.map((metric) => ( {formatMetricValue(row.values[metric])} ))} ); }; /** * Formats metric values for display in the table * Handles numbers and strings with appropriate formatting * * @param value - The metric value to format * @returns Formatted string for display */ function formatMetricValue(value: number | string): string { if (typeof value === "string") { return value; } return numberFormatter(value, 2).replace(/\.00$/, ""); } /** * Formats metric names for column headers * * @param metricName - The metric field name * @returns Formatted column header */ function formatColumnHeader(metricName: string): string { return formatMetricName(metricName); } /** * Main PivotTable Component * * Transforms flat data into a pivot table structure and renders it with * proper styling, indentation, and responsive behavior. * * @param data - Array of data points from the chart query * @param config - Pivot table configuration including dimensions and metrics * @param sortState - Current sort state * @param onSortChange - Callback for sort state changes */ export const PivotTable: React.FC = ({ data, config, sortState, onSortChange, isLoading = false, }) => { // Transform chart data into pivot table structure const pivotTableRows = useMemo(() => { if (!data || data.length === 0) { return []; } // Extract configuration with defaults const pivotConfig: PivotTableConfig = { dimensions: config?.dimensions ?? [], metrics: config?.metrics ?? ["metric"], // Default to 'metric' field from DataPoint rowLimit: config?.rowLimit ?? DEFAULT_ROW_LIMIT, defaultSort: config?.defaultSort, }; // Transform DataPoint[] to DatabaseRow[] format using utility functions const databaseRows: DatabaseRow[] = data.map((point) => { // Cast the point to any to access dynamic fields from the query const rowData = point as any; // Create a database row with all fields from the original data const row: DatabaseRow = { ...rowData }; // Use utility functions to ensure proper extraction and parsing const dimensionValues = extractDimensionValues( row, pivotConfig.dimensions, ); const metricValues = extractMetricValues(row, pivotConfig.metrics); // Combine dimension and metric values into the final row const result: DatabaseRow = { ...dimensionValues, ...metricValues, }; // Include time dimension if present if (point.time_dimension !== undefined) { result.time_dimension = point.time_dimension; } // Include legacy 'metric' field for backward compatibility if (point.metric !== undefined) { if (typeof point.metric === "number") { result.metric = point.metric; } else if (Array.isArray(point.metric)) { result.metric = point.metric .flat() .reduce((sum, val) => sum + val, 0); } } return result; }); try { return transformToPivotTable(databaseRows, pivotConfig); } catch (error) { console.error("Error transforming data to pivot table:", error); return []; } }, [data, config]); // Apply sorting to pivot table rows const sortedRows = useMemo(() => { // Use user sort state if available, otherwise fall back to default sort if (!sortState || !sortState.column) { return pivotTableRows; } try { return sortPivotTableRows(pivotTableRows, sortState); } catch (error) { console.error("Error sorting pivot table rows:", error); return pivotTableRows; } }, [pivotTableRows, sortState]); // Extract metrics from configuration or fallback to default const metrics = useMemo(() => { return config?.metrics ?? ["metric"]; }, [config?.metrics]); // Handle sort click events - simple cycling const handleSort = useCallback( (column: string) => { if (!onSortChange) return; const nextSort = getNextSortState( config?.defaultSort || null, sortState || null, column, ); onSortChange(nextSort); }, [sortState, onSortChange, config?.defaultSort], ); // Track the last known defaultSort to detect changes const [lastDefaultSort, setLastDefaultSort] = useState(config?.defaultSort); // Reset to defaultSort when it changes useEffect(() => { const currentDefaultSort = config?.defaultSort; // If defaultSort changed, reset the sorting if (currentDefaultSort !== lastDefaultSort) { setLastDefaultSort(currentDefaultSort); // Reset to the new default sort if (onSortChange) { onSortChange(currentDefaultSort || null); } } }, [config?.defaultSort, onSortChange, lastDefaultSort]); // Handle empty data state if (!data || data.length === 0) { return (

No data available

); } // Handle transformation errors if (pivotTableRows.length === 0) { return (

Unable to process data for pivot table

); } return (
{isLoading && (
Refreshing data...
)} {/* Dimension column header */} 0 ? config.dimensions.map(formatColumnHeader).join(" / ") // Show all dimensions : "Dimension" } className="p-2 text-left font-medium first:pl-2" /> {/* Metric column headers */} {metrics.map((metric) => ( ))} {sortedRows.map((row) => ( ))}
); }; export default PivotTable;