export-xlsx
Version:
617 lines (584 loc) • 19.7 kB
JavaScript
import FileSaver from 'file-saver';
import ExcelJS from 'exceljs/dist/exceljs.min.js';
// Default border style
export const borderStyle = {
top: { style: 'thin' },
left: { style: 'thin' },
right: { style: 'thin' },
bottom: { style: 'thin' },
};
const generateExcelColumnIndex = () => {
const str = [];
for (let i = 65; i < 91; i++) {
str.push(String.fromCharCode(i));
}
return str;
};
// Excel grid index
export const excelColumnIndex = generateExcelColumnIndex().reduce(
(array, item) => array.concat(generateExcelColumnIndex().map(v => `${item}${v}`)),
generateExcelColumnIndex()
);
// Define alignment style
export const alignment = {
middleLeft: { vertical: 'middle', horizontal: 'left' },
middleRight: { vertical: 'middle', horizontal: 'right' },
middleCenter: { vertical: 'middle', horizontal: 'center' },
};
// Define data type
export const defaultDataType = {
date: 'Date',
number: 'Number',
string: 'String',
currency: 'Currency',
percentage: 'Percentage',
};
const defaultGapBetweenTwoTables = 4;
const defaultNumberFormat = '#,##0.00';
const defaultPercentageFormat = '0.00%';
const tableTitleStyle = {
font: { bold: true },
alignment: alignment.middleLeft,
};
const notificationStyle = { font: { bold: true, color: { argb: 'FFFF0000' } } };
const defaultHeaderStyle = {
font: { bold: true },
alignment: { wrapText: true, ...alignment.middleCenter },
};
const fillStyleForEditableCell = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFFF00' },
bgColor: { indexed: 64 },
};
/**
* Creates an instance of ExcelExport.
*/
export default class ExcelExport {
/**
* @constructor
*/
constructor() {
/**
* Execl worksheet
* @type {Object}
*/
this._ws = null;
/**
* Current actived row index
* @type {Number}
*/
this._activedRowIndex = 1;
/**
* Store start and end row index for all table contents
* @type {Array}
*/
this._tableContentIndex = [];
/**
* Store Callback function to set cell formula
* @type {Array}
*/
this._callCellFormula = [];
}
/**
* Resolve formula at row level
*
* @param {String} formula
* @returns {Object}
*/
resolveRowFormula(formula) {
// formulaKeys: ['{key1}', '{key2}']
const formulaKeys = Array.from(new Set(formula.match(/\{[A-Za-z]+\}/g)));
// columnKeys: ['key1', 'key2']
const columnKeys = formulaKeys.map(key => key.slice(1, -1));
return { formulaKeys, columnKeys };
}
/**
* Set formula for single cell
*
* @param {Object} cell
* @param {String} formula
*/
resolveCellFormula(cell, formula) {
// formula: '{tableName, tableColumnName, tableRowIndex}'
const formulaKeys = Array.from(new Set(formula.match(/\{.*?\}/g)));
const callbackFormula = tableSettings => {
// Resolve cell formula
formula = formulaKeys.reduce((result, formulaKey) => {
const [tableName, tableColumnKey, cellIndex] = formulaKey.slice(1, -1).split(',');
const tableContentIndex = this._tableContentIndex[Object.keys(tableSettings).findIndex(v => v === tableName)];
let columnIndex;
// If tableColumnKey is number type , it is the column index, or else it is the column key which defined by headerDefinition
if (isNaN(tableColumnKey)) {
const index = tableSettings[tableName].headerDefinition.findIndex(v => v.key === tableColumnKey);
columnIndex = excelColumnIndex[index];
} else {
columnIndex = excelColumnIndex[tableColumnKey - 1];
}
// If cellIndex < 0, it means cell index started with the last one, e.g. '-1' is the last row index.
const rowIndex =
cellIndex > 0 ? tableContentIndex.start + Number(cellIndex) - 1 : tableContentIndex.end + Number(cellIndex);
return result.replace(new RegExp(formulaKey, 'g'), `${columnIndex}${rowIndex}`);
}, formula);
// Set formula for this cell
cell.value = { formula, result: cell.value };
};
this._callCellFormula.push(callbackFormula);
}
/**
* Get table headers
*
* @param {Object}
* @returns {Array}
*/
getTableHeaders({ headerDefinition, headerGroups = [] }) {
const headers = [headerDefinition];
let columns = headerDefinition.map(column => headerGroups.find(group => group.key === column.groupKey));
while (columns.filter(v => !!v).length > 0) {
headers.unshift(columns);
columns = columns.map(column => column && headerGroups.find(group => group.key === column.groupKey));
}
return headers;
}
/**
* Get number format
*
* @param {String} dataType
* @returns {String}
*/
getNumberFormat(dataType) {
let numFmt = '';
if (dataType === defaultDataType.number) {
numFmt = defaultNumberFormat;
}
if (dataType === defaultDataType.percentage) {
numFmt = defaultPercentageFormat;
}
return numFmt;
}
/**
* Add table title (default alignment is middle and left)
*
* @param {String} title
*/
addTableTitle(title) {
const row = this._ws.getRow(this._activedRowIndex);
row.values = [title];
// Set title font to bold, and alignment to middle left
row.eachCell(cell => {
cell.style = tableTitleStyle;
});
this._activedRowIndex = this._activedRowIndex + 1;
}
/**
* Add hierarchy structure (middle left alignment)
*
* @param {Array} data
* @param {Object} tableSetting
*/
addHierarchy(data, { headerDefinition }) {
const hierarchyColumn = headerDefinition.find(v => v.hierarchy);
// Add hierarchy structure if table has hierarchy column
if (hierarchyColumn) {
const idCol = this._ws.getColumn(hierarchyColumn.key);
idCol.eachCell((cell, rowNumber) => {
if (rowNumber >= this._activedRowIndex) {
const level = data[rowNumber - this._activedRowIndex].level;
// Hierarchy column must have middle left alignment
if (level > 0) {
cell.alignment = {
...cell.alignment,
...alignment.middleLeft,
indent: 2 * level,
};
} else {
cell.alignment = { ...cell.alignment, ...alignment.middleLeft };
}
}
});
}
}
/**
* Add vertical border line for the table
*
* @param {Array} data
* @param {Object} tableSetting
*/
addVerticalBorderLineForTables(data, headerHeight, { headerDefinition }) {
for (let i = this._activedRowIndex - headerHeight; i < this._activedRowIndex + data.length; i++) {
headerDefinition.forEach((column, index) => {
const colNumber = excelColumnIndex[index];
const cell = this._ws.getCell(`${colNumber}${i}`);
cell.border = {
...cell.border,
left: borderStyle.left,
right: borderStyle.right,
};
});
}
}
/**
* Add horizontal border line for the table content
*
* @param {Object} row
* @param {Array} rowValues
* @param {Array} tableContent
* @param {Number} tableContentIndex
*/
addHorizontalBorderLineForTableContent(row, rowValues, tableContent, tableContentIndex) {
// Add border line at the top of table content
if (tableContentIndex === 0) {
row.eachCell({ includeEmpty: true }, cell => {
cell.border = { ...cell.border, top: borderStyle.top };
});
}
// Add border line at the bottom of table content
if (tableContentIndex === tableContent.length - 1) {
// Special case, when all of the row values is null
if (rowValues.length === 0) {
const lastRow = this._activedRowIndex + tableContent.length - 1;
for (let i = 0; i < tableContent[0].length; i++) {
const cell = this._ws.getCell(`${excelColumnIndex[i]}${lastRow}`);
cell.border = { ...cell.border, bottom: borderStyle.bottom };
}
} else {
row.eachCell({ includeEmpty: true }, cell => {
cell.border = { ...cell.border, bottom: borderStyle.bottom };
});
}
}
}
/**
* Add formula
*
* @param {Array} data
* @param {Object} tableSetting
*/
addFormula(data, { headerDefinition }) {
const hierarchyColumn = headerDefinition.find(column => column.hierarchy);
headerDefinition.forEach((column, index) => {
const columnIndex = excelColumnIndex[index];
// Add formula at column level
if (hierarchyColumn && column.selfSum) {
for (let level = 0; level < data.length; level++) {
data.forEach((v, rowIndex) => {
if (v.level !== undefined && v.level === level) {
const rowNums = data.map((t, i) => (t.parentId === v.id ? i : undefined)).filter(t => t !== undefined);
const formula = rowNums.reduce((result, rowNum) => {
result += `+${columnIndex}${this._activedRowIndex + rowNum}`;
return result;
}, '');
if (formula) {
const cell = this._ws.getCell(`${columnIndex}${this._activedRowIndex + rowIndex}`);
cell.value = { formula: formula.slice(1), result: cell.value };
}
}
});
}
}
// Add formula of row level
if (column.rowFormula) {
const { formulaKeys, columnKeys } = this.resolveRowFormula(column.rowFormula);
data.forEach((v, index) => {
const currentRowIndex = this._activedRowIndex + index;
const leafDataNorNot = hierarchyColumn ? data.findIndex(t => t.parentId === v.id) === -1 : true;
// Leaf node or cell with percentage data-type need to add row level formula
if (column.dataType === defaultDataType.percentage || leafDataNorNot) {
const formula = formulaKeys.reduce((result, formulaKey, index) => {
const columnKeyIndex = headerDefinition.findIndex(column => column.key === columnKeys[index]);
return result.replace(
new RegExp(formulaKey, 'g'),
`${excelColumnIndex[columnKeyIndex]}${currentRowIndex}`
);
}, column.rowFormula);
// Add formula style
const cell = this._ws.getCell(`${columnIndex}${currentRowIndex}`);
cell.value = { formula, result: cell.value };
}
});
}
});
}
/**
* Merge cell for headers
*
* @param {Array} header
* @param {Number} numberIndex
*/
mergeCellForHeader(header, numberIndex) {
for (let i = 0; i < header.length; ) {
if (header[i]) {
let end = excelColumnIndex[i];
const start = excelColumnIndex[i];
const currentName = header[i];
i++;
while (i < header.length) {
if (currentName === header[i]) {
end = excelColumnIndex[i];
i++;
} else {
break;
}
}
if (start !== end) {
this._ws.mergeCells(`${start}${numberIndex}`, `${end}${numberIndex}`);
}
} else {
i++;
}
}
}
/**
* Add table columns
*
* @param {Array} headerDefinition
*/
addTableColumns(headerDefinition) {
// Set table column style
this._ws.columns = headerDefinition.map(column => {
// If data type is String, set cell to middle center alignment
if (!column.dataType) {
column.style = { ...column.style, alignment: alignment.middleCenter };
}
// If data type is Number and Percentage, set number format
if ([defaultDataType.number, defaultDataType.percentage].includes(column.dataType)) {
column.style = {
...column.style,
numFmt: this.getNumberFormat(column.dataType),
};
}
return column;
});
}
/**
* Add table headers
*
* @param {Object} tableSetting
* @return {Number}
*/
addTableHeaders({ headerDefinition, headerGroups }) {
const headers = this.getTableHeaders({ headerDefinition, headerGroups });
// Add table headers
headers.forEach((header, index) => {
let row;
if (index === 0) {
row = this._ws.getRow(this._activedRowIndex);
// Add display name for the first header
row.values = header.map(v => (v ? v.name : null));
// Add crosswise border line at the top of table header
row.eachCell({ includeEmpty: true }, cell => {
cell.border = { top: borderStyle.top };
});
} else {
row = this._ws.addRow(header.map(v => (v ? v.name : null)));
}
// Set style for all headers
row.eachCell((cell, colNumber) => {
const headerStyle = header[colNumber - 1].headerStyle || {};
cell.style = { ...defaultHeaderStyle, ...cell.style, ...headerStyle };
cell.border = { ...cell.border, bottom: borderStyle.bottom };
});
});
// Merge cell for headers
headers.forEach((header, index) =>
this.mergeCellForHeader(header.map(v => (v && v.name) || null), this._activedRowIndex + index)
);
this.addTableColumns(headerDefinition);
const headerHeight = headers.length;
this._activedRowIndex = this._activedRowIndex + headerHeight;
return headerHeight;
}
/**
* Add column width
*
* @param {Number} columnWidth
* @param {Number} columns
*/
addColumnWidth(columnWidth, columns) {
if (columnWidth) {
for (let i = 1; i <= columns; i++) {
this._ws.getColumn(i).width = columnWidth;
}
}
}
/**
* Add notification for table
*
* @param {Object} tableSetting
*/
addNotification(tableSetting) {
if (tableSetting.notification) {
const row = this._ws.addRow([tableSetting.notification]);
// Set notification style
row.eachCell(cell => {
cell.style = notificationStyle;
});
this._activedRowIndex = this._activedRowIndex + 1;
}
}
/**
* Add style for single cell
*
* @param {Object} cell
* @param {Array} data
*/
addCellStyle(cell, data) {
const { font, dataType, cellFormula } = data.style;
// Add number format
cell.numFmt = this.getNumberFormat(dataType);
// Add font style
cell.font = { ...cell.font, ...font };
// Add formula
if (cellFormula) {
this.resolveCellFormula(cell, cellFormula);
}
}
/**
* Add table value
*
* @param {Array} data
* @param {Boolean} noHeader
*/
addTableValues(data, noHeader) {
data.forEach((value, index) => {
const cellsHasStyleSetting = [];
if (noHeader) {
value = value.map((v, index) => {
if (v !== null && typeof v === 'object') {
cellsHasStyleSetting.push(index + 1);
return v.value;
}
return v;
});
}
const row = this._ws.addRow(value);
// Add border line for table content
this.addHorizontalBorderLineForTableContent(row, value, data, index);
// Add cell style if needed
if (cellsHasStyleSetting.length > 0) {
row.eachCell({ includeEmpty: true }, (cell, cellNumber) => {
const objectData = data[index][cellNumber - 1];
if (cellsHasStyleSetting.includes(cellNumber) && objectData !== null && typeof objectData === 'object') {
this.addCellStyle(cell, objectData);
}
});
}
});
}
/**
* Leaf node and editable cells could add fill style (yellow background)
*
* @param {Array} data
* @param {Object} tableSetting
*/
addFillStyleForEditableCells(data, { headerDefinition }) {
headerDefinition.forEach((column, index) => {
const columnIndex = excelColumnIndex[index];
data.forEach((v, rowIndex) => {
// Leaf node need to add these formula
const leafDataIndex = data.findIndex(t => t.parentId === v.id);
if (leafDataIndex === -1 && column.editable) {
const cell = this._ws.getCell(`${columnIndex}${this._activedRowIndex + rowIndex}`);
cell.fill = { ...cell.fill, ...fillStyleForEditableCell };
}
});
});
}
/**
* Add table content index
*
* @param {Number} contentLength
*/
addTableContentIndex(contentLength) {
this._tableContentIndex.push({
start: this._activedRowIndex,
end: this._activedRowIndex + contentLength,
});
this._activedRowIndex = this._activedRowIndex + contentLength;
}
/**
* Set space between two tables
*
* @param {Number} gapBetweenTwoTables
*/
setGapBetweenTwoTables(gapBetweenTwoTables) {
this._activedRowIndex = this._activedRowIndex + gapBetweenTwoTables || defaultGapBetweenTwoTables;
}
/**
* Create work sheet
*
* @param {Array} data
* @param {Object} wsSetting
* @param {Number} wsIndex
*/
createWorkSheet(data, wsSetting, wsIndex) {
const { columnWidth, gapBetweenTwoTables, tableSettings } = wsSetting;
// Create tables for this work sheet
Object.keys(tableSettings).forEach(index => {
let tableSetting = tableSettings[index];
if (typeof tableSetting === 'function') {
tableSetting = tableSetting(data[wsIndex][index]);
}
// Add table title
if (tableSetting.tableTitle) {
this.addTableTitle(tableSetting.tableTitle);
}
if (tableSetting.headerDefinition) {
const tableData = data[wsIndex][index];
// Add table headers
const headerHeight = this.addTableHeaders(tableSetting);
// Add table value
this.addTableValues(tableData, false);
// Add hierarchy structure for column which has hierarchy setting
this.addHierarchy(tableData, tableSetting);
// Add vertical border line
this.addVerticalBorderLineForTables(tableData, headerHeight, tableSetting);
// Add fill style for cells which could editable
this.addFillStyleForEditableCells(tableData, tableSetting);
// Add table column formula
this.addFormula(tableData, tableSetting);
// Push start and end row index of table content to '_tableContentIndex'
this.addTableContentIndex(tableData.length);
// Add notify at table buttom
this.addNotification(tableSetting);
} else {
const rows = tableSetting.rowsDefinition;
// Set column width if table setting has not header definition
this.addColumnWidth(columnWidth, rows.length);
// Add table value
this.addTableValues(rows, true);
// Push start and end row index of table content to '_tableContentIndex'
this.addTableContentIndex(rows.length);
}
// Set space between two tables
this.setGapBetweenTwoTables(gapBetweenTwoTables);
});
// Callback function to set cell formula
this._callCellFormula.forEach(callbackFunc => callbackFunc(tableSettings));
}
/**
* Generate excel file
*
* @param {Object} worksheet
* @param {String} filename
*/
async generateExcel(wb, filename) {
const buf = await wb.xlsx.writeBuffer();
const blob = new Blob([buf], { type: 'application/vnd.ms-excel' });
FileSaver.saveAs(blob, `${filename}.xlsx`);
}
async downloadExcel(settings = {}, data = []) {
const wb = new ExcelJS.Workbook();
const { fileName, workSheets } = settings;
// Create worksheets
workSheets.forEach((wsSetting, wsIndex) => {
const { sheetName, startingRowNumber } = wsSetting;
this._ws = wb.addWorksheet(sheetName);
// Actived row index
this._activedRowIndex = startingRowNumber;
this.createWorkSheet(data, wsSetting, wsIndex);
});
// Generate excel file
await this.generateExcel(wb, fileName);
}
}