import type { AggregationFunction, CalculatedField, FieldStats, PivotValueField } from '@smallwebco/tinypivot-core' import { AGGREGATION_OPTIONS, getAggregationSymbol } from '@smallwebco/tinypivot-core' /** * Pivot Table Configuration Panel for React * Draggable fields with aggregation selection */ import React, { useCallback, useMemo, useState } from 'react' import { useLicense } from '../hooks/useLicense' import { CalculatedFieldModal } from './CalculatedFieldModal' // Check if an aggregation requires Pro license (everything except sum) function aggregationRequiresPro(agg: AggregationFunction): boolean { return agg !== 'sum' } // Extended field stats for calculated fields interface ExtendedFieldStats extends FieldStats { isCalculated?: boolean calcId?: string calcName?: string calcFormula?: string } interface PivotConfigProps { availableFields: FieldStats[] rowFields: string[] columnFields: string[] valueFields: PivotValueField[] showRowTotals: boolean showColumnTotals: boolean calculatedFields?: CalculatedField[] onShowRowTotalsChange: (value: boolean) => void onShowColumnTotalsChange: (value: boolean) => void onClearConfig: () => void onAutoSuggest: () => void onDragStart: (field: string, event: React.DragEvent) => void onDragEnd: () => void onUpdateAggregation: (field: string, oldAgg: AggregationFunction, newAgg: AggregationFunction) => void onAddRowField: (field: string) => void onRemoveRowField: (field: string) => void onAddColumnField: (field: string) => void onRemoveColumnField: (field: string) => void onAddValueField: (field: string, aggregation: AggregationFunction) => void onRemoveValueField: (field: string, aggregation: AggregationFunction) => void onAddCalculatedField?: (field: CalculatedField) => void onRemoveCalculatedField?: (id: string) => void onUpdateCalculatedField?: (field: CalculatedField) => void theme?: string /** Axis value filters: field → excluded values */ fieldFilters?: Record /** Returns the unique values of a field — enables the axis filter UI */ getFieldValues?: (field: string) => string[] onUpdateFieldFilter?: (field: string, excludedValues: string[]) => void } function getFieldIcon(type: FieldStats['type'], isCalculated?: boolean): string { if (isCalculated) return 'ƒ' switch (type) { case 'number': return '#' case 'date': return '📅' case 'boolean': return '✓' default: return 'Aa' } } export function PivotConfig({ availableFields, rowFields, columnFields, valueFields, showRowTotals, calculatedFields, onShowRowTotalsChange, onShowColumnTotalsChange, onClearConfig, onAutoSuggest: _onAutoSuggest, onDragStart, onDragEnd, onUpdateAggregation, onRemoveRowField, onRemoveColumnField, onRemoveValueField, onAddRowField, onAddColumnField, onAddCalculatedField, onRemoveCalculatedField, onUpdateCalculatedField, theme, fieldFilters, getFieldValues, onUpdateFieldFilter, }: PivotConfigProps) { const [fieldSearch, setFieldSearch] = useState('') const [showCalcModal, setShowCalcModal] = useState(false) const [editingCalcField, setEditingCalcField] = useState(null) const [openFilterField, setOpenFilterField] = useState(null) const [filterValueSearch, setFilterValueSearch] = useState('') const { canUseAdvancedAggregations } = useLicense() // Check if an aggregation is available based on license const isAggregationAvailable = useCallback((agg: AggregationFunction): boolean => { return !aggregationRequiresPro(agg) || canUseAdvancedAggregations }, [canUseAdvancedAggregations]) // Get only numeric field names for calculated field formulas const numericFieldNames = useMemo(() => availableFields .filter(f => f.isNumeric) .map(f => f.field), [availableFields]) // Convert calculated fields to virtual FieldStats for display const calculatedFieldsAsStats = useMemo((): ExtendedFieldStats[] => { if (!calculatedFields) return [] return calculatedFields.map(calc => ({ field: `calc:${calc.id}`, type: 'number' as const, uniqueCount: 0, isNumeric: true, isCalculated: true, calcId: calc.id, calcName: calc.name, calcFormula: calc.formula, })) }, [calculatedFields]) // Combined available fields (data fields + calculated fields) const allAvailableFields = useMemo((): ExtendedFieldStats[] => [ ...availableFields.map(f => ({ ...f, isCalculated: false })), ...calculatedFieldsAsStats, ], [availableFields, calculatedFieldsAsStats]) // Assigned fields const assignedFields = useMemo(() => { const rowSet = new Set(rowFields) const colSet = new Set(columnFields) const valueMap = new Map(valueFields.map(v => [v.field, v])) return allAvailableFields .filter(f => rowSet.has(f.field) || colSet.has(f.field) || valueMap.has(f.field)) .map(f => ({ ...f, assignedTo: rowSet.has(f.field) ? ('row' as const) : colSet.has(f.field) ? ('column' as const) : ('value' as const), valueConfig: valueMap.get(f.field), })) }, [allAvailableFields, rowFields, columnFields, valueFields]) // Unassigned fields (including unassigned calculated fields) const unassignedFields = useMemo(() => { const rowSet = new Set(rowFields) const colSet = new Set(columnFields) const valSet = new Set(valueFields.map(v => v.field)) return allAvailableFields.filter( f => !rowSet.has(f.field) && !colSet.has(f.field) && !valSet.has(f.field), ) }, [allAvailableFields, rowFields, columnFields, valueFields]) const filteredUnassignedFields = useMemo(() => { if (!fieldSearch.trim()) return unassignedFields const search = fieldSearch.toLowerCase().trim() return unassignedFields.filter((f) => { const fieldName = f.field.toLowerCase() const displayName = f.isCalculated && f.calcName ? f.calcName.toLowerCase() : '' return fieldName.includes(search) || displayName.includes(search) }) }, [unassignedFields, fieldSearch]) const assignedCount = assignedFields.length // Get display name for field (handles calculated fields) const getFieldDisplayName = useCallback((field: ExtendedFieldStats): string => { if (field.isCalculated && field.calcName) { return field.calcName } return field.field }, []) const handleDragStart = useCallback( (field: string, event: React.DragEvent) => { event.dataTransfer?.setData('text/plain', field) event.dataTransfer!.effectAllowed = 'move' onDragStart(field, event) }, [onDragStart], ) const handleAggregationChange = useCallback( (field: string, currentAgg: AggregationFunction, newAgg: AggregationFunction) => { // Prevent changing to Pro aggregations without license if (!isAggregationAvailable(newAgg)) { console.warn(`[TinyPivot] "${newAgg}" aggregation requires a Pro license. Visit https://tiny-pivot.com/#pricing to upgrade.`) return } onUpdateAggregation(field, currentAgg, newAgg) }, [onUpdateAggregation, isAggregationAvailable], ) const toggleRowColumn = useCallback( (field: string, currentAssignment: 'row' | 'column') => { if (currentAssignment === 'row') { onRemoveRowField(field) onAddColumnField(field) } else { onRemoveColumnField(field) onAddRowField(field) } }, [onRemoveRowField, onAddColumnField, onRemoveColumnField, onAddRowField], ) const removeField = useCallback( (field: string, assignedTo: 'row' | 'column' | 'value', valueConfig?: PivotValueField) => { if (assignedTo === 'row') { onRemoveRowField(field) } else if (assignedTo === 'column') { onRemoveColumnField(field) } else if (valueConfig) { onRemoveValueField(field, valueConfig.aggregation) } }, [onRemoveRowField, onRemoveColumnField, onRemoveValueField], ) // Axis value filter helpers const openFilterValues = useMemo(() => { if (!openFilterField || !getFieldValues) return [] return getFieldValues(openFilterField) }, [openFilterField, getFieldValues]) const openFilterExcluded = useMemo(() => { if (!openFilterField) return new Set() return new Set(fieldFilters?.[openFilterField] ?? []) }, [openFilterField, fieldFilters]) // Cap the rendered checkbox list — a high-cardinality field (IDs, // timestamp-like columns) would otherwise mount tens of thousands of // DOM nodes and freeze the tab. All/None still act on the full value set. const MAX_RENDERED_FILTER_VALUES = 200 const filteredFilterValues = useMemo(() => { const search = filterValueSearch.toLowerCase().trim() if (!search) return openFilterValues return openFilterValues.filter(v => v.toLowerCase().includes(search)) }, [openFilterValues, filterValueSearch]) const openFilterSelectedCount = useMemo( () => openFilterValues.filter(v => !openFilterExcluded.has(v)).length, [openFilterValues, openFilterExcluded], ) const canFilterField = useCallback( (field: { assignedTo: string, isCalculated?: boolean }): boolean => { return !!getFieldValues && !!onUpdateFieldFilter && !field.isCalculated && (field.assignedTo === 'row' || field.assignedTo === 'column') }, [getFieldValues, onUpdateFieldFilter], ) const hasActiveFilter = useCallback( (field: string): boolean => (fieldFilters?.[field]?.length ?? 0) > 0, [fieldFilters], ) const toggleFilterDropdown = useCallback((field: string) => { setOpenFilterField(prev => (prev === field ? null : field)) setFilterValueSearch('') }, []) const toggleFilterValue = useCallback( (field: string, value: string) => { const excluded = new Set(fieldFilters?.[field] ?? []) if (excluded.has(value)) { excluded.delete(value) } else { excluded.add(value) } onUpdateFieldFilter?.(field, Array.from(excluded)) }, [fieldFilters, onUpdateFieldFilter], ) // Handle totals toggle (toggle both row and column together) const handleTotalsToggle = useCallback((checked: boolean) => { onShowRowTotalsChange(checked) onShowColumnTotalsChange(checked) }, [onShowRowTotalsChange, onShowColumnTotalsChange]) // Calculated field modal handlers const openCalcModal = useCallback((field?: CalculatedField) => { setEditingCalcField(field || null) setShowCalcModal(true) }, []) const handleSaveCalcField = useCallback((field: CalculatedField) => { if (editingCalcField && onUpdateCalculatedField) { onUpdateCalculatedField(field) } else if (onAddCalculatedField) { onAddCalculatedField(field) } setShowCalcModal(false) setEditingCalcField(null) }, [editingCalcField, onAddCalculatedField, onUpdateCalculatedField]) const handleCloseCalcModal = useCallback(() => { setShowCalcModal(false) setEditingCalcField(null) }, []) return (
{/* Header */}

