-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Add pivot table with limitations #2551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
const {objectFromProps, range, toSortedArray} = require('../utils/utils'); | ||
|
||
// TK(2023-10-10): turn this into a class constructor. | ||
|
||
function makePivotTable(worksheet, model) { | ||
// Example `model`: | ||
// { | ||
// // Source of data: the entire sheet range is taken, | ||
// // akin to `worksheet1.getSheetValues()`. | ||
// sourceSheet: worksheet1, | ||
// | ||
// // Pivot table fields: values indicate field names; | ||
// // they come from the first row in `worksheet1`. | ||
// rows: ['A', 'B'], | ||
// columns: ['C'], | ||
// values: ['E'], // only 1 item possible for now | ||
// metric: 'sum', // only 'sum' possible for now | ||
// } | ||
|
||
validate(worksheet, model); | ||
|
||
const {sourceSheet} = model; | ||
let {rows, columns, values} = model; | ||
|
||
const cacheFields = makeCacheFields(sourceSheet, [...rows, ...columns]); | ||
|
||
// let {rows, columns, values} use indices instead of names; | ||
// names can then be accessed via `pivotTable.cacheFields[index].name`. | ||
// *Note*: Using `reduce` as `Object.fromEntries` requires Node 12+; | ||
// ExcelJS is >=8.3.0 (as of 2023-10-08). | ||
const nameToIndex = cacheFields.reduce((result, cacheField, index) => { | ||
result[cacheField.name] = index; | ||
return result; | ||
}, {}); | ||
rows = rows.map(row => nameToIndex[row]); | ||
columns = columns.map(column => nameToIndex[column]); | ||
values = values.map(value => nameToIndex[value]); | ||
|
||
// form pivot table object | ||
return { | ||
sourceSheet, | ||
rows, | ||
columns, | ||
values, | ||
metric: 'sum', | ||
cacheFields, | ||
// defined in <pivotTableDefinition> of xl/pivotTables/pivotTable1.xml; | ||
// also used in xl/workbook.xml | ||
cacheId: '10', | ||
}; | ||
} | ||
|
||
function validate(worksheet, model) { | ||
if (worksheet.workbook.pivotTables.length === 1) { | ||
throw new Error( | ||
'A pivot table was already added. At this time, ExcelJS supports at most one pivot table per file.' | ||
); | ||
} | ||
|
||
if (model.metric && model.metric !== 'sum') { | ||
throw new Error('Only the "sum" metric is supported at this time.'); | ||
} | ||
|
||
const headerNames = model.sourceSheet.getRow(1).values.slice(1); | ||
const isInHeaderNames = objectFromProps(headerNames, true); | ||
for (const name of [...model.rows, ...model.columns, ...model.values]) { | ||
if (!isInHeaderNames[name]) { | ||
throw new Error(`The header name "${name}" was not found in ${model.sourceSheet.name}.`); | ||
} | ||
} | ||
|
||
if (!model.rows.length) { | ||
throw new Error('No pivot table rows specified.'); | ||
} | ||
|
||
if (!model.columns.length) { | ||
throw new Error('No pivot table columns specified.'); | ||
} | ||
|
||
if (model.values.length !== 1) { | ||
throw new Error('Exactly 1 value needs to be specified at this time.'); | ||
} | ||
} | ||
|
||
function makeCacheFields(worksheet, fieldNamesWithSharedItems) { | ||
// Cache fields are used in pivot tables to reference source data. | ||
// | ||
// Example | ||
// ------- | ||
// Turn | ||
// | ||
// `worksheet` sheet values [ | ||
// ['A', 'B', 'C', 'D', 'E'], | ||
// ['a1', 'b1', 'c1', 4, 5], | ||
// ['a1', 'b2', 'c1', 4, 5], | ||
// ['a2', 'b1', 'c2', 14, 24], | ||
// ['a2', 'b2', 'c2', 24, 35], | ||
// ['a3', 'b1', 'c3', 34, 45], | ||
// ['a3', 'b2', 'c3', 44, 45] | ||
// ]; | ||
// fieldNamesWithSharedItems = ['A', 'B', 'C']; | ||
// | ||
// into | ||
// | ||
// [ | ||
// { name: 'A', sharedItems: ['a1', 'a2', 'a3'] }, | ||
// { name: 'B', sharedItems: ['b1', 'b2'] }, | ||
// { name: 'C', sharedItems: ['c1', 'c2', 'c3'] }, | ||
// { name: 'D', sharedItems: null }, | ||
// { name: 'E', sharedItems: null } | ||
// ] | ||
|
||
const names = worksheet.getRow(1).values; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it always start from row 1? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I made the simplifying assumption that the source data is represented by the entire sheet, with the first row representing the header. Do you think this assumption is too simplistic and won't capture 95% of the cases? |
||
const nameToHasSharedItems = objectFromProps(fieldNamesWithSharedItems, true); | ||
|
||
const aggregate = columnIndex => { | ||
const columnValues = worksheet.getColumn(columnIndex).values.splice(2); | ||
const columnValuesAsSet = new Set(columnValues); | ||
return toSortedArray(columnValuesAsSet); | ||
Siemienik marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}; | ||
|
||
// make result | ||
const result = []; | ||
for (const columnIndex of range(1, names.length)) { | ||
Siemienik marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const name = names[columnIndex]; | ||
const sharedItems = nameToHasSharedItems[name] ? aggregate(columnIndex) : null; | ||
result.push({name, sharedItems}); | ||
} | ||
return result; | ||
} | ||
|
||
module.exports = {makePivotTable}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,6 +8,7 @@ const Enums = require('./enums'); | |
const Image = require('./image'); | ||
const Table = require('./table'); | ||
const DataValidations = require('./data-validations'); | ||
const {makePivotTable} = require('./pivot-table'); | ||
const Encryptor = require('../utils/encryptor'); | ||
const {copyStyle} = require('../utils/copy-style'); | ||
|
||
|
@@ -124,6 +125,8 @@ class Worksheet { | |
// for tables | ||
this.tables = {}; | ||
|
||
this.pivotTables = []; | ||
|
||
this.conditionalFormattings = []; | ||
} | ||
|
||
|
@@ -806,6 +809,23 @@ class Worksheet { | |
return Object.values(this.tables); | ||
} | ||
|
||
// ========================================================================= | ||
// Pivot Tables | ||
addPivotTable(model) { | ||
// eslint-disable-next-line no-console | ||
console.warn( | ||
`Warning: Pivot Table support is experimental. | ||
Please leave feedback at https://github.com/exceljs/exceljs/discussions/2575` | ||
); | ||
|
||
const pivotTable = makePivotTable(this, model); | ||
|
||
this.pivotTables.push(pivotTable); | ||
this.workbook.pivotTables.push(pivotTable); | ||
Comment on lines
+823
to
+824
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What will happen if someone remove the pivot table from one of these places, or overwrite one of them? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
@Siemienik It seems you're pointing at the mutability of
At this time, I intend for there to be at most one pivotTable in at most one sheet across the entire workbook. The user should not have to mutate |
||
|
||
return pivotTable; | ||
} | ||
|
||
// =========================================================================== | ||
// Conditional Formatting | ||
addConditionalFormatting(cf) { | ||
|
@@ -854,6 +874,7 @@ class Worksheet { | |
media: this._media.map(medium => medium.model), | ||
sheetProtection: this.sheetProtection, | ||
tables: Object.values(this.tables).map(table => table.model), | ||
pivotTables: this.pivotTables, | ||
conditionalFormattings: this.conditionalFormattings, | ||
}; | ||
|
||
|
@@ -920,6 +941,7 @@ class Worksheet { | |
tables[table.name] = t; | ||
return tables; | ||
}, {}); | ||
this.pivotTables = value.pivotTables; | ||
this.conditionalFormattings = value.conditionalFormattings; | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,21 +1,20 @@ | ||
'use strict'; | ||
|
||
module.exports = { | ||
OfficeDocument: | ||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', | ||
OfficeDocument: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', | ||
Worksheet: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet', | ||
CalcChain: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain', | ||
SharedStrings: | ||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings', | ||
SharedStrings: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings', | ||
Styles: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles', | ||
Theme: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme', | ||
Hyperlink: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink', | ||
Image: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', | ||
CoreProperties: | ||
'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties', | ||
ExtenderProperties: | ||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties', | ||
CoreProperties: 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties', | ||
ExtenderProperties: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties', | ||
Comments: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments', | ||
VmlDrawing: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing', | ||
Table: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table', | ||
PivotCacheDefinition: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition', | ||
PivotCacheRecords: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords', | ||
PivotTable: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable', | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
const BaseXform = require('../base-xform'); | ||
|
||
class WorkbookPivotCacheXform extends BaseXform { | ||
render(xmlStream, model) { | ||
xmlStream.leafNode('pivotCache', { | ||
cacheId: model.cacheId, | ||
'r:id': model.rId, | ||
}); | ||
} | ||
|
||
parseOpen(node) { | ||
if (node.name === 'pivotCache') { | ||
this.model = { | ||
cacheId: node.attributes.cacheId, | ||
rId: node.attributes['r:id'], | ||
}; | ||
return true; | ||
} | ||
return false; | ||
Siemienik marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
parseText() {} | ||
|
||
parseClose() { | ||
return false; | ||
} | ||
} | ||
|
||
module.exports = WorkbookPivotCacheXform; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would It be enabled to add partially configured pivot table without for instance rows set?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At this time, the code doesn't support that. You need at least one item in each of {rows, columns, values}.
However, this could be a potential feature down the road if it is requested.