react-pivot
Version:
React-Pivot is a data-grid component with pivot-table-like functionality for data display, filtering, and exploration.
560 lines (464 loc) • 13.3 kB
JSX
import filter from 'lodash/filter'
import map from 'lodash/map'
import find from 'lodash/find'
import React from 'react'
import createReactClass from 'create-react-class'
import DataFrame from 'dataframe'
import Emitter from 'wildemitter'
import partial from './lib/partial'
import download from './lib/download'
import getValue from './lib/get-value'
import PivotTable from './lib/pivot-table.jsx'
import Dimensions from './lib/dimensions.jsx'
import ColumnControl from './lib/column-control.jsx'
import SoloControl from './lib/solo-control.jsx'
import {
serializeSoloValue,
createSoloFilter,
soloEntries
} from './lib/solo-utils.js'
const _ = { filter, map, find }
export default createReactClass({
displayName: 'ReactPivot',
getDefaultProps: function() {
return {
rows: [],
dimensions: [],
activeDimensions: [],
reduce: function() {},
tableClassName: '',
csvDownloadFileName: 'table.csv',
csvTemplateFormat: false,
defaultStyles: true,
nPaginateRows: 25,
solo: {},
hiddenColumns: [],
hideRows: null,
sortBy: null,
sortDir: 'asc',
eventBus: new Emitter,
compact: false,
excludeSummaryFromExport: false,
onData: function () {},
soloText: "solo",
unsoloText: "unsolo",
subDimensionText: "Sub Dimension...",
showClearFilters: false,
clearFiltersText: "Clear Filters"
}
},
getInitialState: function() {
var allDimensions = this.props.dimensions
var activeDimensions = _.filter(this.props.activeDimensions, function (title) {
return _.find(allDimensions, function(col) {
return col.title === title
})
})
return {
dimensions: activeDimensions,
calculations: {},
sortBy: this.props.sortBy,
sortDir: this.props.sortDir,
hiddenColumns: this.props.hiddenColumns,
solo: this.props.solo,
filtersPaused: false,
hideRows: this.props.hideRows,
rows: []
}
},
componentDidMount: function() {
if (this.props.defaultStyles) loadStyles()
this.dataFrame = DataFrame({
rows: this.props.rows,
dimensions: this.props.dimensions,
reduce: this.props.reduce
})
this.updateRows()
},
componentDidUpdate: function(prevProps) {
if(this.props.hiddenColumns !== prevProps.hiddenColumns) {
this.setHiddenColumns(this.props.hiddenColumns);
}
if(this.props.rows !== prevProps.rows) {
this.dataFrame = DataFrame({
rows: this.props.rows,
dimensions: this.props.dimensions,
reduce: this.props.reduce
})
this.updateRows()
}
if (this.props.solo !== prevProps.solo) {
this.setState({solo: this.props.solo}, this.updateRows)
}
},
getColumns: function() {
var self = this
var columns = []
this.state.dimensions.forEach(function(title) {
var d = _.find(self.props.dimensions, function(col) {
return col.title === title
})
columns.push({
type: 'dimension', title: d.title, value: d.value,
className: d.className, template: d.template, sortBy: d.sortBy
})
})
this.props.calculations.forEach(function(c) {
if (self.state.hiddenColumns.indexOf(c.title) >= 0) return
var className = 'reactPivot-calculation'
if (c.className) className += ' ' + c.className
columns.push({
type:'calculation', title: c.title, template: c.template,
value: c.value, className: className, sortBy: c.sortBy
})
})
return columns
},
renderFilterActions: function() {
var hasFilters = soloEntries(this.state.solo).length > 0
if (!hasFilters) {
return <SoloControl solo={this.state.solo} onToggle={this.setSolo} />
}
var buttonText = this.state.filtersPaused ? 'Resume Filters' : 'Pause Filters'
return (
<div className='reactPivot-filterActions'>
<SoloControl solo={this.state.solo} onToggle={this.setSolo} />
{this.props.showClearFilters && (
<button
className='reactPivot-clearFilters'
onClick={this.clearSolo}
>
{this.props.clearFiltersText}
</button>
)}
<button onClick={this.toggleFilters}>
{buttonText}
</button>
</div>
)
},
render: function() {
var html = (
<div className='reactPivot'>
<div className='reactPivot-toolbar'>
{ this.props.hideDimensionFilter ? null :
<Dimensions
dimensions={this.props.dimensions}
subDimensionText={this.props.subDimensionText}
selectedDimensions={this.state.dimensions}
onChange={this.setDimensions} />
}
<div className='reactPivot-controls'>
<ColumnControl
hiddenColumns={this.state.hiddenColumns}
onChange={this.setHiddenColumns} />
{this.renderFilterActions()}
<div className="reactPivot-csvExport">
<button onClick={partial(this.downloadCSV, this.state.rows)}>
Export CSV
</button>
</div>
</div>
</div>
<PivotTable
columns={this.getColumns()}
rows={this.state.rows}
sortBy={this.state.sortBy}
sortDir={this.state.sortDir}
onSort={this.setSort}
onColumnHide={this.hideColumn}
nPaginateRows={this.props.nPaginateRows}
tableClassName={this.props.tableClassName}
onSolo={this.setSolo}
soloText={this.props.soloText}
unsoloText={this.props.unsoloText}
solo={this.state.solo}
/>
</div>
)
return html
},
updateRows: function () {
var columns = this.getColumns()
var sortByTitle = this.state.sortBy
var sortCol = _.find(columns, function(col) {
return col.title === sortByTitle
}) || {}
var sortBy = sortCol.sortBy || (sortCol.type === 'dimension' ? sortCol.title : sortCol.value);
var sortDir = this.state.sortDir
var hideRows = this.state.hideRows
var calcOpts = {
dimensions: this.state.dimensions,
sortBy: sortBy,
sortDir: sortDir,
compact: this.props.compact
}
if (!this.state.filtersPaused) {
calcOpts.filter = createSoloFilter(this.state.solo, this.state.dimensions)
}
var rows = this.dataFrame
.calculate(calcOpts)
.filter(function (row) { return hideRows ? !hideRows(row) : true })
this.setState({rows: rows})
this.props.onData(rows)
},
setDimensions: function (updatedDimensions) {
this.props.eventBus.emit('activeDimensions', updatedDimensions)
this.setState({dimensions: updatedDimensions})
setTimeout(this.updateRows, 0)
},
setHiddenColumns: function (hidden) {
this.props.eventBus.emit('hiddenColumns', hidden)
this.setState({hiddenColumns: hidden})
setTimeout(this.updateRows, 0)
},
setSort: function(cTitle) {
var sortBy = this.state.sortBy
var sortDir = this.state.sortDir
if (sortBy === cTitle) {
sortDir = (sortDir === 'asc') ? 'desc' : 'asc'
} else {
sortBy = cTitle
sortDir = 'asc'
}
this.props.eventBus.emit('sortBy', sortBy)
this.props.eventBus.emit('sortDir', sortDir)
this.setState({sortBy: sortBy, sortDir: sortDir})
setTimeout(this.updateRows, 0)
},
setSolo: function(solo) {
if (!solo || typeof solo !== 'object') return
var dimension = solo.title
if (!dimension) return
var valueKey = serializeSoloValue(solo.value)
if (!valueKey) return
var newSolo = Object.assign({}, this.state.solo)
var valueMap = newSolo[dimension] || {}
if (Object.prototype.hasOwnProperty.call(valueMap, valueKey)) {
newSolo[dimension] = this.removeSoloValue(valueMap, valueKey)
if (!newSolo[dimension]) delete newSolo[dimension]
} else {
newSolo[dimension] = this.addSoloValue(valueMap, valueKey)
}
this.props.eventBus.emit('solo', newSolo)
// Auto-resume filters when adding or removing a solo value
this.setState({solo: newSolo, filtersPaused: false}, this.updateRows)
},
clearSolo: function() {
this.props.eventBus.emit('solo', {})
this.setState({solo: {}, filtersPaused: false}, this.updateRows)
},
addSoloValue: function(valueMap, key) {
var updated = Object.assign({}, valueMap)
updated[key] = true
return updated
},
removeSoloValue: function(valueMap, key) {
var updated = Object.assign({}, valueMap)
delete updated[key]
return Object.keys(updated).length > 0 ? updated : null
},
toggleFilters: function() {
this.setState({filtersPaused: !this.state.filtersPaused}, this.updateRows)
},
hideColumn: function(cTitle) {
var hidden = this.state.hiddenColumns.concat([cTitle])
this.setHiddenColumns(hidden)
setTimeout(this.updateRows, 0)
},
downloadCSV: function(rows) {
var self = this
var columns = this.getColumns()
var csv = _.map(columns, 'title')
.map(JSON.stringify.bind(JSON))
.join(',') + '\n'
var maxLevel = this.state.dimensions.length - 1
var excludeSummary = this.props.excludeSummaryFromExport
rows.forEach(function(row) {
if (excludeSummary && (row._level < maxLevel)) return
var vals = columns.map(function(col) {
if (col.type === 'dimension') {
var val = row[col.title]
} else {
var val = getValue(col, row)
}
if (col.template && self.props.csvTemplateFormat) {
val = col.template(val)
}
return JSON.stringify(val)
})
csv += vals.join(',') + '\n'
})
download(csv, this.props.csvDownloadFileName, 'text/csv')
}
})
function loadStyles() {
if (typeof document === 'undefined') return // SSR safety
if (document.getElementById('react-pivot-styles')) return // Already loaded
const css = `.reactPivot {
margin-top: 40px;
padding: 10px 20px 20px;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.reactPivot select {
color: #555;
height: 28px;
border: none;
margin-right: 5px;
margin-top: 5px;
background-color: #FFF;
border: 1px solid #CCC;
}
.reactPivot-results table {
width: 100%;
clear: both;
text-align: left;
border-spacing: 0;
}
.reactPivot-results th.asc:after,
.reactPivot-results th.desc:after {
font-size: 50%;
opacity: 0.5;
}
.reactPivot-results th.asc:after { content: ' ▲' }
.reactPivot-results th.desc:after { content: ' ▼' }
.reactPivot-results td {
border-top: 1px solid #ddd;
padding: 8px;
}
.reactPivot-results td.reactPivot-calculation,
.reactPivot-results th.reactPivot-calculation {
text-align: right;
}
.reactPivot-results td.reactPivot-indent {
border: none;
}
.reactPivot-results tr:hover td {
background: #f5f5f5
}
.reactPivot-results tr:hover td.reactPivot-indent {
background: none;
}
.reactPivot-solo {
opacity: 0;
margin-left: 6px;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.reactPivot-solo:hover {font-weight: bold}
td:hover .reactPivot-solo {opacity: 0.5}
.reactPivot-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
margin: 10px 0;
}
.reactPivot-controls {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: flex-start;
justify-content: flex-end;
flex: 0 0 auto;
}
.reactPivot-controls > * {
margin: 0;
}
.reactPivot-controls select {
margin: 0;
width: 120px;
}
.reactPivot-toolbar select {
margin-top: 0;
}
.reactPivot-csvExport {
display: flex;
align-items: flex-start;
flex: 0 0 auto;
}
.reactPivot-csvExport button {
background-color: #FFF;
border: 1px solid #CCC;
height: 28px;
color: #555;
cursor: pointer;
padding: 0 12px;
border-radius: 0;
margin-top: 0;
white-space: nowrap;
flex-shrink: 0;
}
.reactPivot-filterActions {
display: flex;
gap: 5px;
align-items: flex-start;
flex: 0 0 auto;
}
.reactPivot-filterActions button {
background-color: #FFF;
border: 1px solid #CCC;
height: 28px;
color: #555;
cursor: pointer;
padding: 0 12px;
border-radius: 0;
margin-top: 0;
white-space: nowrap;
flex-shrink: 0;
}
.reactPivot-soloControl {
display: flex;
gap: 5px;
align-items: flex-start;
}
.reactPivot-dimensions {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding: 0;
text-align: left;
flex: 1 1 300px;
min-width: 0;
}
.reactPivot-dimensions select {
margin: 0;
}
.reactPivot-hideColumn { opacity: 0 }
th:hover .reactPivot-hideColumn {
opacity: 0.5;
margin-right: 4px;
margin-bottom: 2px;
}
.reactPivot-hideColumn:hover {
font-weight: bold;
cursor: pointer;
}
.reactPivot-pageNumber {
padding: 2px;
margin: 4px;
cursor: pointer;
color: gray;
font-size: 14px;
}
.reactPivot-pageNumber:hover {
font-weight: bold;
border-bottom: black solid 1px;
color: black;
}
.reactPivot-pageNumber.is-selected {
font-weight: bold;
border-bottom: black solid 1px;
color: black;
}
.reactPivot-paginate {
margin-top: 24px;
}`
const style = document.createElement('style')
style.id = 'react-pivot-styles'
style.textContent = css
document.head.appendChild(style)
}