From e899fbba3daa41261dcaa57b0555e37e9bdfafb4 Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Thu, 15 Apr 2021 23:27:34 -0400 Subject: [PATCH] fix(exports): grid with colspan should be export accordingly (#311) - read each item metadata to evaluate if the row has any colspan defined and if so then export them accordingly (in Excel we would merge the cells but in regular text export then we would just return empty string) --- .../src/examples/example08.ts | 21 +-- .../__tests__/export-utilities.spec.ts | 33 ++-- .../common/src/services/export-utilities.ts | 23 ++- .../src/excelExport.service.spec.ts | 83 +++++++++- .../excel-export/src/excelExport.service.ts | 147 +++++++++++++----- .../src/textExport.service.spec.ts | 64 +++++++- .../text-export/src/textExport.service.ts | 57 +++++-- 7 files changed, 330 insertions(+), 98 deletions(-) diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example08.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example08.ts index 95c26b244..dc0136003 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example08.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example08.ts @@ -6,6 +6,7 @@ import { OperatorString, } from '@slickgrid-universal/common'; import { ExcelExportService } from '@slickgrid-universal/excel-export'; +import { TextExportService } from '@slickgrid-universal/text-export'; import { Slicker, SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; import { ExampleGridOptions } from './example-grid-options'; @@ -51,7 +52,7 @@ export class Example08 { definedGrid1() { this.columnDefinitions1 = [ { id: 'title', name: 'Title', field: 'title', sortable: true, columnGroup: 'Common Factor' }, - { id: 'duration', name: 'Duration', type: FieldType.number, field: 'duration', columnGroup: 'Common Factor' }, + { id: 'duration', name: 'Duration', field: 'duration', columnGroup: 'Common Factor' }, { id: 'start', name: 'Start', field: 'start', columnGroup: 'Period' }, { id: 'finish', name: 'Finish', field: 'finish', columnGroup: 'Period' }, { id: '%', name: '% Complete', type: FieldType.number, field: 'percentComplete', selectable: false, columnGroup: 'Analysis' }, @@ -62,12 +63,13 @@ export class Example08 { enableAutoResize: false, gridHeight: 275, gridWidth: 800, + enableTextExport: true, enableExcelExport: true, excelExportOptions: { exportWithFormatter: true, sanitizeDataExport: true }, - registerExternalResources: [new ExcelExportService()], + registerExternalResources: [new TextExportService(), new ExcelExportService()], enableCellNavigation: true, enableColumnReorder: false, enableSorting: true, @@ -150,15 +152,14 @@ export class Example08 { } } }; - } else { - return { - columns: { - 0: { - colspan: '*' // starting at column index 0, we will span accross all column (*) - } - } - }; } + return { + columns: { + 0: { + colspan: '*' // starting at column index 0, we will span accross all column (*) + } + } + }; } // diff --git a/packages/common/src/services/__tests__/export-utilities.spec.ts b/packages/common/src/services/__tests__/export-utilities.spec.ts index 741a01561..51dd0925b 100644 --- a/packages/common/src/services/__tests__/export-utilities.spec.ts +++ b/packages/common/src/services/__tests__/export-utilities.spec.ts @@ -1,6 +1,17 @@ import { exportWithFormatterWhenDefined } from '../export-utilities'; import { Column, Formatter, SlickGrid } from '../../interfaces/index'; +const mockDataView = { + constructor: jest.fn(), + init: jest.fn(), + destroy: jest.fn(), + getItemMetadata: jest.fn(), +}; + +const gridStub = { + getData: () => mockDataView, +}; + describe('Export Utilities', () => { let mockItem; let mockColumn: Column; @@ -14,60 +25,60 @@ describe('Export Utilities', () => { describe('exportWithFormatterWhenDefined method', () => { it('should NOT enable exportWithFormatter and expect the firstName to returned', () => { - const output = exportWithFormatterWhenDefined(1, 1, mockItem, mockColumn, {} as SlickGrid, { exportWithFormatter: false }); + const output = exportWithFormatterWhenDefined(1, 1, mockItem, mockColumn, gridStub as SlickGrid, { exportWithFormatter: false }); expect(output).toBe('John'); }); it('should provide a column definition field defined with a dot (.) notation and expect a complex object result', () => { - const output = exportWithFormatterWhenDefined(1, 1, mockItem, { ...mockColumn, field: 'address.zip' }, {} as SlickGrid, {}); + const output = exportWithFormatterWhenDefined(1, 1, mockItem, { ...mockColumn, field: 'address.zip' }, gridStub as SlickGrid, {}); expect(output).toEqual({ zip: 12345 }); }); it('should provide a column definition field defined with a dot (.) notation and expect an empty string when the complex result is an empty object', () => { - const output = exportWithFormatterWhenDefined(1, 1, mockItem, { ...mockColumn, field: 'empty' }, {} as SlickGrid, {}); + const output = exportWithFormatterWhenDefined(1, 1, mockItem, { ...mockColumn, field: 'empty' }, gridStub as SlickGrid, {}); expect(output).toEqual(''); }); it('should provide a exportCustomFormatter in the column definition and expect the output to be formatted', () => { - const output = exportWithFormatterWhenDefined(1, 1, mockItem, { ...mockColumn, exportCustomFormatter: myBoldHtmlFormatter }, {} as SlickGrid, { exportWithFormatter: true }); + const output = exportWithFormatterWhenDefined(1, 1, mockItem, { ...mockColumn, exportCustomFormatter: myBoldHtmlFormatter }, gridStub as SlickGrid, { exportWithFormatter: true }); expect(output).toBe('John'); }); it('should provide a exportCustomFormatter in the column definition and expect empty string when associated item property is null', () => { - const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: null }, { ...mockColumn, exportCustomFormatter: myBoldHtmlFormatter }, {} as SlickGrid, { exportWithFormatter: true }); + const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: null }, { ...mockColumn, exportCustomFormatter: myBoldHtmlFormatter }, gridStub as SlickGrid, { exportWithFormatter: true }); expect(output).toBe(''); }); it('should provide a exportCustomFormatter in the column definition and expect empty string when associated item property is undefined', () => { - const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: undefined }, { ...mockColumn, exportCustomFormatter: myBoldHtmlFormatter }, {} as SlickGrid, { exportWithFormatter: true }); + const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: undefined }, { ...mockColumn, exportCustomFormatter: myBoldHtmlFormatter }, gridStub as SlickGrid, { exportWithFormatter: true }); expect(output).toBe(''); }); it('should enable exportWithFormatter as an exportOption and expect the firstName to be formatted', () => { - const output = exportWithFormatterWhenDefined(1, 1, mockItem, mockColumn, {} as SlickGrid, { exportWithFormatter: true }); + const output = exportWithFormatterWhenDefined(1, 1, mockItem, mockColumn, gridStub as SlickGrid, { exportWithFormatter: true }); expect(output).toBe('JOHN'); }); it('should enable exportWithFormatter as a grid option and expect the firstName to be formatted', () => { mockColumn.exportWithFormatter = true; - const output = exportWithFormatterWhenDefined(1, 1, mockItem, mockColumn, {} as SlickGrid, { exportWithFormatter: true }); + const output = exportWithFormatterWhenDefined(1, 1, mockItem, mockColumn, gridStub as SlickGrid, { exportWithFormatter: true }); expect(output).toBe('JOHN'); }); it('should enable exportWithFormatter as a grid option and expect empty string when associated item property is null', () => { mockColumn.exportWithFormatter = true; - const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: null }, mockColumn, {} as SlickGrid, { exportWithFormatter: true }); + const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: null }, mockColumn, gridStub as SlickGrid, { exportWithFormatter: true }); expect(output).toBe(''); }); it('should enable exportWithFormatter as a grid option and expect empty string when associated item property is undefined', () => { mockColumn.exportWithFormatter = true; - const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: undefined }, mockColumn, {} as SlickGrid, { exportWithFormatter: true }); + const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: undefined }, mockColumn, gridStub as SlickGrid, { exportWithFormatter: true }); expect(output).toBe(''); }); it('should expect empty string when associated item property is undefined and has no formatter defined', () => { - const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: undefined }, mockColumn, {} as SlickGrid, {}); + const output = exportWithFormatterWhenDefined(1, 1, { ...mockItem, firstName: undefined }, mockColumn, gridStub as SlickGrid, {}); expect(output).toBe(''); }); }); diff --git a/packages/common/src/services/export-utilities.ts b/packages/common/src/services/export-utilities.ts index af7eb0206..ed1b2818f 100644 --- a/packages/common/src/services/export-utilities.ts +++ b/packages/common/src/services/export-utilities.ts @@ -14,9 +14,6 @@ export function exportWithFormatterWhenDefined(row: number, col: number, dataCon isEvaluatingFormatter = !!columnDef.exportWithFormatter; } - // did the user provide a Custom Formatter for the export - const exportCustomFormatter: Formatter | undefined = (columnDef.exportCustomFormatter !== undefined) ? columnDef.exportCustomFormatter : undefined; - // does the field have the dot (.) notation and is a complex object? if so pull the first property name const fieldId = columnDef.field || columnDef.id || ''; let fieldProperty = fieldId; @@ -27,17 +24,17 @@ export function exportWithFormatterWhenDefined(row: number, col: number, dataCon const cellValue = dataContext.hasOwnProperty(fieldProperty) ? dataContext[fieldProperty] : null; - if (dataContext && exportCustomFormatter !== undefined) { - const formattedData = exportCustomFormatter(row, col, cellValue, columnDef, dataContext, grid); - output = formattedData as string; - if (formattedData && typeof formattedData === 'object' && formattedData.hasOwnProperty('text')) { - output = formattedData.text; - } - if (output === null || output === undefined) { - output = ''; - } + let formatter: Formatter | undefined; + if (dataContext && columnDef.exportCustomFormatter) { + // did the user provide a Custom Formatter for the export + formatter = columnDef.exportCustomFormatter; } else if (isEvaluatingFormatter && columnDef.formatter) { - const formattedData = columnDef.formatter(row, col, cellValue, columnDef, dataContext, grid); + // or else do we have a column Formatter AND are we evaluating it? + formatter = columnDef.formatter; + } + + if (typeof formatter === 'function') { + const formattedData = formatter(row, col, cellValue, columnDef, dataContext, grid); output = formattedData as string; if (formattedData && typeof formattedData === 'object' && formattedData.hasOwnProperty('text')) { output = formattedData.text; diff --git a/packages/excel-export/src/excelExport.service.spec.ts b/packages/excel-export/src/excelExport.service.spec.ts index d081a9877..52aa3c558 100644 --- a/packages/excel-export/src/excelExport.service.spec.ts +++ b/packages/excel-export/src/excelExport.service.spec.ts @@ -9,6 +9,7 @@ import { GridOption, GroupTotalsFormatter, GroupTotalFormatters, + ItemMetadata, PubSubService, SlickDataView, SlickGrid, @@ -53,6 +54,7 @@ const myCustomObjectFormatter: Formatter = (_row, _cell, value, _columnDef, data const dataViewStub = { getGrouping: jest.fn(), getItem: jest.fn(), + getItemMetadata: jest.fn(), getLength: jest.fn(), setGrouping: jest.fn(), } as unknown as SlickDataView; @@ -978,9 +980,9 @@ describe('ExcelExportService', () => { ], ['Order: 20 (2 items)'], ['Last Name: Z (1 items)'], - ['', '1E06', 'John', 'Z', 'Sales Rep.', { metadata: { style: 3 }, value: '10', }], + ['', '1E06', 'John', 'Z', 'Sales Rep.', { metadata: { style: 3, type: 'number' }, value: 10, }], ['Last Name: Doe (1 items)'], - ['', '2B02', 'Jane', 'DOE', 'Finance Manager', { metadata: { style: 3 }, value: '10', }], + ['', '2B02', 'Jane', 'DOE', 'Finance Manager', { metadata: { style: 3, type: 'number' }, value: 10, }], ['Last Name: null (0 items)'], ['', '', '', '', '', '20'], ['', '', '', '', '', '10'], @@ -1360,6 +1362,83 @@ describe('ExcelExportService', () => { }); }); }); + + describe('grid with colspan', () => { + let mockCollection; + let oddMetatadata = { columns: { lastName: { colspan: 2 } } } as ItemMetadata; + let evenMetatadata = { columns: { 0: { colspan: '*' } } } as ItemMetadata; + + beforeEach(() => { + mockGridOptions.enableTranslate = true; + mockGridOptions.translater = translateService; + mockGridOptions.excelExportOptions = {}; + mockGridOptions.createPreHeaderPanel = false; + mockGridOptions.showPreHeaderPanel = false; + mockGridOptions.colspanCallback = (item: any) => (item.id % 2 === 1) ? evenMetatadata : oddMetatadata; + + mockColumns = [ + { id: 'userId', field: 'userId', name: 'User Id', width: 100 }, + { id: 'firstName', nameKey: 'FIRST_NAME', width: 100, formatter: myBoldHtmlFormatter }, + { id: 'lastName', field: 'lastName', nameKey: 'LAST_NAME', width: 100, formatter: myBoldHtmlFormatter, exportCustomFormatter: myUppercaseFormatter, sanitizeDataExport: true, exportWithFormatter: true }, + { id: 'position', field: 'position', name: 'Position', width: 100, formatter: Formatters.translate, exportWithFormatter: true }, + { id: 'order', field: 'order', width: 100, }, + ] as Column[]; + + jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return associated Excel column name when calling "getExcelColumnNameByIndex" method with a column index', () => { + const excelColumnA = service.getExcelColumnNameByIndex(1); + const excelColumnZ = service.getExcelColumnNameByIndex(26); + const excelColumnAA = service.getExcelColumnNameByIndex(27); + const excelColumnCA = service.getExcelColumnNameByIndex(79); + + expect(excelColumnA).toBe('A'); + expect(excelColumnZ).toBe('Z'); + expect(excelColumnAA).toBe('AA'); + expect(excelColumnCA).toBe('CA'); + }); + + it(`should export same colspan in the export excel as defined in the grid`, async () => { + mockCollection = [ + { id: 0, userId: '1E06', firstName: 'John', lastName: 'Z', position: 'SALES_REP', order: 10 }, + { id: 1, userId: '1E09', firstName: 'Jane', lastName: 'Doe', position: 'DEVELOPER', order: 15 }, + { id: 2, userId: '2ABC', firstName: 'Sponge', lastName: 'Bob', position: 'IT_ADMIN', order: 33 }, + ]; + jest.spyOn(dataViewStub, 'getLength').mockReturnValue(mockCollection.length); + jest.spyOn(dataViewStub, 'getItem').mockReturnValue(null).mockReturnValueOnce(mockCollection[0]).mockReturnValueOnce(mockCollection[1]).mockReturnValueOnce(mockCollection[2]); + jest.spyOn(dataViewStub, 'getItemMetadata').mockReturnValue(oddMetatadata).mockReturnValueOnce(evenMetatadata).mockReturnValueOnce(oddMetatadata).mockReturnValueOnce(evenMetatadata); + const pubSubSpy = jest.spyOn(pubSubServiceStub, 'publish'); + const spyUrlCreate = jest.spyOn(URL, 'createObjectURL'); + const spyDownload = jest.spyOn(service, 'startDownloadFile'); + + const optionExpectation = { filename: 'export.xlsx', format: FileType.xlsx }; + + service.init(gridStub, container); + await service.exportToExcel(mockExportExcelOptions); + + expect(pubSubSpy).toHaveBeenCalledWith(`onAfterExportToExcel`, optionExpectation); + expect(spyUrlCreate).toHaveBeenCalledWith(mockExcelBlob); + expect(spyDownload).toHaveBeenCalledWith({ + ...optionExpectation, blob: new Blob(), data: [ + [ + { metadata: { style: 1, }, value: 'User Id', }, + { metadata: { style: 1, }, value: 'First Name', }, + { metadata: { style: 1, }, value: 'Last Name', }, + { metadata: { style: 1, }, value: 'Position', }, + { metadata: { style: 1, }, value: 'Order', }, + ], + ['1E06', '', '', ''], + ['1E09', 'Jane', 'DOE', '', 15], + ['2ABC', '', '', ''], + ] + }); + }); + }); }); describe('without Translater Service', () => { diff --git a/packages/excel-export/src/excelExport.service.ts b/packages/excel-export/src/excelExport.service.ts index 3b13ac16a..81c28f128 100644 --- a/packages/excel-export/src/excelExport.service.ts +++ b/packages/excel-export/src/excelExport.service.ts @@ -49,6 +49,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ private _locales!: Locale; private _groupedColumnHeaders?: Array; private _columnHeaders: Array = []; + private _hasColumnTitlePreHeader = false; private _hasGroupedItems = false; private _excelExportOptions!: ExcelExportOption; private _sheet!: ExcelWorksheet; @@ -172,6 +173,30 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ }); } + /** + * Takes a positive integer and returns the corresponding column name. + * dealing with the Excel column position is a bit tricky since the first 26 columns are single char (A,B,...) but after that it becomes double char (AA,AB,...) + * so we must first see if we are in the first section of 26 chars, if that is the case we just concatenate 1 (1st row) so it becomes (A1, B1, ...) + * and again if we go 26, we need to add yet again an extra prefix (AA1, AB1, ...) and so goes the cycle + * @param {number} colIndex - The positive integer to convert to a column name. + * @return {string} The column name. + */ + getExcelColumnNameByIndex(colIndex: number): string { + const letters = 'ZABCDEFGHIJKLMNOPQRSTUVWXY'; + + let nextPos = Math.floor(colIndex / 26); + const lastPos = Math.floor(colIndex % 26); + if (lastPos === 0) { + nextPos--; + } + + if (colIndex > 26) { + return this.getExcelColumnNameByIndex(nextPos) + letters[lastPos]; + } + + return letters[lastPos] + ''; + } + /** * Triggers download file with file format. * IE(6-10) are not supported @@ -242,7 +267,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ } break; case FieldType.number: - const val = isNaN(+data) ? null : data; + const val = isNaN(+data) ? null : +data; outputData = { value: val, metadata: { style: this._stylesheetFormats.numberFormatter.id } }; break; default: @@ -271,6 +296,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ // when having Grouped Header Titles (in the pre-header), then make the cell Bold & Aligned Center const boldCenterAlign = this._stylesheet.createFormat({ alignment: { horizontal: 'center' }, font: { bold: true } }); outputData.push(this.getColumnGroupedHeaderTitlesData(columns, { style: boldCenterAlign?.id })); + this._hasColumnTitlePreHeader = true; } // get all Column Header Titles (it might include a "Group by" title at A1 cell) @@ -309,7 +335,9 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ /** * Get all Grouped Header Titles and their keys, translate the title when required, and format them in Bold - * @param {Array} columns of the grid + * @param {Array} columns - grid column definitions + * @param {Object} metadata - Excel metadata + * @returns {Object} array of Excel cell format */ private getColumnGroupedHeaderTitlesData(columns: Column[], metadata: ExcelMetadata): Array { let outputGroupedHeaderTitles: Array = []; @@ -322,34 +350,16 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ } // merge necessary cells (any grouped header titles) - // dealing with the Excel column position is a bit tricky since the first 26 columns are single char (A,B,...) but after that it becomes double char (AA,AB,...) - // so we must first see if we are in the first section of 26 chars, if that is the case we just concatenate 1 (1st row) so it becomes (A1, B1, ...) - // but if we are over enumarating passed 26, we need an extra prefix (AA1, AB1, ...) - const charA = 'A'.charCodeAt(0); - let cellPositionStart = 'A'; - let cellPositionEnd = ''; - let lastIndex = 0; + let colspanStartIndex = 0; const headersLn = this._groupedColumnHeaders.length; for (let cellIndex = 0; cellIndex < headersLn; cellIndex++) { - // if we reached the last indenx, we are considered at the end - // else we check if next title is equal to current title, if so then we know it's a grouped header - // and we include it and continue looping until we reach the end if ((cellIndex + 1) === headersLn || ((cellIndex + 1) < headersLn && this._groupedColumnHeaders[cellIndex].title !== this._groupedColumnHeaders[cellIndex + 1].title)) { - // calculate left prefix, divide by 26 and use modulo to find out what number add to A - // for example if we have cell index 54, we will do ((54/26) %26) => 2.0769, Math.floor is 2, then we do A which is 65 + 2 gives us B so final cell will be AB1 - const leftCellCharCodePrefix = Math.floor((lastIndex / 26) % 26); - const leftCellCharacterPrefix = String.fromCharCode(charA + leftCellCharCodePrefix - 1); - - const rightCellCharCodePrefix = Math.floor((cellIndex / 26) % 26); - const rightCellCharacterPrefix = String.fromCharCode(charA + rightCellCharCodePrefix - 1); - - cellPositionEnd = String.fromCharCode(charA + (cellIndex % 26)); - const leftCell = `${lastIndex > 26 ? leftCellCharacterPrefix : ''}${cellPositionStart}1`; - const rightCell = `${cellIndex > 26 ? rightCellCharacterPrefix : ''}${cellPositionEnd}1`; - this._sheet.mergeCells(leftCell, rightCell); + const leftExcelColumnChar = this.getExcelColumnNameByIndex(colspanStartIndex + 1); + const rightExcelColumnChar = this.getExcelColumnNameByIndex(cellIndex + 1); + this._sheet.mergeCells(`${leftExcelColumnChar}1`, `${rightExcelColumnChar}1`); - cellPositionStart = String.fromCharCode(cellPositionEnd.charCodeAt(0) + 1); - lastIndex = cellIndex; + // next group starts 1 column index away + colspanStartIndex = cellIndex + 1; } } @@ -485,14 +495,19 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ /** * Get the data of a regular row (a row without grouping) - * @param row - * @param itemObj + * @param {Array} columns - column definitions + * @param {Number} row - row index + * @param {Object} itemObj - item datacontext object */ private readRegularRowData(columns: Column[], row: number, itemObj: any): string[] { let idx = 0; - const rowOutputStrings: string[] = []; + const rowOutputStrings = []; + const columnsLn = columns.length; + let prevColspan: number | string = 1; + let colspanStartIndex = 0; + const itemMetadata = this._dataView.getItemMetadata(row); - for (let col = 0, ln = columns.length; col < ln; col++) { + for (let col = 0; col < columnsLn; col++) { const columnDef = columns[col]; const fieldType = columnDef.outputType || columnDef.type || FieldType.string; @@ -506,24 +521,70 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ rowOutputStrings.push(''); } - // get the output by analyzing if we'll pull the value from the cell or from a formatter - let itemData: ExcelCellFormat | string = exportWithFormatterWhenDefined(row, col, itemObj, columnDef, this._grid, this._excelExportOptions); - - // does the user want to sanitize the output data (remove HTML tags)? - if (columnDef.sanitizeDataExport || this._excelExportOptions.sanitizeDataExport) { - itemData = sanitizeHtmlToText(itemData as string); + let colspan = 1; + let colspanColumnId; + if (itemMetadata?.columns) { + const metadata = itemMetadata.columns; + const columnData = metadata[columnDef.id] || metadata[col]; + if (!(prevColspan > 1 || (prevColspan === '*' && col > 0))) { + prevColspan = columnData?.colspan ?? 1; + } + if (prevColspan === '*') { + colspan = columns.length - col; + } else { + colspan = prevColspan as number; + if (columnDef.id in metadata) { + colspanColumnId = columnDef.id; + colspanStartIndex = col; + } + } } - // use different Excel Stylesheet Format as per the Field Type - if (!columnDef.exportWithFormatter) { - itemData = this.useCellFormatByFieldType(itemData as string, fieldType); - } + // when using grid with colspan, we will merge some cells together + if ((prevColspan === '*' && col > 0) || (prevColspan > 1 && columnDef.id !== colspanColumnId)) { + // -- Merge Data + // Excel row starts at 2 or at 3 when dealing with pre-header grouping + const excelRowNumber = row + (this._hasColumnTitlePreHeader ? 3 : 2); + + if (typeof prevColspan === 'number' && (colspan - 1) === 1) { + // partial column span + const leftExcelColumnChar = this.getExcelColumnNameByIndex(colspanStartIndex + 1); + const rightExcelColumnChar = this.getExcelColumnNameByIndex(col + 1); + this._sheet.mergeCells(`${leftExcelColumnChar}${excelRowNumber}`, `${rightExcelColumnChar}${excelRowNumber}`); + rowOutputStrings.push(''); // clear cell that won't be shown by a cell merge + } else if (prevColspan === '*' && colspan === 1) { + // full column span (from A1 until the last column) + const rightExcelColumnChar = this.getExcelColumnNameByIndex(col + 1); + this._sheet.mergeCells(`A${excelRowNumber}`, `${rightExcelColumnChar}${excelRowNumber}`); + } else { + rowOutputStrings.push(''); // clear cell that won't be shown by a cell merge + } - rowOutputStrings.push(itemData as string); - idx++; + // decrement colspan until we reach colspan of 1 then proceed with cell merge OR full row merge when colspan is (*) + if (typeof prevColspan === 'number' && prevColspan > 1) { + colspan = prevColspan--; + } + } else { + // -- Read Data & Push to Data Array + // get the output by analyzing if we'll pull the value from the cell or from a formatter + let itemData: ExcelCellFormat | string = exportWithFormatterWhenDefined(row, col, itemObj, columnDef, this._grid, this._excelExportOptions); + + // does the user want to sanitize the output data (remove HTML tags)? + if (columnDef.sanitizeDataExport || this._excelExportOptions.sanitizeDataExport) { + itemData = sanitizeHtmlToText(itemData as string); + } + + // use different Excel Stylesheet Format as per the Field Type + if (!columnDef.exportWithFormatter) { + itemData = this.useCellFormatByFieldType(itemData as string, fieldType); + } + + rowOutputStrings.push(itemData); + idx++; + } } - return rowOutputStrings; + return rowOutputStrings as string[]; } /** diff --git a/packages/text-export/src/textExport.service.spec.ts b/packages/text-export/src/textExport.service.spec.ts index d0ac0f50a..624bf99fb 100644 --- a/packages/text-export/src/textExport.service.spec.ts +++ b/packages/text-export/src/textExport.service.spec.ts @@ -8,8 +8,8 @@ import { Formatters, GridOption, GroupTotalFormatters, + ItemMetadata, PubSubService, - SharedService, SlickDataView, SlickGrid, SortComparers, @@ -19,8 +19,8 @@ import { import { ContainerServiceStub } from '../../../test/containerServiceStub'; import { TranslateServiceStub } from '../../../test/translateServiceStub'; -function removeMultipleSpaces(textS) { - return `${textS}`.replace(/ +/g, ''); +function removeMultipleSpaces(inputText: string) { + return `${inputText}`.replace(/ +/g, ''); } const pubSubServiceStub = { @@ -49,6 +49,7 @@ const myCustomObjectFormatter: Formatter = (_row, _cell, value, _columnDef, data const dataViewStub = { getGrouping: jest.fn(), getItem: jest.fn(), + getItemMetadata: jest.fn(), getLength: jest.fn(), setGrouping: jest.fn(), } as unknown as SlickDataView; @@ -1035,6 +1036,63 @@ describe('ExportService', () => { }); }); }); + + describe('grid with colspan', () => { + let mockCollection; + let oddMetatadata = { columns: { lastName: { colspan: 2 } } } as ItemMetadata; + let evenMetatadata = { columns: { 0: { colspan: '*' } } } as ItemMetadata; + + beforeEach(() => { + mockGridOptions.enableTranslate = true; + mockGridOptions.translater = translateService; + mockGridOptions.textExportOptions = {}; + mockGridOptions.createPreHeaderPanel = false; + mockGridOptions.showPreHeaderPanel = false; + mockGridOptions.colspanCallback = (item: any) => (item.id % 2 === 1) ? evenMetatadata : oddMetatadata; + + mockColumns = [ + { id: 'userId', field: 'userId', name: 'User Id', width: 100, exportCsvForceToKeepAsString: true }, + { id: 'firstName', nameKey: 'FIRST_NAME', width: 100, formatter: myBoldHtmlFormatter }, + { id: 'lastName', field: 'lastName', nameKey: 'LAST_NAME', width: 100, formatter: myBoldHtmlFormatter, exportCustomFormatter: myUppercaseFormatter, sanitizeDataExport: true, exportWithFormatter: true }, + { id: 'position', field: 'position', name: 'Position', width: 100, formatter: Formatters.translate, exportWithFormatter: true }, + { id: 'order', field: 'order', width: 100, }, + ] as Column[]; + + jest.spyOn(gridStub, 'getColumns').mockReturnValue(mockColumns); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it(`should export same colspan in the csv export as defined in the grid`, async () => { + mockCollection = [ + { id: 0, userId: '1E06', firstName: 'John', lastName: 'Z', position: 'SALES_REP', order: 10 }, + { id: 1, userId: '1E09', firstName: 'Jane', lastName: 'Doe', position: 'DEVELOPER', order: 15 }, + { id: 2, userId: '2ABC', firstName: 'Sponge', lastName: 'Bob', position: 'IT_ADMIN', order: 33 }, + ]; + jest.spyOn(dataViewStub, 'getLength').mockReturnValue(mockCollection.length); + jest.spyOn(dataViewStub, 'getItem').mockReturnValue(null).mockReturnValueOnce(mockCollection[0]).mockReturnValueOnce(mockCollection[1]).mockReturnValueOnce(mockCollection[2]); + jest.spyOn(dataViewStub, 'getItemMetadata').mockReturnValue(oddMetatadata).mockReturnValueOnce(evenMetatadata).mockReturnValueOnce(oddMetatadata).mockReturnValueOnce(evenMetatadata); + const pubSubSpy = jest.spyOn(pubSubServiceStub, 'publish'); + const spyUrlCreate = jest.spyOn(URL, 'createObjectURL'); + const spyDownload = jest.spyOn(service, 'startDownloadFile'); + + const optionExpectation = { filename: 'export.csv', format: 'csv', mimeType: 'text/plain', useUtf8WithBom: false }; + const contentExpectation = + `"User Id","First Name","Last Name","Position","Order" + ="1E06",,,, + ="1E09","Jane","DOE",,"15" + ="2ABC",,,,`; + + service.init(gridStub, container); + await service.exportToFile(mockExportCsvOptions); + + expect(pubSubSpy).toHaveBeenNthCalledWith(2, `onAfterExportToTextFile`, optionExpectation); + expect(spyUrlCreate).toHaveBeenCalledWith(mockCsvBlob); + expect(spyDownload).toHaveBeenCalledWith({ ...optionExpectation, content: removeMultipleSpaces(contentExpectation) }); + }); + }); }); describe('without Translater Service', () => { diff --git a/packages/text-export/src/textExport.service.ts b/packages/text-export/src/textExport.service.ts index 4984f7c08..71fffd159 100644 --- a/packages/text-export/src/textExport.service.ts +++ b/packages/text-export/src/textExport.service.ts @@ -329,13 +329,16 @@ export class TextExportService implements ExternalResource, BaseTextExportServic /** * Get the data of a regular row (a row without grouping) - * @param row - * @param itemObj + * @param {Array} columns - column definitions + * @param {Number} row - row index + * @param {Object} itemObj - item datacontext object */ private readRegularRowData(columns: Column[], row: number, itemObj: any) { let idx = 0; const rowOutputStrings = []; const exportQuoteWrapper = this._exportQuoteWrapper; + let prevColspan: number | string = 1; + const itemMetadata = this._dataView.getItemMetadata(row); for (let col = 0, ln = columns.length; col < ln; col++) { const columnDef = columns[col]; @@ -351,24 +354,46 @@ export class TextExportService implements ExternalResource, BaseTextExportServic rowOutputStrings.push(emptyValue); } - // get the output by analyzing if we'll pull the value from the cell or from a formatter - let itemData = exportWithFormatterWhenDefined(row, col, itemObj, columnDef, this._grid, this._exportOptions); - - // does the user want to sanitize the output data (remove HTML tags)? - if (columnDef.sanitizeDataExport || this._exportOptions.sanitizeDataExport) { - itemData = sanitizeHtmlToText(itemData); + let colspanColumnId; + if (itemMetadata?.columns) { + const metadata = itemMetadata?.columns; + const columnData = metadata[columnDef.id] || metadata[col]; + if (!(prevColspan > 1 || (prevColspan === '*' && col > 0))) { + prevColspan = columnData?.colspan ?? 1; + } + if (prevColspan !== '*') { + if (columnDef.id in metadata) { + colspanColumnId = columnDef.id; + } + } } - // when CSV we also need to escape double quotes twice, so " becomes "" - if (this._fileFormat === FileType.csv && itemData) { - itemData = itemData.toString().replace(/"/gi, `""`); - } + if ((prevColspan === '*' && col > 0) || (prevColspan > 1 && columnDef.id !== colspanColumnId)) { + rowOutputStrings.push(''); + if (prevColspan > 1) { + (prevColspan as number)--; + } + } else { + // get the output by analyzing if we'll pull the value from the cell or from a formatter + let itemData = exportWithFormatterWhenDefined(row, col, itemObj, columnDef, this._grid, this._exportOptions); + + // does the user want to sanitize the output data (remove HTML tags)? + if (columnDef.sanitizeDataExport || this._exportOptions.sanitizeDataExport) { + itemData = sanitizeHtmlToText(itemData); + } - // do we have a wrapper to keep as a string? in certain cases like "1E06", we don't want excel to transform it into exponential (1.0E06) - // to cancel that effect we can had = in front, ex: ="1E06" - const keepAsStringWrapper = (columnDef && columnDef.exportCsvForceToKeepAsString) ? '=' : ''; + // when CSV we also need to escape double quotes twice, so " becomes "" + if (this._fileFormat === FileType.csv && itemData) { + itemData = itemData.toString().replace(/"/gi, `""`); + } + + // do we have a wrapper to keep as a string? in certain cases like "1E06", we don't want excel to transform it into exponential (1.0E06) + // to cancel that effect we can had = in front, ex: ="1E06" + const keepAsStringWrapper = columnDef?.exportCsvForceToKeepAsString ? '=' : ''; + + rowOutputStrings.push(keepAsStringWrapper + exportQuoteWrapper + itemData + exportQuoteWrapper); + } - rowOutputStrings.push(keepAsStringWrapper + exportQuoteWrapper + itemData + exportQuoteWrapper); idx++; }