import type { AggregationFunction, CalculatedField, FieldStats, PivotConfig, PivotResult, PivotValueField } from '@smallwebco/tinypivot-core' import type { Ref } from 'vue' import { computeAvailableFields, computePivotResult, generateStorageKey, getAggregationLabel, getUnassignedFields, isConfigValidForFields, isPivotConfigured, loadCalculatedFields, loadPivotConfig, saveCalculatedFields, savePivotConfig, } from '@smallwebco/tinypivot-core' /** * Pivot Table Composable for Vue * Wraps core pivot logic with Vue reactivity */ import { computed, ref, watch } from 'vue' import { useLicense } from './useLicense' // Re-export for convenience export { getAggregationLabel } /** * Main pivot table composable */ export function usePivotTable(data: Ref[]>, enableDrillDown: Ref = ref(true)) { const { canUsePivot, requirePro } = useLicense() // Configuration state const rowFields = ref([]) const columnFields = ref([]) const valueFields = ref([]) const showRowTotals = ref(true) const showColumnTotals = ref(true) const calculatedFields = ref(loadCalculatedFields()) const collapsedPaths = ref>(new Set()) // Track current storage key const currentStorageKey = ref(null) // Compute available fields from data const availableFields = computed((): FieldStats[] => { return computeAvailableFields(data.value) }) // Get fields that haven't been assigned yet const unassignedFields = computed(() => { return getUnassignedFields( availableFields.value, rowFields.value, columnFields.value, valueFields.value, ) }) // Check if pivot is configured const isConfigured = computed(() => { return isPivotConfigured({ rowFields: rowFields.value, columnFields: columnFields.value, valueFields: valueFields.value, showRowTotals: showRowTotals.value, showColumnTotals: showColumnTotals.value, }) }) // Build pivot result const pivotResult = computed(() => { if (!isConfigured.value) return null // Check license for pivot feature if (!canUsePivot.value) return null return computePivotResult(data.value, { rowFields: rowFields.value, columnFields: columnFields.value, valueFields: valueFields.value, showRowTotals: showRowTotals.value, showColumnTotals: showColumnTotals.value, calculatedFields: calculatedFields.value, }, { collapsedPaths: enableDrillDown.value ? collapsedPaths.value : new Set() }) }) // Actions - pivot is free with sum aggregation, Pro required for other aggregations function addRowField(field: string) { if (!rowFields.value.includes(field)) { rowFields.value = [...rowFields.value, field] } } function removeRowField(field: string) { rowFields.value = rowFields.value.filter(f => f !== field) } function addColumnField(field: string) { if (!columnFields.value.includes(field)) { columnFields.value = [...columnFields.value, field] } } function removeColumnField(field: string) { columnFields.value = columnFields.value.filter(f => f !== field) } function addValueField(field: string, aggregation: AggregationFunction = 'sum') { // Pro required for non-sum aggregations if (aggregation !== 'sum' && !requirePro(`${aggregation} aggregation`)) { return } if (valueFields.value.some(v => v.field === field && v.aggregation === aggregation)) { return } valueFields.value = [...valueFields.value, { field, aggregation }] } function removeValueField(field: string, aggregation?: AggregationFunction) { if (aggregation) { valueFields.value = valueFields.value.filter( v => !(v.field === field && v.aggregation === aggregation), ) } else { valueFields.value = valueFields.value.filter(v => v.field !== field) } } function updateValueFieldAggregation( field: string, oldAgg: AggregationFunction, newAgg: AggregationFunction, ) { valueFields.value = valueFields.value.map((v) => { if (v.field === field && v.aggregation === oldAgg) { return { ...v, aggregation: newAgg } } return v }) } function clearConfig() { rowFields.value = [] columnFields.value = [] valueFields.value = [] } function moveField( from: { area: 'row' | 'column' | 'value', index: number }, to: { area: 'row' | 'column' | 'value', index: number }, ) { if (from.area === to.area) { if (from.area === 'row') { const items = [...rowFields.value] const [removed] = items.splice(from.index, 1) items.splice(to.index, 0, removed) rowFields.value = items } else if (from.area === 'column') { const items = [...columnFields.value] const [removed] = items.splice(from.index, 1) items.splice(to.index, 0, removed) columnFields.value = items } } } function toggleCollapsedPath(key: string, altKey: boolean, _rowFields: string[], currentPivotResult: PivotResult | null) { if (!altKey) { const next = new Set(collapsedPaths.value) if (next.has(key)) { next.delete(key) } else { next.add(key) } collapsedPaths.value = next return } // Alt-click: toggle all groups at the same depth if (!currentPivotResult) return // Determine which depth this key belongs to by looking at groupStarts let targetDepth = -1 for (const meta of currentPivotResult.rowMeta) { for (const gs of meta.groupStarts) { if (gs.key === key) { targetDepth = gs.depth break } } if (targetDepth >= 0) break } if (targetDepth < 0) return // Collect all keys at this depth const keysAtDepth = new Set() for (const meta of currentPivotResult.rowMeta) { for (const gs of meta.groupStarts) { if (gs.depth === targetDepth) { keysAtDepth.add(gs.key) } } } // If the clicked key is currently collapsed, expand all; otherwise collapse all const shouldCollapse = !collapsedPaths.value.has(key) const next = new Set(collapsedPaths.value) for (const k of keysAtDepth) { if (shouldCollapse) { next.add(k) } else { next.delete(k) } } collapsedPaths.value = next } function autoSuggestConfig() { if (!requirePro('Pivot Table - Auto Suggest')) return if (availableFields.value.length === 0) return const categoricalFields = availableFields.value.filter(f => !f.isNumeric && f.uniqueCount < 50) const numericFields = availableFields.value.filter(f => f.isNumeric) if (categoricalFields.length > 0 && numericFields.length > 0) { rowFields.value = [categoricalFields[0].field] valueFields.value = [{ field: numericFields[0].field, aggregation: 'sum' }] } } // Calculated field management function addCalculatedField(field: CalculatedField) { const existing = calculatedFields.value.findIndex(f => f.id === field.id) if (existing >= 0) { calculatedFields.value = [ ...calculatedFields.value.slice(0, existing), field, ...calculatedFields.value.slice(existing + 1), ] } else { calculatedFields.value = [...calculatedFields.value, field] } saveCalculatedFields(calculatedFields.value) } function removeCalculatedField(id: string) { calculatedFields.value = calculatedFields.value.filter(f => f.id !== id) // Also remove from value fields if it was being used valueFields.value = valueFields.value.filter(v => v.field !== `calc:${id}`) saveCalculatedFields(calculatedFields.value) } // Watch data to restore or validate config watch( data, (newData) => { if (newData.length === 0) return const newKeys = Object.keys(newData[0]) const storageKey = generateStorageKey(newKeys) if (storageKey !== currentStorageKey.value) { currentStorageKey.value = storageKey const savedConfig = loadPivotConfig(storageKey) if (savedConfig && isConfigValidForFields(savedConfig, newKeys)) { rowFields.value = savedConfig.rowFields columnFields.value = savedConfig.columnFields valueFields.value = savedConfig.valueFields showRowTotals.value = savedConfig.showRowTotals showColumnTotals.value = savedConfig.showColumnTotals if (savedConfig.calculatedFields) { calculatedFields.value = savedConfig.calculatedFields } } else { const currentConfig: PivotConfig = { rowFields: rowFields.value, columnFields: columnFields.value, valueFields: valueFields.value, showRowTotals: showRowTotals.value, showColumnTotals: showColumnTotals.value, } if (!isConfigValidForFields(currentConfig, newKeys)) { clearConfig() } } // Load collapsed paths from separate sessionStorage key try { const collapsedKey = `${storageKey}-collapsed` const raw = sessionStorage.getItem(collapsedKey) if (raw) { const parsed = JSON.parse(raw) as string[] collapsedPaths.value = new Set(parsed) } else { collapsedPaths.value = new Set() } } catch { collapsedPaths.value = new Set() } } else { const currentConfig: PivotConfig = { rowFields: rowFields.value, columnFields: columnFields.value, valueFields: valueFields.value, showRowTotals: showRowTotals.value, showColumnTotals: showColumnTotals.value, } if (!isConfigValidForFields(currentConfig, newKeys)) { clearConfig() } } }, { immediate: true }, ) // Watch config changes and save to sessionStorage watch( [rowFields, columnFields, valueFields, showRowTotals, showColumnTotals, calculatedFields], () => { if (!currentStorageKey.value) return const config: PivotConfig = { rowFields: rowFields.value, columnFields: columnFields.value, valueFields: valueFields.value, showRowTotals: showRowTotals.value, showColumnTotals: showColumnTotals.value, calculatedFields: calculatedFields.value, } savePivotConfig(currentStorageKey.value, config) }, { deep: true }, ) // Watch collapsedPaths separately and save to sessionStorage watch( collapsedPaths, (paths) => { if (!currentStorageKey.value) return try { const collapsedKey = `${currentStorageKey.value}-collapsed` sessionStorage.setItem(collapsedKey, JSON.stringify(Array.from(paths))) } catch { // sessionStorage not available (SSR or private browsing) } }, ) return { // State rowFields, columnFields, valueFields, showRowTotals, showColumnTotals, calculatedFields, collapsedPaths, // Computed availableFields, unassignedFields, isConfigured, pivotResult, // Actions addRowField, removeRowField, addColumnField, removeColumnField, addValueField, removeValueField, updateValueFieldAggregation, clearConfig, moveField, autoSuggestConfig, addCalculatedField, removeCalculatedField, toggleCollapsedPath, } }