import { CSSProperties, forwardRef, useEffect, useMemo, useRef, useState, } from 'react' import { X, Plus } from 'lucide-react' import { ButtonSubType } from '../../Button/types' import Modal from '../../Modal/Modal' import Block from '../../Primitives/Block/Block' import PrimitiveText from '../../Primitives/PrimitiveText/PrimitiveText' import Button from '../../Button/Button' import { ButtonSize, ButtonType } from '../../Button/types' import SingleSelect from '../../SingleSelect/SingleSelect' import { SelectMenuAlignment, SelectMenuSize, SelectMenuVariant, } from '../../SingleSelect/types' import { Checkbox } from '../../Checkbox/Checkbox' import { CheckboxSize } from '../../Checkbox/types' import { TruncatedTextWithTooltipV2 } from '../../common/TruncatedTextWithTooltipV2' import { TooltipV2Align, TooltipV2Side } from '../../TooltipV2/tooltipV2.types' import { ColumnDefinition, PivotAggregationType, ColumnType } from '../types' import { TableTokenType } from '../dataTable.tokens' import { downloadCSV } from '../utils' import { useResponsiveTokens } from '../../../hooks/useResponsiveTokens' import { useTheme } from '../../../context/ThemeContext' import { FOUNDATION_THEME } from '../../../tokens' import { PivotTableModalProps, PivotFieldConfig } from './types' import { buildPivotPreview, getPivotFieldOptions, getSupportedAggregationsForField, } from './utils' import { NoScrollbar } from './pivotModal.styled' import { getPivotModalStyleTokens } from './pivotModalStyleTokens' // eslint-disable-next-line import-x/no-cycle -- intentional recursion: pivot preview renders a nested DataTable import PivotPreviewPanel from './PivotPreviewPanel' type PivotSectionKey = 'rows' | 'columns' | 'values' const PivotTableModal = forwardRef< HTMLDivElement, PivotTableModalProps> >( ( { isOpen, onClose, columns, data, title = 'Create Pivot Table', description: subtitle, initialConfig, onConfigChange, onExport, availableAggregations, trigger, onTriggerClick, }, ref ) => { const tableToken = useResponsiveTokens('TABLE') as TableTokenType const { theme } = useTheme() const pivot = useMemo( () => getPivotModalStyleTokens(FOUNDATION_THEME, tableToken, theme), [tableToken, theme] ) const removeButtonStyle = useMemo( (): CSSProperties => ({ minWidth: pivot.removeButton.minWidth, padding: pivot.removeButton.padding, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s ease', }), [pivot] ) // State for pivot configuration const [rowFields, setRowFields] = useState( () => initialConfig?.rows?.map((field) => ({ field: String(field), showTotal: false, })) || [] ) const [columnFields, setColumnFields] = useState( () => initialConfig?.columns?.map((field) => ({ field: String(field), showTotal: false, })) || [] ) const [valueConfigs, setValueConfigs] = useState< Array<{ field: keyof Record aggregation: PivotAggregationType }> >( () => (initialConfig?.values as Array<{ field: keyof Record aggregation: PivotAggregationType }>) || [] ) const [addRowSelection, setAddRowSelection] = useState('') const [addColumnSelection, setAddColumnSelection] = useState('') const [addValueSelection, setAddValueSelection] = useState('') // Compute field options from columns const fieldOptions = useMemo( () => getPivotFieldOptions(columns), [columns] ) const columnTypeByField = useMemo( () => new Map( columns.map((column) => [String(column.field), column.type]) ), [columns] ) const allowedAggregations = useMemo( () => availableAggregations || Object.values(PivotAggregationType), [availableAggregations] ) const supportedAggregationsByField = useMemo(() => { const map = new Map() fieldOptions.forEach((option) => { const supported = getSupportedAggregationsForField( data, option.key as keyof Record, allowedAggregations, [ColumnType.NUMBER, ColumnType.SLIDER].includes( columnTypeByField.get(option.key) as ColumnType ) ) map.set(option.key, supported) }) return map }, [fieldOptions, data, allowedAggregations, columnTypeByField]) const getSupportedAggregations = (field: string) => { return supportedAggregationsByField.get(field) || [] } const getDefaultAggregationForField = (field: string) => { const supported = getSupportedAggregations(field) if (supported.includes(PivotAggregationType.SUM)) { return PivotAggregationType.SUM } return supported[0] } // Add field handlers (Sheets-style) const addRowField = (field: string) => { if (!field || rowFields.some((f) => f.field === field)) return // mutual exclusivity: move from Columns -> Rows setColumnFields((prev) => prev.filter((f) => f.field !== field)) setRowFields((prev) => [...prev, { field, showTotal: false }]) } const removeRowField = (field: string) => { setRowFields((prev) => prev.filter((f) => f.field !== field)) } const addColumnField = (field: string) => { if (!field || columnFields.some((f) => f.field === field)) return // mutual exclusivity: move from Rows -> Columns setRowFields((prev) => prev.filter((f) => f.field !== field)) setColumnFields((prev) => [...prev, { field, showTotal: false }]) } const removeColumnField = (field: string) => { setColumnFields((prev) => prev.filter((f) => f.field !== field)) } const updateRowFieldTotal = (field: string, showTotal: boolean) => { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console console.debug('[PivotUI] updateRowFieldTotal', { field, showTotal, }) } setRowFields((prev) => prev.map((f) => (f.field === field ? { ...f, showTotal } : f)) ) } const updateColumnFieldTotal = (field: string, showTotal: boolean) => { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console console.debug('[PivotUI] updateColumnFieldTotal', { field, showTotal, }) } setColumnFields((prev) => prev.map((f) => (f.field === field ? { ...f, showTotal } : f)) ) } const addValueField = (field: string) => { if (!field) return const aggregation = getDefaultAggregationForField(field) if (!aggregation) return // allow duplicates by (field, aggregation), but prevent exact duplicates if ( valueConfigs.some( (v) => String(v.field) === field && v.aggregation === aggregation ) ) { return } setValueConfigs((prev) => [ ...prev, { field: field as keyof Record, aggregation, }, ]) } const removeValueField = (index: number) => { setValueConfigs((prev) => prev.filter((_, i) => i !== index)) } const updateValueAggregation = ( index: number, aggregation: PivotAggregationType ) => { setValueConfigs((prev) => { const target = prev[index] if (!target) return prev const isDuplicate = prev.some( (v, i) => i !== index && String(v.field) === String(target.field) && v.aggregation === aggregation ) if (isDuplicate) return prev const newConfigs = [...prev] newConfigs[index].aggregation = aggregation return newConfigs }) } const updateRowField = (index: number, nextField: string) => { setRowFields((prev) => { const usedInOtherSections = columnFields.some( (field) => field.field === nextField ) if ( !nextField || usedInOtherSections || prev.some((f, i) => i !== index && f.field === nextField) ) { return prev } return prev.map((field, i) => i === index ? { ...field, field: nextField } : field ) }) } const updateColumnField = (index: number, nextField: string) => { setColumnFields((prev) => { const usedInOtherSections = rowFields.some( (field) => field.field === nextField ) if ( !nextField || usedInOtherSections || prev.some((f, i) => i !== index && f.field === nextField) ) { return prev } return prev.map((field, i) => i === index ? { ...field, field: nextField } : field ) }) } const updateValueField = (index: number, nextField: string) => { setValueConfigs((prev) => { if ( !nextField || prev.some( (config, i) => i !== index && String(config.field) === nextField ) ) { return prev } const defaultAggregation = getDefaultAggregationForField(nextField) if (!defaultAggregation) return prev return prev.map((config, i) => { if (i !== index) return config const supported = getSupportedAggregations(nextField) let resolvedAggregation: PivotAggregationType if (supported.includes(PivotAggregationType.SUM)) { resolvedAggregation = PivotAggregationType.SUM } else { resolvedAggregation = supported.includes( config.aggregation ) ? config.aggregation : defaultAggregation } // prevent creating an exact duplicate (field + aggregation) pair if ( prev.some( (other, j) => j !== index && String(other.field) === nextField && other.aggregation === resolvedAggregation ) ) { return config } return { ...config, field: nextField as keyof Record, aggregation: resolvedAggregation, } }) }) } const lastEmittedConfigRef = useRef('') useEffect(() => { if (!isOpen) { lastEmittedConfigRef.current = '' return } const config = { rows: rowFields.map((f) => f.field), columns: columnFields.map((f) => f.field), values: valueConfigs, } const configString = JSON.stringify(config) if (lastEmittedConfigRef.current !== configString) { lastEmittedConfigRef.current = configString onConfigChange?.(config) } }, [isOpen, rowFields, columnFields, valueConfigs, onConfigChange]) const exportPivotTable = () => { const exportColumns = effectivePreviewColumns const exportRows = effectivePreviewRows if (onExport) { onExport({ rows: rowFields.map((f) => f.field), columns: columnFields.map((f) => f.field), values: valueConfigs, }) return } if (!exportRows || exportRows.length === 0) return const headers = exportColumns?.map((col) => `"${col.label}"`) || [] const rows = exportRows.map((row) => exportColumns?.map( (col) => `"${String(row[col.key] ?? '').replace(/"/g, '""')}"` ) ) const csvContent = [ headers.join(','), ...rows.map((r) => r?.join(',') || ''), ].join('\n') downloadCSV(csvContent, 'pivot-table.csv') } const effectivePreview = useMemo(() => { return buildPivotPreview( data as Record[], rowFields.map((f) => ({ field: f.field as keyof Record, showTotal: f.showTotal, })), columnFields.map((f) => ({ field: f.field as keyof Record, showTotal: f.showTotal, })), valueConfigs, Object.fromEntries( fieldOptions.map((opt) => [opt.key, opt.label]) ) ) }, [data, rowFields, columnFields, valueConfigs, fieldOptions]) const effectivePreviewColumns = effectivePreview.columns const effectivePreviewRows = effectivePreview.rows const previewTableColumns: ColumnDefinition>[] = useMemo( () => effectivePreviewColumns.map((col) => ({ field: col.key, header: col.label, type: ColumnType.TEXT, })), [effectivePreviewColumns] ) const rowAddOptions = useMemo(() => { const blocked = new Set([ ...rowFields.map((f) => f.field), ...columnFields.map((f) => f.field), ]) return fieldOptions.filter((opt) => !blocked.has(opt.key)) }, [fieldOptions, rowFields, columnFields]) const columnAddOptions = useMemo(() => { const blocked = new Set([ ...rowFields.map((f) => f.field), ...columnFields.map((f) => f.field), ]) return fieldOptions.filter((opt) => !blocked.has(opt.key)) }, [fieldOptions, rowFields, columnFields]) const valueAddOptions = useMemo(() => fieldOptions, [fieldOptions]) const fieldLabelByKey = useMemo(() => { return new Map(fieldOptions.map((opt) => [opt.key, opt.label])) }, [fieldOptions]) const getFieldLabel = (fieldKey: string) => { return fieldLabelByKey.get(fieldKey) || fieldKey } // Render field config card const renderFieldConfig = ( field: PivotFieldConfig, onRemove: () => void, onFieldChange?: (nextField: string) => void, showAggSelector?: boolean, aggValue?: PivotAggregationType, onAggChange?: (agg: PivotAggregationType) => void, onShowTotalChange?: (checked: boolean) => void, showTotalToggle = true ) => ( ({ label: option.label, value: option.key, })), }, ]} selected={field.field} onSelect={(value) => onFieldChange?.(String(value))} variant={SelectMenuVariant.CONTAINER} size={SelectMenuSize.SMALL} fullWidth /> {showAggSelector && aggValue && onAggChange && ( ({ label: item.toUpperCase(), value: item, })), }, ]} selected={aggValue} onSelect={(value) => onAggChange(value as PivotAggregationType) } variant={SelectMenuVariant.CONTAINER} size={SelectMenuSize.SMALL} alignment={SelectMenuAlignment.END} fullWidth /> )} {showTotalToggle && ( onShowTotalChange?.( checked === true || checked === 'indeterminate' ) } > Show Total )} ) const renderSection = ( title: string, sectionKey: PivotSectionKey, fields: PivotFieldConfig[], onRemove: (field: string) => void, showAgg?: boolean, aggs?: PivotAggregationType[], onAggChange?: (index: number, agg: PivotAggregationType) => void, onShowTotalChange?: (field: string, showTotal: boolean) => void ) => ( 0 ? FOUNDATION_THEME.unit[10] : '0', }} > {title} ({ label: o.label, value: o.key, })) : sectionKey === 'columns' ? columnAddOptions.map((o) => ({ label: o.label, value: o.key, })) : valueAddOptions.map((o) => ({ label: o.label, value: o.key, })), }, ]} selected={ sectionKey === 'rows' ? addRowSelection : sectionKey === 'columns' ? addColumnSelection : addValueSelection } onSelect={(value) => { const next = String(value) if (sectionKey === 'rows') { setAddRowSelection(next) addRowField(next) setAddRowSelection('') return } if (sectionKey === 'columns') { setAddColumnSelection(next) addColumnField(next) setAddColumnSelection('') return } setAddValueSelection(next) addValueField(next) setAddValueSelection('') }} variant={SelectMenuVariant.NO_CONTAINER} size={SelectMenuSize.SMALL} alignment={SelectMenuAlignment.END} allowDeselect={false} customTrigger={