Fields

{assignedCount > 0 && ( )}
{/* Assigned Fields */} {assignedCount > 0 && (
Active
{assignedFields.map(field => (
handleDragStart(field.field, e)} onDragEnd={onDragEnd} >
{field.isCalculated ? 'ƒ' : field.assignedTo === 'row' ? 'R' : field.assignedTo === 'column' ? 'C' : getAggregationSymbol(field.valueConfig?.aggregation || 'sum')} {getFieldDisplayName(field)}
{canFilterField(field) && ( )} {(field.assignedTo === 'row' || field.assignedTo === 'column') && ( )} {field.assignedTo === 'value' && field.valueConfig && ( )}
{/* Axis value filter panel */} {openFilterField === field.field && canFilterField(field) && (
{openFilterSelectedCount} {' of '} {openFilterValues.length} {' shown'}
{openFilterValues.length > 8 && ( setFilterValueSearch(e.target.value)} placeholder="Search values..." className="vpg-filter-search" /> )}
{filteredFilterValues.slice(0, MAX_RENDERED_FILTER_VALUES).map(value => ( ))} {filteredFilterValues.length > MAX_RENDERED_FILTER_VALUES && (
{filteredFilterValues.length - MAX_RENDERED_FILTER_VALUES} {' more — search to narrow the list'}
)} {filteredFilterValues.length === 0 && (
No matching values
)}
)}
))}
)} {/* Unassigned Fields */}
Available {' '} {unassignedFields.length}
{/* Field Search */}
setFieldSearch(e.target.value)} placeholder="Search fields..." className="vpg-search-input" /> {fieldSearch && ( )}
{filteredUnassignedFields.map(field => (
handleDragStart(field.field, e)} onDragEnd={onDragEnd} > {getFieldIcon(field.type, field.isCalculated)} {getFieldDisplayName(field)} {field.isCalculated ? ( <> ) : ( {field.uniqueCount} )}
))} {filteredUnassignedFields.length === 0 && fieldSearch && (
No fields match " {fieldSearch} "
)} {unassignedFields.length === 0 &&
All fields assigned
}
{/* Options */}
{/* Calculated Field Modal */}
) }