diff --git a/CHANGELOG.md b/CHANGELOG.md index 48bd6a6de04..53c52b6cf98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ All notable changes for each version of this project will be documented in this - `onDataPreLoad` -> `dataPreLoad` ### New Features +- `IgxHierarchicalGrid` + - Added support for exporting hierarchical data. - `IgxForOf`, `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid` - **Behavioral Change** - Virtual containers now scroll smoothly when using the mouse wheel(s) to scroll them horizontally or vertically. This behavior more closely resembles the scrolling behavior of non-virtualized containers in most modern browsers. - `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid` diff --git a/projects/igniteui-angular/src/lib/services/csv/char-separated-value-data.ts b/projects/igniteui-angular/src/lib/services/csv/char-separated-value-data.ts index 5a3f3e356b5..f4fb3888e62 100644 --- a/projects/igniteui-angular/src/lib/services/csv/char-separated-value-data.ts +++ b/projects/igniteui-angular/src/lib/services/csv/char-separated-value-data.ts @@ -32,7 +32,7 @@ export class CharSeparatedValueData { return ''; } - this._isSpecialData = ExportUtilities.isSpecialData(this._data); + this._isSpecialData = ExportUtilities.isSpecialData(this._data[0]); this._escapeCharacters.push(this._delimiter); this._headerRecord = this.processHeaderRecord(keys); @@ -52,7 +52,7 @@ export class CharSeparatedValueData { done(''); } - this._isSpecialData = ExportUtilities.isSpecialData(this._data); + this._isSpecialData = ExportUtilities.isSpecialData(this._data[0]); this._escapeCharacters.push(this._delimiter); this._headerRecord = this.processHeaderRecord(keys); diff --git a/projects/igniteui-angular/src/lib/services/excel/excel-exporter-grid.spec.ts b/projects/igniteui-angular/src/lib/services/excel/excel-exporter-grid.spec.ts index 19e3df13a7d..72133037ce2 100644 --- a/projects/igniteui-angular/src/lib/services/excel/excel-exporter-grid.spec.ts +++ b/projects/igniteui-angular/src/lib/services/excel/excel-exporter-grid.spec.ts @@ -26,10 +26,15 @@ import { configureTestSuite } from '../../test-utils/configure-suite'; import { IgxTreeGridPrimaryForeignKeyComponent } from '../../test-utils/tree-grid-components.spec'; import { IgxTreeGridModule, IgxTreeGridComponent } from '../../grids/tree-grid/public_api'; import { IgxNumberFilteringOperand } from '../../data-operations/filtering-condition'; -import { wait } from '../../test-utils/ui-interactions.spec'; +import { UIInteractions, wait } from '../../test-utils/ui-interactions.spec'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { FilteringExpressionsTree } from '../../data-operations/filtering-expressions-tree'; import { FilteringLogic } from '../../data-operations/filtering-expression.interface'; +import { IgxHierarchicalGridExportComponent } from '../../test-utils/hierarchical-grid-components.spec'; +import { IgxHierarchicalGridModule, + IgxHierarchicalGridComponent, +} from '../../grids/hierarchical-grid/public_api'; +import { IgxHierarchicalRowComponent } from '../../grids/hierarchical-grid/hierarchical-row.component'; describe('Excel Exporter', () => { configureTestSuite(); @@ -47,9 +52,10 @@ describe('Excel Exporter', () => { GridWithEmptyColumnsComponent, GridIDNameJobTitleHireDataPerformanceComponent, GridHireDateComponent, - GridExportGroupedDataComponent + GridExportGroupedDataComponent, + IgxHierarchicalGridExportComponent ], - imports: [IgxGridModule, IgxTreeGridModule, NoopAnimationsModule] + imports: [IgxGridModule, IgxTreeGridModule, IgxHierarchicalGridModule, NoopAnimationsModule] }).compileComponents(); })); @@ -140,7 +146,6 @@ describe('Excel Exporter', () => { await wait(); const grid = fix.componentInstance.grid; - options.ignoreColumnsOrder = true; options.ignoreColumnsVisibility = false; expect(grid.visibleColumns.length).toEqual(3, 'Invalid number of visible columns!'); @@ -632,6 +637,101 @@ describe('Excel Exporter', () => { }); }); + describe('', () => { + let fix; + let hGrid: IgxHierarchicalGridComponent; + + beforeEach(waitForAsync(() => { + options = createExportOptions('HierarchicalGridExcelExport'); + fix = TestBed.createComponent(IgxHierarchicalGridExportComponent); + fix.detectChanges(); + + hGrid = fix.componentInstance.hGrid; + })); + + it('should export hierarchical grid', async () => { + await exportAndVerify(hGrid, options, actualData.exportHierarchicalData); + }); + + it('should export hierarchical grid respecting options width.', async () => { + options = createExportOptions('HierarchicalGridExcelExport', 50); + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithColumnWidth); + }); + + it('should export sorted hierarchical grid data', async () => { + hGrid.sort({fieldName: 'GrammyNominations', dir: SortingDirection.Desc}); + + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportSortedHierarchicalData); + }); + + it('should export hierarchical grid data with ignored sorting', async () => { + hGrid.sort({fieldName: 'GrammyNominations', dir: SortingDirection.Desc}); + + options.ignoreSorting = true; + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalData); + }); + + it('should export filtered hierarchical grid data', async () => { + hGrid.filter('Debut', '2009', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + await exportAndVerify(hGrid, options, actualData.exportFilteredHierarchicalData); + }); + + it('should export hierarchical grid data with ignored filtering', async () => { + hGrid.filter('Debut', '2009', IgxStringFilteringOperand.instance().condition('contains'), true); + fix.detectChanges(); + + options.ignoreFiltering = true; + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalData); + }); + + it('should export hierarchical grid with expanded rows.', async () => { + const firstRow = hGrid.hgridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + const secondRow = hGrid.hgridAPI.get_row_by_index(1) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(firstRow.expander); + fix.detectChanges(); + expect(firstRow.expanded).toBe(true); + + let childGrids = hGrid.hgridAPI.getChildGrids(false); + + const firstChildGrid = childGrids[0]; + const firstChildRow = firstChildGrid.hgridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(firstChildRow.expander); + fix.detectChanges(); + expect(firstChildRow.expanded).toBe(true); + + const secondChildGrid = childGrids[1]; + const secondChildRow = secondChildGrid.hgridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(secondChildRow.expander); + fix.detectChanges(); + expect(secondChildRow.expanded).toBe(true); + + UIInteractions.simulateClickAndSelectEvent(secondRow.expander); + fix.detectChanges(); + expect(secondRow.expanded).toBe(true); + + childGrids = hGrid.hgridAPI.getChildGrids(false); + + const thirdChildGrid = childGrids[3]; + const thirdChildRow = thirdChildGrid.hgridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + + UIInteractions.simulateClickAndSelectEvent(thirdChildRow.expander); + fix.detectChanges(); + expect(thirdChildRow.expanded).toBe(true); + + await exportAndVerify(hGrid, options, actualData.exportHierarchicalDataWithExpandedRows); + }); + }); + describe('', () => { let fix; let treeGrid: IgxTreeGridComponent; @@ -855,9 +955,10 @@ describe('Excel Exporter', () => { }; const exportAndVerify = async (component, exportOptions, expectedData) => { + const isHGrid = component instanceof IgxHierarchicalGridComponent; const wrapper = await getExportedData(component, exportOptions); - await wrapper.verifyStructure(); - await wrapper.verifyDataFilesContent(expectedData); + await wrapper.verifyStructure(isHGrid); + await wrapper.verifyDataFilesContent(expectedData, '', isHGrid); }; }); diff --git a/projects/igniteui-angular/src/lib/services/excel/excel-exporter.ts b/projects/igniteui-angular/src/lib/services/excel/excel-exporter.ts index dc003310b45..c6153697003 100644 --- a/projects/igniteui-angular/src/lib/services/excel/excel-exporter.ts +++ b/projects/igniteui-angular/src/lib/services/excel/excel-exporter.ts @@ -5,7 +5,7 @@ import { ExcelElementsFactory } from './excel-elements-factory'; import { ExcelFolderTypes } from './excel-enums'; import { IgxExcelExporterOptions } from './excel-exporter-options'; import { IExcelFolder } from './excel-interfaces'; -import { IExportRecord, IgxBaseExporter } from '../exporter-common/base-export-service'; +import { ExportRecordType, IExportRecord, IgxBaseExporter, DEFAULT_OWNER } from '../exporter-common/base-export-service'; import { ExportUtilities } from '../exporter-common/export-utilities'; import { WorksheetData } from './worksheet-data'; import { IBaseEventArgs } from '../../core/utils'; @@ -72,9 +72,13 @@ export class IgxExcelExporterService extends IgxBaseExporter { } protected exportDataImplementation(data: IExportRecord[], options: IgxExcelExporterOptions): void { - const level = data[0]?.level; + const firstDataElement = data[0]; + let rootKeys; + let columnCount; + let columnWidths; + let indexOfLastPinnedColumn; - if (typeof level !== 'undefined') { + if (typeof firstDataElement !== 'undefined') { let maxLevel = 0; data.forEach((r) => { @@ -84,9 +88,25 @@ export class IgxExcelExporterService extends IgxBaseExporter { if (maxLevel > 7) { throw Error('Can create an outline of up to eight levels!'); } + + if (firstDataElement.type === ExportRecordType.HierarchicalGridRecord) { + columnCount = data + .map(a => this._ownersMap.get(a.owner).columns.length + a.level) + .sort((a,b) => b - a)[0]; + + rootKeys = this._ownersMap.get(firstDataElement.owner).columns.map(c => c.header); + } else { + const defaultOwner = this._ownersMap.get(DEFAULT_OWNER); + const columns = defaultOwner.columns.filter(col => !col.skip); + columnWidths = defaultOwner.columnWidths; + indexOfLastPinnedColumn = defaultOwner.indexOfLastPinnedColumn; + columnCount = columns.length; + rootKeys = columns.map(c => c.header); + } } - const worksheetData = new WorksheetData(data, this.columnWidthList, options, this._indexOfLastPinnedColumn, this._sort); + const worksheetData = + new WorksheetData(data, options, this._sort, columnCount, rootKeys, indexOfLastPinnedColumn, columnWidths); this._xlsx = new (JSZip as any).default(); diff --git a/projects/igniteui-angular/src/lib/services/excel/excel-files.ts b/projects/igniteui-angular/src/lib/services/excel/excel-files.ts index 8147c9902db..4ea69415c1e 100644 --- a/projects/igniteui-angular/src/lib/services/excel/excel-files.ts +++ b/projects/igniteui-angular/src/lib/services/excel/excel-files.ts @@ -4,6 +4,7 @@ import { WorksheetData } from './worksheet-data'; import * as JSZip from 'jszip'; import { yieldingLoop } from '../../core/utils'; +import { ExportRecordType } from '../exporter-common/base-export-service'; /** * @hidden @@ -67,9 +68,10 @@ export class WorksheetFile implements IExcelFile { return new Promise(resolve => { this.prepareDataAsync(worksheetData, (cols, rows) => { const hasTable = !worksheetData.isEmpty && worksheetData.options.exportAsTable; + const isHierarchicalGrid = worksheetData.data[0]?.type === ExportRecordType.HierarchicalGridRecord; folder.file('sheet1.xml', ExcelStrings.getSheetXML( - this.dimension, this.freezePane, cols, rows, hasTable, this.maxOutlineLevel)); + this.dimension, this.freezePane, cols, rows, hasTable, this.maxOutlineLevel, isHierarchicalGrid)); resolve(); }); }); @@ -85,42 +87,53 @@ export class WorksheetFile implements IExcelFile { this.dimension = 'A1'; done('', sheetData); } else { - sheetData += ''; + const isHierarchicalGrid = worksheetData.data[0].type === ExportRecordType.HierarchicalGridRecord; + const height = worksheetData.options.rowHeight; - this.rowHeight = height ? ' ht="' + height + '" customHeight="1"' : ''; - sheetData += ``; + const rowStyle = isHierarchicalGrid ? ' s="3"' : ''; + this.rowHeight = height ? ` ht="${height}" customHeight="1"` : ''; + + sheetData += ``; - for (let i = 0; i < worksheetData.columnCount; i++) { + for (let i = 0; i < worksheetData.rootKeys.length; i++) { const column = ExcelStrings.getExcelColumn(i) + 1; - const value = dictionary.saveValue(worksheetData.keys[i], i, true); - sheetData += `${value}`; + const value = dictionary.saveValue(worksheetData.rootKeys[i], true); + sheetData += `${value}`; } sheetData += ''; - this.dimension = 'A1:' + ExcelStrings.getExcelColumn(worksheetData.columnCount - 1) + worksheetData.rowCount; - cols += ''; - - for (let i = 0; i < worksheetData.columnCount; i++) { - const width = dictionary.columnWidths[i]; - // Use the width provided in the options if it exists - let widthInTwips = worksheetData.options.columnWidth !== undefined ? - worksheetData.options.columnWidth : - Math.max(((width / 96) * 14.4), WorksheetFile.MIN_WIDTH); - if (!(widthInTwips > 0)) { - widthInTwips = WorksheetFile.MIN_WIDTH; + if (!isHierarchicalGrid) { + this.dimension = 'A1:' + ExcelStrings.getExcelColumn(worksheetData.columnCount - 1) + worksheetData.rowCount; + cols += ''; + + for (let i = 0; i < worksheetData.columnCount; i++) { + const width = dictionary.columnWidths[i]; + // Use the width provided in the options if it exists + let widthInTwips = worksheetData.options.columnWidth !== undefined ? + worksheetData.options.columnWidth : + Math.max(((width / 96) * 14.4), WorksheetFile.MIN_WIDTH); + if (!(widthInTwips > 0)) { + widthInTwips = WorksheetFile.MIN_WIDTH; + } + + cols += ``; } - cols += ``; - } + cols += ''; - cols += ''; + const indexOfLastPinnedColumn = worksheetData.indexOfLastPinnedColumn; - if (worksheetData.indexOfLastPinnedColumn !== -1 && - !worksheetData.options.ignorePinning && - !worksheetData.options.ignoreColumnsOrder) { - const frozenColumnCount = worksheetData.indexOfLastPinnedColumn + 1; - const firstCell = ExcelStrings.getExcelColumn(frozenColumnCount) + '1'; - this.freezePane = ``; + if (indexOfLastPinnedColumn !== -1 && + !worksheetData.options.ignorePinning && + !worksheetData.options.ignoreColumnsOrder) { + const frozenColumnCount = indexOfLastPinnedColumn + 1; + const firstCell = ExcelStrings.getExcelColumn(frozenColumnCount) + '1'; + this.freezePane = + ``; + } + } else { + const columnWidth = worksheetData.options.columnWidth ? worksheetData.options.columnWidth : 20; + cols += ``; } this.processDataRecordsAsync(worksheetData, (rows) => { @@ -146,41 +159,49 @@ export class WorksheetFile implements IExcelFile { } private processRow(worksheetData: WorksheetData, i: number) { - const rowData = new Array(worksheetData.columnCount + 2); const record = worksheetData.data[i - 1]; - const sHidden = record.hidden ? ` hidden="1"` : ''; + + const isHierarchicalGrid = record.type === ExportRecordType.HeaderRecord || record.type === ExportRecordType.HierarchicalGridRecord; + const rowData = new Array(worksheetData.columnCount + 2); + const rowLevel = record.level; const outlineLevel = rowLevel > 0 ? ` outlineLevel="${rowLevel}"` : ''; - this.maxOutlineLevel = this.maxOutlineLevel < rowLevel ? rowLevel : this.maxOutlineLevel; + const sHidden = record.hidden ? ` hidden="1"` : ''; + rowData[0] = ``; - for (let j = 0; j < worksheetData.columnCount; j++) { - const cellData = WorksheetFile.getCellData(worksheetData, i, j); + const keys = worksheetData.isSpecialData ? [record.data] : Object.keys(record.data); + + for (let j = 0; j < keys.length; j++) { + const col = j + (isHierarchicalGrid ? rowLevel : 0); + + const cellData = WorksheetFile.getCellData(worksheetData, i, col, keys[j]); + rowData[j + 1] = cellData; } - rowData[worksheetData.columnCount + 1] = ''; + rowData[keys.length + 1] = ''; return rowData.join(''); } /* eslint-disable @typescript-eslint/member-ordering */ - private static getCellData(worksheetData: WorksheetData, row: number, column: number): string { + private static getCellData(worksheetData: WorksheetData, row: number, column: number, key: string): string { const dictionary = worksheetData.dataDictionary; const columnName = ExcelStrings.getExcelColumn(column) + (row + 1); - const columnHeader = worksheetData.keys[column]; const fullRow = worksheetData.data[row - 1]; + const isHeaderRecord = fullRow.type === ExportRecordType.HeaderRecord; const cellValue = worksheetData.isSpecialData ? fullRow.data : - fullRow.data[columnHeader]; + fullRow.data[key]; if (cellValue === undefined || cellValue === null) { return ``; } else { - const savedValue = dictionary.saveValue(cellValue, column, false); + const savedValue = dictionary.saveValue(cellValue, isHeaderRecord); const isSavedAsString = savedValue !== -1; const isSavedAsDate = !isSavedAsString && cellValue instanceof Date; @@ -195,7 +216,7 @@ export class WorksheetFile implements IExcelFile { const type = isSavedAsString ? ` t="s"` : isSavedAsDate ? ` t="d"` : ''; - const format = isSavedAsString ? '' : isSavedAsDate ? ` s="2"` : ` s="1"`; + const format = isHeaderRecord ? ` s="3"` : isSavedAsString ? '' : isSavedAsDate ? ` s="2"` : ` s="1"`; return `${value}`; } @@ -210,7 +231,9 @@ export class StyleFile implements IExcelFile { public writeElement(folder: JSZip, worksheetData: WorksheetData) { const hasNumberValues = worksheetData.dataDictionary && worksheetData.dataDictionary.hasNumberValues; const hasDateValues = worksheetData.dataDictionary && worksheetData.dataDictionary.hasDateValues; - folder.file('styles.xml', ExcelStrings.getStyles(hasNumberValues, hasDateValues)); + const isHierarchicalGrid = worksheetData.data[0]?.type === ExportRecordType.HierarchicalGridRecord; + + folder.file('styles.xml', ExcelStrings.getStyles(hasNumberValues, hasDateValues, isHierarchicalGrid)); } } @@ -261,7 +284,7 @@ export class TablesFile implements IExcelFile { const columnCount = worksheetData.columnCount; const lastColumn = ExcelStrings.getExcelColumn(columnCount - 1) + worksheetData.rowCount; const dimension = 'A1:' + lastColumn; - const values = worksheetData.keys; + const values = worksheetData.rootKeys; let sortString = ''; let tableColumns = ''; diff --git a/projects/igniteui-angular/src/lib/services/excel/excel-strings.ts b/projects/igniteui-angular/src/lib/services/excel/excel-strings.ts index 33345512ea2..d364a6d893b 100644 --- a/projects/igniteui-angular/src/lib/services/excel/excel-strings.ts +++ b/projects/igniteui-angular/src/lib/services/excel/excel-strings.ts @@ -20,15 +20,9 @@ export class ExcelStrings { return ExcelStrings.XML_STRING + ''; } - public static getStyles(hasNumberValues: boolean, hasDateValues: boolean): string { - const cellXFCount = hasDateValues ? 3 : hasNumberValues ? 2 : 1; - let additionalCellXF = ''; - - if (hasDateValues) { - additionalCellXF = additionalCellXF + ' '; - } - - return ExcelStrings.XML_STRING + '' + additionalCellXF + ''; + public static getStyles(hasNumberValues: boolean, hasDateValues: boolean, isHierarchicalGrid: boolean): string { + return ExcelStrings.XML_STRING + + ' '; } public static getWorkbook(worksheetName: string): string { @@ -51,18 +45,20 @@ export class ExcelStrings { return retVal; } - public static getSheetXML(dimension: string, freezePane: string, cols: string, sheetData: string, hasTable: boolean, outlineLevel = 0): string { + public static getSheetXML(dimension: string, freezePane: string, cols: string, sheetData: string, hasTable: boolean, outlineLevel = 0, isHierarchicalGrid: boolean): string { const hasOutline = outlineLevel > 0; const tableParts = hasTable ? '' : ''; const sheetOutlineProp = hasOutline ? '' : ''; const sOutlineLevel = hasOutline ? `outlineLevelRow="${outlineLevel}"` : ''; + const dimensions = isHierarchicalGrid ? '' : ``; + // return ExcelStrings.XML_STRING + // '' + freezePane + '' + cols + sheetData + '' + tableParts + ''; return `${ExcelStrings.XML_STRING} ${sheetOutlineProp} - +${dimensions} ${freezePane} ${cols} diff --git a/projects/igniteui-angular/src/lib/services/excel/jszip-helper.spec.ts b/projects/igniteui-angular/src/lib/services/excel/jszip-helper.spec.ts index b471429bb49..e91db27d953 100644 --- a/projects/igniteui-angular/src/lib/services/excel/jszip-helper.spec.ts +++ b/projects/igniteui-angular/src/lib/services/excel/jszip-helper.spec.ts @@ -33,6 +33,11 @@ export class JSZipFiles { 'xl/sharedStrings.xml' ]; + public static hGridDataFilesAndFoldersNames = [ + 'xl/worksheets/sheet1.xml', + 'xl/sharedStrings.xml' + ]; + public static templatesNames = [ '_rels/', '_rels/.rels', @@ -124,20 +129,7 @@ export class JSZipFiles { const cellXfCount = this.hasDates ? 3 : 1; const additionalCellXf = this.hasDates ? ` ` : ''; - return ` - ` + - `` + - `` + - `` + `${additionalCellXf}` + - `` + - `` + - ``; + return ' '; } public static getSharedStringsXML(stringsData: string) { @@ -163,29 +155,29 @@ export class JSZipFiles { `; } - public static getSheetDataFile(sheetData: string, hasValues) { + public static getSheetDataFile(sheetData: string, hasValues: boolean, isHGrid: boolean) { if (hasValues) { - return ` -${ sheetData }` + -`` + -``; + const tablePart = isHGrid ? '' : ''; + return ` + ${ sheetData }` + + `${tablePart}`; } else { return ` -` + -``; + ` + + ``; } } - public static createExpectedXML(xmlFile: ExcelFileTypes, currentData = '', hasValues = true): any { + public static createExpectedXML(xmlFile: ExcelFileTypes, currentData = '', hasValues = true, isHGrid: boolean = false): any { let resultXml; switch (xmlFile) { case ExcelFileTypes.RootRelsFile: @@ -276,7 +268,7 @@ export class JSZipFiles { case ExcelFileTypes.WorksheetFile: resultXml = { name: JSZipFiles.templatesNames[11], - content : JSZipFiles.getSheetDataFile(currentData, hasValues) + content : JSZipFiles.getSheetDataFile(currentData, hasValues, isHGrid) }; break; case ExcelFileTypes.ContentTypesFile: diff --git a/projects/igniteui-angular/src/lib/services/excel/jszip-verification-wrapper.spec.ts b/projects/igniteui-angular/src/lib/services/excel/jszip-verification-wrapper.spec.ts index e1c52fb2146..f2df05530be 100644 --- a/projects/igniteui-angular/src/lib/services/excel/jszip-verification-wrapper.spec.ts +++ b/projects/igniteui-angular/src/lib/services/excel/jszip-verification-wrapper.spec.ts @@ -19,11 +19,12 @@ export class JSZipWrapper { } /* Asserts the JSZip contains the files it should contain. */ - public verifyStructure(message = '') { + public verifyStructure(isHGrid: boolean = false, message = '') { let result = ObjectComparer.AreEqual(this.templateFilesAndFolders, JSZipFiles.templatesNames); + const template = isHGrid ? JSZipFiles.hGridDataFilesAndFoldersNames : JSZipFiles.dataFilesAndFoldersNames; result = (this.hasValues) ? - result && ObjectComparer.AreEqual(this.dataFilesAndFolders, JSZipFiles.dataFilesAndFoldersNames) : + result && ObjectComparer.AreEqual(this.dataFilesAndFolders, template) : result && this._filesAndFolders.length === JSZipFiles.templatesNames.length; expect(result).toBe(true, message + ' Unexpected zip structure!'); @@ -45,12 +46,12 @@ export class JSZipWrapper { /* Verifies the contents of all data files and asserts the result. Optionally, a message can be passed in, which, if specified, will be shown in the beginning of the comparison result. */ - public async verifyDataFilesContent(expectedData: IFileContent[], message = '') { + public async verifyDataFilesContent(expectedData: IFileContent[], message = '', isHGrid = false) { let result; const msg = (message !== '') ? message + '\r\n' : ''; await this.readDataFiles().then(() => { - result = this.compareFiles(this.dataFilesContent, expectedData); + result = this.compareFiles(this.dataFilesContent, expectedData, isHGrid); expect(result.areEqual).toBe(true, msg + result.differences); }); } @@ -142,10 +143,10 @@ export class JSZipWrapper { } /* Compares the content of two files based on the provided file type and expected value data. */ - private compareFilesContent(currentContent: string, fileType: ExcelFileTypes, fileData: string) { + private compareFilesContent(currentContent: string, fileType: ExcelFileTypes, fileData: string, isHGrid) { let result = true; let differences = ''; - const expectedFile = JSZipFiles.createExpectedXML(fileType, fileData, this.hasValues); + const expectedFile = JSZipFiles.createExpectedXML(fileType, fileData, this.hasValues, isHGrid); const expectedContent = expectedFile.content; result = ObjectComparer.AreEqualXmls(currentContent, expectedContent); if (!result) { @@ -155,14 +156,14 @@ export class JSZipWrapper { return { areEqual: result, differences }; } - private compareContent(currentFile: IFileContent, expectedData: string) { + private compareContent(currentFile: IFileContent, expectedData: string, isHGrid) { let result = true; let differences = ''; const fileType = this.getFileTypeByName(currentFile.fileName); if (fileType !== undefined) { - const comparisonResult = this.compareFilesContent(currentFile.fileContent, fileType, expectedData); + const comparisonResult = this.compareFilesContent(currentFile.fileContent, fileType, expectedData, isHGrid); result = comparisonResult.areEqual; if (!result) { differences = comparisonResult.differences; @@ -174,13 +175,13 @@ export class JSZipWrapper { } /* Compares the contents of the provided files to their expected values. */ - private compareFiles(actualFilesContent: IFileContent[], expectedFilesData: IFileContent[]) { + private compareFiles(actualFilesContent: IFileContent[], expectedFilesData: IFileContent[], isHGrid: boolean = false) { let result = true; let differences = ''; for (const current of actualFilesContent) { const index = (expectedFilesData !== undefined) ? expectedFilesData.findIndex((f) => f.fileName === current.fileName) : -1; const excelData = (index > -1 && expectedFilesData[index] !== undefined) ? expectedFilesData[index].fileContent : ''; - const comparisonResult = this.compareContent(current, excelData); + const comparisonResult = this.compareContent(current, excelData, isHGrid); result = result && comparisonResult.areEqual; if (!comparisonResult.areEqual) { differences = differences + comparisonResult.differences; diff --git a/projects/igniteui-angular/src/lib/services/excel/test-data.service.spec.ts b/projects/igniteui-angular/src/lib/services/excel/test-data.service.spec.ts index 723ce8b327a..cbd3bc8f2a6 100644 --- a/projects/igniteui-angular/src/lib/services/excel/test-data.service.spec.ts +++ b/projects/igniteui-angular/src/lib/services/excel/test-data.service.spec.ts @@ -240,15 +240,20 @@ export class FileContentData { constructor() {} - public create(worksheetData: string, tableData: string, sharedStringsData: string, workbookData: string, appData: string): IFileContent[] { + public create(worksheetData: string, tableData: string, sharedStringsData: string, workbookData: string, appData: string, isHGrid: boolean = false): IFileContent[] { this._fileContentCollection = [ { fileName: JSZipFiles.dataFiles[1].name, fileContent : worksheetData }, - { fileName: JSZipFiles.dataFiles[2].name, fileContent : tableData }, { fileName: JSZipFiles.dataFiles[3].name, fileContent : sharedStringsData }, { fileName: JSZipFiles.templateFiles[6].name, fileContent : workbookData }, { fileName: JSZipFiles.templateFiles[1].name, fileContent : appData }, ]; + if (!isHGrid) { + this._fileContentCollection.push({ + fileName: JSZipFiles.dataFiles[2].name, fileContent : tableData + }); + } + return this._fileContentCollection; } @@ -351,8 +356,8 @@ export class FileContentData { return this.createData(); } - private createData() { - return this.create(this._worksheetData, this._tableData, this._sharedStringsData, this._workbookData, this._appData); + private createData(isHGrid: boolean = false) { + return this.create(this._worksheetData, this._tableData, this._sharedStringsData, this._workbookData, this._appData, isHGrid); } public get differentTypesDataContent() { @@ -1064,7 +1069,7 @@ export class FileContentData { public get exportGroupedDataWithIgnoreSorting() { this._sharedStringsData = - `count="30" uniqueCount="21">PriceModelEditionBrand: Tesla (3)75000Model SSport100000RoadsterPerformance65000BaseBrand: BMW (2)150000M5CompetitionBrand: VW (3)ArteonBusinessPassatR Line`; + `count="22" uniqueCount="17">PriceModelEditionBrand: Tesla (3)Model SSportRoadsterPerformanceBaseBrand: BMW (2)M5CompetitionBrand: VW (3)ArteonBusinessPassatR Line`; this._tableData = `ref="A1:C12" totalsRowShown="0"> @@ -1076,14 +1081,14 @@ export class FileContentData { - 012345678910511121314157149164171810191871720`; + 0123750004510000067650004891500001011100000107127500013146500015141000001316`; return this.createData(); } public get exportGroupedDataWithIgnoreFiltering() { this._sharedStringsData = - `count="30" uniqueCount="21">PriceModelEditionBrand: BMW (2)150000M5Competition100000PerformanceBrand: Tesla (3)75000Model SSportRoadster65000BaseBrand: VW (3)ArteonBusinessPassatR Line`; + `count="22" uniqueCount="17">PriceModelEditionBrand: BMW (2)M5CompetitionPerformanceBrand: Tesla (3)Model SSportRoadsterBaseBrand: VW (3)ArteonBusinessPassatR Line`; this._tableData = `ref="A1:C12" totalsRowShown="0"> @@ -1095,7 +1100,7 @@ export class FileContentData { - 0123456758910111271381411151610171814191871720`; + 012315000045100000467750008910000010665000811127500013146500015141000001316`; return this.createData(); } @@ -1118,4 +1123,73 @@ export class FileContentData { return this.createData(); } + public get exportHierarchicalData() { + this._sharedStringsData = + `count="106" uniqueCount="57">ArtistDebutGrammyNominationsGrammyAwardsNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryTickets SoldAttendantsBelgiumUSABabila EbwéléFahrenheitShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomaniaChloe`; + + this._worksheetData = + ` + + + + 0123420116047200901156201531`; + + return this.createData(); + } + + public get exportHierarchicalDataWithColumnWidth() { + this._sharedStringsData = + `count="106" uniqueCount="57">ArtistDebutGrammyNominationsGrammyAwardsNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryTickets SoldAttendantsBelgiumUSABabila EbwéléFahrenheitShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomaniaChloe`; + + this._worksheetData = + ` + + + + 0123420116047200901156201531`; + + return this.createData(); + } + + public get exportHierarchicalDataWithExpandedRows() { + this._sharedStringsData = + `count="106" uniqueCount="57">ArtistDebutGrammyNominationsGrammyAwardsNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryTickets SoldAttendantsBelgiumUSABabila EbwéléFahrenheitShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomaniaChloe`; + + this._worksheetData = + ` + + + + 01234201160567892000-05-31T00:00:008642222001-05-31T00:00:00122252021-12-19T00:00:0011101112131262021-12-19T00:00:00272282021-12-19T00:00:00273292021-12-19T00:00:0027302020-07-17T00:00:00533435363738394041424344451000010000461923001865233839404138394041383940414720090115678482000-05-31T00:00:008642343536375253404142434454250001982255650216332056201531`; + + return this.createData(); + } + + public get exportSortedHierarchicalData() { + this._sharedStringsData = + `count="106" uniqueCount="57">ArtistDebutGrammyNominationsGrammyAwardsNaomí YepesAlbumLaunch DateBillboard ReviewUS Billboard 200Pushing up daisiesNo.TitleReleasedGenreWood Shavifdsafdsafsangs Forever*fdasfsaWood Shavifdsafdsafsavngs Forever*vxzvczxWfdsafsaings Forever*fdsacewwwqwqWood Shavings Forever*rewqrqcxzPushing up daisies - DeluxeWood Shavings Forever - RemixPunkUtopiaSANTORINIHip-HopHEARTBEATOVERSEASWish You Were HereZoomDo You?No PhotosTourStarted onLocationHeadlinerFaithful TourSep 12WorldwideNOCountryTickets SoldAttendantsBelgiumUSAChloeBabila EbwéléFahrenheitShow OutMood SwingsScenarioAstroworldJul 21BulgariaRomania`; + + this._worksheetData = + ` + + + + 0123420116047201531482009011`; + + return this.createData(); + } + + public get exportFilteredHierarchicalData() { + this._sharedStringsData = + `count="33" uniqueCount="31">ArtistDebutGrammyNominationsGrammyAwardsBabila EbwéléAlbumLaunch DateBillboard ReviewUS Billboard 200FahrenheitNo.TitleReleasedGenreShow OutHip-HopMood SwingsScenarioTourStarted onLocationHeadlinerAstroworldJul 21WorldwideNOCountryTickets SoldAttendantsBulgariaRomania`; + + this._worksheetData = + ` + + + + 012342009011`; + + return this.createData(); + } } diff --git a/projects/igniteui-angular/src/lib/services/excel/worksheet-data-dictionary.ts b/projects/igniteui-angular/src/lib/services/excel/worksheet-data-dictionary.ts index e83b03452d6..1146cc85186 100644 --- a/projects/igniteui-angular/src/lib/services/excel/worksheet-data-dictionary.ts +++ b/projects/igniteui-angular/src/lib/services/excel/worksheet-data-dictionary.ts @@ -20,8 +20,6 @@ export class WorksheetDataDictionary { private _columnWidths: number[]; private _context: any; - private _columnTypeInfo: boolean[]; - constructor(columnCount: number, columnWidth: number, columnWidthsList: number[]) { this._dictionary = {}; this._widthsDictionary = {}; @@ -29,7 +27,6 @@ export class WorksheetDataDictionary { this.dirtyKeyCollections(); this._columnWidths = new Array(columnCount); - this._columnTypeInfo = new Array(columnCount); if (columnWidth) { this._columnWidths.fill(columnWidth); @@ -44,14 +41,10 @@ export class WorksheetDataDictionary { return this._columnWidths; } - public saveValue(value: any, column: number, isHeader: boolean): number { - if (this._columnTypeInfo[column] === undefined && isHeader === false) { - this._columnTypeInfo[column] = typeof value !== 'number' && value !== Number(value) && !Number.isFinite(value); - } - + public saveValue(value: any, isHeader: boolean): number { let sanitizedValue = ''; const isDate = value instanceof Date; - const isSavedAsString = (this._columnTypeInfo[column] || isHeader) && !isDate; + const isSavedAsString = isHeader || (typeof value !== 'number' && value !== Number(value) && !Number.isFinite(value) && !isDate); if (isSavedAsString) { sanitizedValue = this.sanitizeValue(value); diff --git a/projects/igniteui-angular/src/lib/services/excel/worksheet-data.ts b/projects/igniteui-angular/src/lib/services/excel/worksheet-data.ts index cd989f24425..c80e39bf1b4 100644 --- a/projects/igniteui-angular/src/lib/services/excel/worksheet-data.ts +++ b/projects/igniteui-angular/src/lib/services/excel/worksheet-data.ts @@ -1,18 +1,21 @@ -import { IExportRecord } from '../exporter-common/base-export-service'; +import { ExportRecordType, IExportRecord } from '../exporter-common/base-export-service'; import { ExportUtilities } from '../exporter-common/export-utilities'; import { IgxExcelExporterOptions } from './excel-exporter-options'; import { WorksheetDataDictionary } from './worksheet-data-dictionary'; /** @hidden */ export class WorksheetData { - private _columnCount: number; private _rowCount: number; private _dataDictionary: WorksheetDataDictionary; - private _keys: string[]; private _isSpecialData: boolean; - constructor(private _data: IExportRecord[], private _columnWidths: number[], public options: IgxExcelExporterOptions, - public indexOfLastPinnedColumn: number, public sort: any) { + constructor(private _data: IExportRecord[], + public options: IgxExcelExporterOptions, + public sort: any, + public columnCount: number, + public rootKeys: string[], + public indexOfLastPinnedColumn: number, + public columnWidths: number[]) { this.initializeData(); } @@ -20,20 +23,12 @@ export class WorksheetData { return this._data; } - public get columnCount(): number { - return this._columnCount; - } - public get rowCount(): number { return this._rowCount; } public get isEmpty(): boolean { - return !this.rowCount || !this._columnCount; - } - - public get keys(): string[] { - return this._keys; + return !this.rowCount || !this.columnCount; } public get isSpecialData(): boolean { @@ -49,18 +44,12 @@ export class WorksheetData { return; } - const actualData = this._data.map(item => item.data); - - this._keys = ExportUtilities.getKeysFromData(actualData); - if (this._keys.length === 0) { - return; + if (this._data[0].type === ExportRecordType.HierarchicalGridRecord) { + this.options.exportAsTable = false; } - this._isSpecialData = ExportUtilities.isSpecialData(actualData); - - this._columnCount = this._keys.length; + this._isSpecialData = ExportUtilities.isSpecialData(this._data[0].data); this._rowCount = this._data.length + 1; - - this._dataDictionary = new WorksheetDataDictionary(this._columnCount, this.options.columnWidth, this._columnWidths); + this._dataDictionary = new WorksheetDataDictionary(this.columnCount, this.options.columnWidth, this.columnWidths); } } diff --git a/projects/igniteui-angular/src/lib/services/exporter-common/base-export-service.ts b/projects/igniteui-angular/src/lib/services/exporter-common/base-export-service.ts index 8506b40ba8f..809cec9c4a5 100644 --- a/projects/igniteui-angular/src/lib/services/exporter-common/base-export-service.ts +++ b/projects/igniteui-angular/src/lib/services/exporter-common/base-export-service.ts @@ -1,8 +1,6 @@ import { EventEmitter } from '@angular/core'; - import { cloneArray, cloneValue, IBaseEventArgs, resolveNestedPath, yieldingLoop } from '../../core/utils'; -import { DataUtil } from '../../data-operations/data-util'; - +import { DataType, DataUtil } from '../../data-operations/data-util'; import { ExportUtilities } from './export-utilities'; import { IgxExporterOptionsBase } from './exporter-options-base'; import { ITreeGridRecord } from '../../grids/tree-grid/tree-grid.interfaces'; @@ -11,23 +9,44 @@ import { IGroupingState } from '../../data-operations/groupby-state.interface'; import { getHierarchy, isHierarchyMatch } from '../../data-operations/operations'; import { IGroupByExpandState } from '../../data-operations/groupby-expand-state.interface'; import { IFilteringState } from '../../data-operations/filtering-state.interface'; -import { IgxGridBaseDirective } from '../../grids/public_api'; +import { IgxColumnComponent, IgxGridBaseDirective } from '../../grids/public_api'; import { IgxTreeGridComponent } from '../../grids/tree-grid/public_api'; import { IgxGridComponent } from '../../grids/grid/public_api'; import { DatePipe } from '@angular/common'; import { IGroupByRecord } from '../../data-operations/groupby-record.interface'; +import { IgxHierarchicalGridComponent } from '../../grids/hierarchical-grid/hierarchical-grid.component'; +import { IgxRowIslandComponent } from '../../grids/hierarchical-grid/row-island.component'; +import { IPathSegment } from './../../grids/hierarchical-grid/hierarchical-grid-base.directive'; export enum ExportRecordType { GroupedRecord = 1, TreeGridRecord = 2, DataRecord = 3, + HierarchicalGridRecord = 4, + HeaderRecord = 5, } export interface IExportRecord { data: any; level: number; - hidden?: boolean; type: ExportRecordType; + owner?: string | IgxGridBaseDirective; + hidden?: boolean; +} + +export interface IColumnList { + columns: IColumnInfo[]; + columnWidths: number[]; + indexOfLastPinnedColumn: number; +} + +export interface IColumnInfo { + header: string; + field: string; + skip: boolean; + dataType?: DataType; + skipFormatter?: boolean; + formatter?: any; } /** @@ -86,11 +105,18 @@ export interface IColumnExportingEventArgs extends IBaseEventArgs { * Export the column's data without applying its formatter, when set to true */ skipFormatter: boolean; + + /** + * A reference to the grid owner. + */ + grid?: IgxGridBaseDirective; } +export const DEFAULT_OWNER = 'default'; const DEFAULT_COLUMN_WIDTH = 8.43; export abstract class IgxBaseExporter { + public exportEnded = new EventEmitter(); /** @@ -117,16 +143,11 @@ export abstract class IgxBaseExporter { */ public columnExporting = new EventEmitter(); - protected _indexOfLastPinnedColumn = -1; protected _sort = null; + protected _ownersMap: Map = new Map(); - private _columnList: any[]; - private _columnWidthList: number[]; private flatRecords: IExportRecord[] = []; - - public get columnWidthList() { - return this._columnWidthList; - } + private options: IgxExporterOptionsBase; /** * Method for exporting IgxGrid component's data. @@ -141,48 +162,27 @@ export abstract class IgxBaseExporter { throw Error('No options provided!'); } - const columns = grid.columnList.toArray(); - this._columnList = new Array(columns.length); - this._columnWidthList = new Array(columns.filter(c => !c.hidden).length); + this.options = options; - const hiddenColumns = []; - let lastVisibleColumnIndex = -1; + const columns = grid.columnList.toArray(); + const columnList = this.getColumns(columns); - columns.forEach((column) => { - const columnHeader = !ExportUtilities.isNullOrWhitespaces(column.header) ? column.header : column.field; - const exportColumn = !column.hidden || options.ignoreColumnsVisibility; - const index = options.ignoreColumnsOrder || options.ignoreColumnsVisibility ? column.index : column.visibleIndex; - const columnWidth = Number(column.width.slice(0, -2)); + const tagName = grid.nativeElement.tagName.toLowerCase(); - const columnInfo = { - header: columnHeader, - dataType: column.dataType, - field: column.field, - skip: !exportColumn, - formatter: column.formatter, - skipFormatter: false - }; + if (tagName === 'igx-hierarchical-grid') { + this._ownersMap.set(grid, columnList); - if (index !== -1) { - this._columnList[index] = columnInfo; - this._columnWidthList[index] = columnWidth; - lastVisibleColumnIndex = Math.max(lastVisibleColumnIndex, index); - } else { - hiddenColumns.push(columnInfo); - } + const childLayoutList = (grid as IgxHierarchicalGridComponent).childLayoutList; - if (column.pinned && exportColumn) { - this._indexOfLastPinnedColumn++; + for (const island of childLayoutList) { + this.mapHierarchicalGridColumns(island); } - }); - - // Append the hidden columns to the end of the list - hiddenColumns.forEach((hiddenColumn) => { - this._columnList[++lastVisibleColumnIndex] = hiddenColumn; - }); + } else { + this._ownersMap.set(DEFAULT_OWNER, columnList); + } - this.prepareData(grid, options); - this.exportGridRecordsData(this.flatRecords, options); + this.prepareData(grid); + this.exportGridRecordsData(this.flatRecords, grid); } /** @@ -198,6 +198,8 @@ export abstract class IgxBaseExporter { throw Error('No options provided!'); } + this.options = options; + const records = data.map(d => { const record: IExportRecord = { data: d, @@ -208,72 +210,85 @@ export abstract class IgxBaseExporter { return record; }); - this.exportGridRecordsData(records, options); + this.exportGridRecordsData(records); } - private exportGridRecordsData(records: IExportRecord[], options: IgxExporterOptionsBase) { - if (options === undefined || options === null) { - throw Error('No options provided!'); - } - - if (!this._columnList || this._columnList.length === 0) { + private exportGridRecordsData(records: IExportRecord[], grid?: IgxGridBaseDirective) { + if (this._ownersMap.size === 0) { const recordsData = records.map(r => r.data); const keys = ExportUtilities.getKeysFromData(recordsData); - this._columnList = keys.map((k) => ({ header: k, field: k, skip: false })); - this._columnWidthList = new Array(keys.length).fill(DEFAULT_COLUMN_WIDTH); + const columns = keys.map((k) => ({ header: k, field: k, skip: false })); + const columnWidths = new Array(keys.length).fill(DEFAULT_COLUMN_WIDTH); + + const mapRecord: IColumnList = { + columns, + columnWidths, + indexOfLastPinnedColumn: -1 + }; + + this._ownersMap.set(DEFAULT_OWNER, mapRecord); } - let skippedPinnedColumnsCount = 0; - let columnsWithoutHeaderCount = 1; - this._columnList.forEach((column, index) => { - if (!column.skip) { - const columnExportArgs = { - header: !ExportUtilities.isNullOrWhitespaces(column.header) ? - column.header : - 'Column' + columnsWithoutHeaderCount++, - field: column.field, - columnIndex: index, - cancel: false, - skipFormatter: false - }; - this.columnExporting.emit(columnExportArgs); + for (const [key, mapRecord] of this._ownersMap) { + let skippedPinnedColumnsCount = 0; + let columnsWithoutHeaderCount = 1; + let indexOfLastPinnedColumn = mapRecord.indexOfLastPinnedColumn; + + mapRecord.columns.forEach((column, index) => { + if (!column.skip) { + const columnExportArgs: IColumnExportingEventArgs = { + header: !ExportUtilities.isNullOrWhitespaces(column.header) ? + column.header : + 'Column' + columnsWithoutHeaderCount++, + field: column.field, + columnIndex: index, + cancel: false, + skipFormatter: false, + grid: key === DEFAULT_OWNER ? grid : key + }; + this.columnExporting.emit(columnExportArgs); - column.header = columnExportArgs.header; - column.skip = columnExportArgs.cancel; - column.skipFormatter = columnExportArgs.skipFormatter; + column.header = columnExportArgs.header; + column.skip = columnExportArgs.cancel; + column.skipFormatter = columnExportArgs.skipFormatter; - if (column.skip && index <= this._indexOfLastPinnedColumn) { - skippedPinnedColumnsCount++; - } + if (column.skip && index <= indexOfLastPinnedColumn) { + skippedPinnedColumnsCount++; + } - if (this._sort && this._sort.fieldName === column.field) { - if (column.skip) { - this._sort = null; - } else { - this._sort.fieldName = column.header; + if (this._sort && this._sort.fieldName === column.field) { + if (column.skip) { + this._sort = null; + } else { + this._sort.fieldName = column.header; + } } } - } - }); + }); - this._indexOfLastPinnedColumn -= skippedPinnedColumnsCount; + indexOfLastPinnedColumn -= skippedPinnedColumnsCount; + } const dataToExport = new Array(); - const actualData = records.map(r => r.data); + const actualData = records[0]?.data; const isSpecialData = ExportUtilities.isSpecialData(actualData); yieldingLoop(records.length, 100, (i) => { const row = records[i]; this.exportRow(dataToExport, row, i, isSpecialData); }, () => { - this.exportDataImplementation(dataToExport, options); + this.exportDataImplementation(dataToExport, this.options); this.resetDefaults(); }); } private exportRow(data: IExportRecord[], record: IExportRecord, index: number, isSpecialData: boolean) { - if (!isSpecialData) { - record.data = this._columnList.reduce((a, e) => { + if (!isSpecialData && record.type !== ExportRecordType.HeaderRecord) { + const columns = record.owner === undefined ? + this._ownersMap.get(DEFAULT_OWNER).columns : + this._ownersMap.get(record.owner).columns; + + record.data = columns.reduce((a, e) => { if (!e.skip) { let rawValue = resolveNestedPath(record.data, e.field); @@ -308,7 +323,7 @@ export abstract class IgxBaseExporter { } } - private prepareData(grid: IgxGridBaseDirective, options: IgxExporterOptionsBase) { + private prepareData(grid: IgxGridBaseDirective) { this.flatRecords = []; const tagName = grid.nativeElement.tagName.toLowerCase(); @@ -318,15 +333,211 @@ export abstract class IgxBaseExporter { const hasSorting = grid.sortingExpressions && grid.sortingExpressions.length > 0; + switch(tagName) { + case 'igx-hierarchical-grid': { + this.prepareHierarchicalGridData(grid as IgxHierarchicalGridComponent, hasFiltering, hasSorting); + break; + } + case 'igx-tree-grid': { + this.prepareTreeGridData(grid as IgxTreeGridComponent, hasFiltering, hasSorting); + break; + } + default: { + this.prepareGridData(grid as IgxGridComponent, hasFiltering, hasSorting); + break; + } + } + } + + private prepareHierarchicalGridData(grid: IgxHierarchicalGridComponent, hasFiltering: boolean, hasSorting: boolean) { + + const skipOperations = + (!hasFiltering || !this.options.ignoreFiltering) && + (!hasSorting || !this.options.ignoreSorting); + + if (skipOperations) { + const data = grid.filteredSortedData; + this.addHierarchicalGridData(grid, data); + } else { + let data = grid.data; + + if (hasFiltering && !this.options.ignoreFiltering) { + const filteringState: IFilteringState = { + expressionsTree: grid.filteringExpressionsTree, + advancedExpressionsTree: grid.advancedFilteringExpressionsTree, + strategy: grid.filterStrategy + }; + + data = DataUtil.filter(data, filteringState, grid); + } - if (tagName === 'igx-grid') { - this.prepareGridData(grid as IgxGridComponent, options, hasFiltering, hasSorting); - } if (tagName === 'igx-tree-grid') { - this.prepareTreeGridData(grid as IgxTreeGridComponent, options, hasFiltering, hasSorting); + if (hasSorting && !this.options.ignoreSorting) { + this._sort = cloneValue(grid.sortingExpressions[0]); + + data = DataUtil.sort(data, grid.sortingExpressions, grid.sortStrategy, grid); + } + + this.addHierarchicalGridData(grid, data); } } - private prepareGridData(grid: IgxGridComponent, options: IgxExporterOptionsBase, hasFiltering: boolean, hasSorting: boolean) { + private addHierarchicalGridData(grid: IgxHierarchicalGridComponent, records: any[]) { + const childLayoutList = grid.childLayoutList; + const columnFields = this._ownersMap.get(grid).columns.map(col => col.field); + + for(const entry of records) { + const expansionStateVal = grid.expansionStates.has(entry) ? grid.expansionStates.get(entry) : false; + + const dataWithoutChildren = Object.keys(entry) + .filter(k => columnFields.includes(k)) + .reduce((obj, key) => { + obj[key] = entry[key]; + return obj; + }, {}); + + const hierarchicalGridRecord: IExportRecord = { + data: dataWithoutChildren, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: grid, + }; + + this.flatRecords.push(hierarchicalGridRecord); + + for (const island of childLayoutList) { + const path: IPathSegment = { + rowID: island.primaryKey ? entry[island.primaryKey] : entry, + rowIslandKey: island.key + }; + + const islandGrid = grid?.hgridAPI.getChildGrid([path]); + const keyRecordData = this.prepareIslandData(island, islandGrid, entry[island.key]) || []; + + this.getAllChildColumnsAndData(island, keyRecordData, expansionStateVal, islandGrid); + } + } + } + + private prepareIslandData(island: IgxRowIslandComponent, islandGrid: IgxHierarchicalGridComponent, data: any[]): any[] { + if (islandGrid !== undefined) { + const hasFiltering = (islandGrid.filteringExpressionsTree && + islandGrid.filteringExpressionsTree.filteringOperands.length > 0) || + (islandGrid.advancedFilteringExpressionsTree && + islandGrid.advancedFilteringExpressionsTree.filteringOperands.length > 0); + + const hasSorting = islandGrid.sortingExpressions && + islandGrid.sortingExpressions.length > 0; + + const skipOperations = + (!hasFiltering || !this.options.ignoreFiltering) && + (!hasSorting || !this.options.ignoreSorting); + + if (skipOperations) { + data = islandGrid.filteredSortedData; + } else { + if (hasFiltering && !this.options.ignoreFiltering) { + const filteringState: IFilteringState = { + expressionsTree: islandGrid.filteringExpressionsTree, + advancedExpressionsTree: islandGrid.advancedFilteringExpressionsTree, + strategy: islandGrid.filterStrategy + }; + + data = DataUtil.filter(data, filteringState, islandGrid); + } + + if (hasSorting && !this.options.ignoreSorting) { + this._sort = cloneValue(islandGrid.sortingExpressions[0]); + + data = DataUtil.sort(data, islandGrid.sortingExpressions, islandGrid.sortStrategy, islandGrid); + } + } + } else { + const hasFiltering = (island.filteringExpressionsTree && + island.filteringExpressionsTree.filteringOperands.length > 0) || + (island.advancedFilteringExpressionsTree && + island.advancedFilteringExpressionsTree.filteringOperands.length > 0); + + const hasSorting = island.sortingExpressions && + island.sortingExpressions.length > 0; + + const skipOperations = + (!hasFiltering || this.options.ignoreFiltering) && + (!hasSorting || this.options.ignoreSorting); + + if (!skipOperations) { + if (hasFiltering && !this.options.ignoreFiltering) { + const filteringState: IFilteringState = { + expressionsTree: island.filteringExpressionsTree, + advancedExpressionsTree: island.advancedFilteringExpressionsTree, + strategy: island.filterStrategy + }; + + data = DataUtil.filter(data, filteringState, island); + } + + if (hasSorting && !this.options.ignoreSorting) { + this._sort = cloneValue(island.sortingExpressions[0]); + + data = DataUtil.sort(data, island.sortingExpressions, island.sortStrategy, island); + } + } + } + + return data; + } + + private getAllChildColumnsAndData(island: IgxRowIslandComponent, + childData: any[], expansionStateVal: boolean, grid: IgxHierarchicalGridComponent) { + const islandColumnList = island.childColumns.toArray(); + const columnList = this.getColumns(islandColumnList); + const columnHeader = columnList.columns.map(col => col.header ? col.header : col.field); + + const headerRecord: IExportRecord = { + data: columnHeader, + level: island.level, + type: ExportRecordType.HeaderRecord, + owner: island, + hidden: !expansionStateVal + }; + + if (childData && childData.length > 0) { + this.flatRecords.push(headerRecord); + + for (const rec of childData) { + const exportRecord: IExportRecord = { + data: rec, + level: island.level, + type: ExportRecordType.HierarchicalGridRecord, + owner: island, + hidden: !expansionStateVal + }; + + this.flatRecords.push(exportRecord); + + if (island.children.length > 0) { + const islandExpansionStateVal = grid === undefined ? + false : + grid.expansionStates.has(rec) ? + grid.expansionStates.get(rec) : + false; + + for (const childIsland of island.children) { + const path: IPathSegment = { + rowID: childIsland.primaryKey ? rec[childIsland.primaryKey] : rec, + rowIslandKey: childIsland.key + }; + + const childIslandGrid = grid?.hgridAPI.getChildGrid([path]); + const keyRecordData = this.prepareIslandData(island, childIslandGrid, rec[childIsland.key]) || []; + + this.getAllChildColumnsAndData(childIsland, keyRecordData, islandExpansionStateVal, childIslandGrid); + } + } + } + } + } + + private prepareGridData(grid: IgxGridComponent, hasFiltering: boolean, hasSorting: boolean) { const groupedGridGroupingState: IGroupingState = { expressions: grid.groupingExpressions, expansion: grid.groupingExpansionState, @@ -337,9 +548,9 @@ export abstract class IgxBaseExporter { grid.groupingExpressions.length > 0; const skipOperations = - (!hasFiltering || !options.ignoreFiltering) && - (!hasSorting || !options.ignoreSorting) && - (!hasGrouping || !options.ignoreGrouping); + (!hasFiltering || !this.options.ignoreFiltering) && + (!hasSorting || !this.options.ignoreSorting) && + (!hasGrouping || !this.options.ignoreGrouping); if (skipOperations) { if (hasGrouping) { @@ -350,16 +561,17 @@ export abstract class IgxBaseExporter { } else { let gridData = grid.data; - if (hasFiltering && !options.ignoreFiltering) { + if (hasFiltering && !this.options.ignoreFiltering) { const filteringState: IFilteringState = { expressionsTree: grid.filteringExpressionsTree, advancedExpressionsTree: grid.advancedFilteringExpressionsTree, + strategy: grid.filterStrategy }; - filteringState.strategy = grid.filterStrategy; + gridData = DataUtil.filter(gridData, filteringState, grid); } - if (hasSorting && !options.ignoreSorting) { + if (hasSorting && !this.options.ignoreSorting) { // TODO: We should drop support for this since in a grouped grid it doesn't make sense // this._sort = !isGroupedGrid ? // cloneValue(grid.sortingExpressions[0]) : @@ -370,13 +582,13 @@ export abstract class IgxBaseExporter { gridData = DataUtil.sort(gridData, grid.sortingExpressions, grid.sortStrategy, grid); } - if (hasGrouping && !options.ignoreGrouping) { + if (hasGrouping && !this.options.ignoreGrouping) { const groupsRecords = []; DataUtil.group(cloneArray(gridData), groupedGridGroupingState, grid, groupsRecords); gridData = groupsRecords; } - if (hasGrouping && !options.ignoreGrouping) { + if (hasGrouping && !this.options.ignoreGrouping) { this.addGroupedData(grid, gridData, groupedGridGroupingState); } else { this.addFlatData(gridData); @@ -384,29 +596,28 @@ export abstract class IgxBaseExporter { } } - private prepareTreeGridData(grid: IgxTreeGridComponent, options: IgxExporterOptionsBase, hasFiltering: boolean, hasSorting: boolean) { + private prepareTreeGridData(grid: IgxTreeGridComponent, hasFiltering: boolean, hasSorting: boolean) { const skipOperations = - (!hasFiltering || !options.ignoreFiltering) && - (!hasSorting || !options.ignoreSorting); + (!hasFiltering || !this.options.ignoreFiltering) && + (!hasSorting || !this.options.ignoreSorting); if (skipOperations) { this.addTreeGridData(grid.processedRootRecords); } else { let gridData = grid.rootRecords; - if (hasFiltering && !options.ignoreFiltering) { + if (hasFiltering && !this.options.ignoreFiltering) { const filteringState: IFilteringState = { expressionsTree: grid.filteringExpressionsTree, advancedExpressionsTree: grid.advancedFilteringExpressionsTree, + strategy: (grid.filterStrategy) ? grid.filterStrategy : new TreeGridFilteringStrategy() }; - filteringState.strategy = (grid.filterStrategy) ? grid.filterStrategy : new TreeGridFilteringStrategy(); - gridData = filteringState.strategy .filter(gridData, filteringState.expressionsTree, filteringState.advancedExpressionsTree); } - if (hasSorting && !options.ignoreSorting) { + if (hasSorting && !this.options.ignoreSorting) { this._sort = cloneValue(grid.sortingExpressions[0]); gridData = DataUtil.treeGridSort(gridData, grid.sortingExpressions, grid.sortStrategy); @@ -454,7 +665,7 @@ export abstract class IgxBaseExporter { return; } - const firstCol = this._columnList[0].field; + const firstCol = this._ownersMap.get(DEFAULT_OWNER).columns[0].field; for (const record of records) { let recordVal = record.value; @@ -498,7 +709,7 @@ export abstract class IgxBaseExporter { data: rowRecord, level: record.level + 1, hidden: !(expanded && parentExpanded), - type: ExportRecordType.DataRecord + type: ExportRecordType.DataRecord, }; this.flatRecords.push(currentRecord); @@ -507,11 +718,73 @@ export abstract class IgxBaseExporter { } } + private getColumns(columns: IgxColumnComponent[]): IColumnList { + const colList = []; + const colWidthList = []; + const hiddenColumns = []; + let indexOfLastPinnedColumn = -1; + let lastVisibleColumnIndex = -1; + + columns.forEach((column) => { + const columnHeader = !ExportUtilities.isNullOrWhitespaces(column.header) ? column.header : column.field; + const exportColumn = !column.hidden || this.options.ignoreColumnsVisibility; + const index = this.options.ignoreColumnsOrder || this.options.ignoreColumnsVisibility ? column.index : column.visibleIndex; + const columnWidth = Number(column.width?.slice(0, -2)) || DEFAULT_COLUMN_WIDTH; + + const columnInfo: IColumnInfo = { + header: columnHeader, + dataType: column.dataType, + field: column.field, + skip: !exportColumn, + formatter: column.formatter, + skipFormatter: false + }; + + if (index !== -1) { + colList[index] = columnInfo; + colWidthList[index] = columnWidth; + lastVisibleColumnIndex = Math.max(lastVisibleColumnIndex, index); + } else { + hiddenColumns.push(columnInfo); + } + + if (column.pinned && exportColumn) { + indexOfLastPinnedColumn++; + } + }); + + // Append the hidden columns to the end of the list + hiddenColumns.forEach((hiddenColumn) => { + colList[++lastVisibleColumnIndex] = hiddenColumn; + }); + + const result: IColumnList = { + columns: colList, + columnWidths: colWidthList, + indexOfLastPinnedColumn + }; + + return result; + } + + private mapHierarchicalGridColumns(island: IgxRowIslandComponent) { + const islandColumnList = island.childColumns.toArray(); + const columnList = this.getColumns(islandColumnList); + + this._ownersMap.set(island, columnList); + + if (island.children.length > 0) { + for (const childIsland of island.children) { + this.mapHierarchicalGridColumns(childIsland); + } + } + } + private resetDefaults() { - this._columnList = []; - this._indexOfLastPinnedColumn = -1; this._sort = null; this.flatRecords = []; + this.options = {} as IgxExporterOptionsBase; + this._ownersMap.clear(); } protected abstract exportDataImplementation(data: any[], options: IgxExporterOptionsBase): void; diff --git a/projects/igniteui-angular/src/lib/services/exporter-common/export-utilities.ts b/projects/igniteui-angular/src/lib/services/exporter-common/export-utilities.ts index 1996b25e8d9..45fada1c1b9 100644 --- a/projects/igniteui-angular/src/lib/services/exporter-common/export-utilities.ts +++ b/projects/igniteui-angular/src/lib/services/exporter-common/export-utilities.ts @@ -19,7 +19,7 @@ export class ExportUtilities { const keys = new Set(keys1.concat(keys2).concat(keys3)); - return !ExportUtilities.isSpecialData(data) ? Array.from(keys) : [ 'Column 1' ]; + return !ExportUtilities.isSpecialData(dataEntry) ? Array.from(keys) : [ 'Column 1' ]; } public static saveBlobToFile(blob: Blob, fileName) { @@ -49,11 +49,10 @@ export class ExportUtilities { return buf; } - public static isSpecialData(data: any[]): boolean { - const dataEntry = data[0]; - return (typeof dataEntry === 'string' || - typeof dataEntry === 'number' || - dataEntry instanceof Date); + public static isSpecialData(data: any): boolean { + return (typeof data === 'string' || + typeof data === 'number' || + data instanceof Date); } public static hasValue(value: any): boolean { diff --git a/projects/igniteui-angular/src/lib/test-utils/hierarchical-grid-components.spec.ts b/projects/igniteui-angular/src/lib/test-utils/hierarchical-grid-components.spec.ts index 386bf1a3e4a..9dbf2c66558 100644 --- a/projects/igniteui-angular/src/lib/test-utils/hierarchical-grid-components.spec.ts +++ b/projects/igniteui-angular/src/lib/test-utils/hierarchical-grid-components.spec.ts @@ -305,3 +305,44 @@ export class IgxHierGridExternalAdvancedFilteringComponent extends IgxHierarchic public data = SampleTestData.generateHGridData(5, 3); } + +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` +}) +export class IgxHierarchicalGridExportComponent { + @ViewChild('hierarchicalGrid', { read: IgxHierarchicalGridComponent, static: true }) public hGrid: IgxHierarchicalGridComponent; + public data = SampleTestData.hierarchicalGridExportData(); +} diff --git a/projects/igniteui-angular/src/lib/test-utils/sample-test-data.spec.ts b/projects/igniteui-angular/src/lib/test-utils/sample-test-data.spec.ts index 490a26a9778..3ee7b21bfc9 100644 --- a/projects/igniteui-angular/src/lib/test-utils/sample-test-data.spec.ts +++ b/projects/igniteui-angular/src/lib/test-utils/sample-test-data.spec.ts @@ -1619,6 +1619,241 @@ export class SampleTestData { { Price: 100000, Brand: 'VW', Model: 'Arteon', Edition: 'R Line' }, ]); + /* Data fields: Artist, Debut, GrammyNominations, GrammyAwards, Tours, Albums, Songs */ + public static hierarchicalGridExportData = () => ([ + { + Artist: 'Naomí Yepes', + Debut: 2011, + GrammyNominations: 6, + GrammyAwards: 0, + HasGrammyAward: false, + Tours: [ + { + Tour: 'Faithful Tour', + StartedOn: 'Sep 12', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes', + TourData: [ + { + Country: 'Belgium', + TicketsSold: 10000, + Attendants: 10000, + }, + { + Country: 'USA', + TicketsSold: 192300, + Attendants: 186523, + } + ] + }, + { + Tour: 'Faithful Tour', + StartedOn: 'Sep 12', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Faithful Tour', + StartedOn: 'Sep 12', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + }, + { + Tour: 'Faithful Tour', + StartedOn: 'Sep 12', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Naomí Yepes' + } + ], + Albums: [ + { + Album: 'Pushing up daisies', + LaunchDate: new Date('May 31, 2000'), + BillboardReview: 86, + USBillboard200: 42, + Artist: 'Naomí Yepes', + Songs: [ + { + Number: 1, + Title: 'Wood Shavifdsafdsafsangs Forever', + Released: new Date('9 Jun 2019'), + Genre: '*fdasfsa', + Album: 'Pushing up daisies' + }, + { + Number: 2, + Title: 'Wood Shavifdsafdsafsavngs Forever', + Released: new Date('9 Jun 2019'), + Genre: '*vxzvczx', + Album: 'Pushing up daisies' + }, + { + Number: 3, + Title: 'Wfdsafsaings Forever', + Released: new Date('9 Jun 2019'), + Genre: '*fdsacewwwqwq', + Album: 'Pushing up daisies' + }, + { + Number: 4, + Title: 'Wood Shavings Forever', + Released: new Date('9 Jun 2019'), + Genre: '*rewqrqcxz', + Album: 'Pushing up daisies' + }, + ] + }, + { + Album: 'Pushing up daisies - Deluxe', + LaunchDate: new Date('May 31, 2001'), + BillboardReview: 12, + USBillboard200: 2, + Artist: 'Naomí Yepes', + Songs: [ + { + Number: 1, + Title: 'Wood Shavings Forever - Remix', + Released: new Date('9 Jun 2020'), + Genre: 'Punk', + Album: 'Pushing up daisies' + }, + ] + }, + { + Album: 'Utopia', + LaunchDate: new Date('Dec 19, 2021'), + BillboardReview: 1, + USBillboard200: 1, + Artist: 'Naomí Yepes', + Songs: [ + { + Number: 1, + Title: 'SANTORINI', + Released: new Date('19 Dec 2021'), + Genre: 'Hip-Hop', + Album: 'Utopia' + }, + { + Number: 2, + Title: 'HEARTBEAT', + Released: new Date('19 Dec 2021'), + Genre: 'Hip-Hop', + Album: 'Utopia' + }, + { + Number: 3, + Title: 'OVERSEAS', + Released: new Date('19 Dec 2021'), + Genre: 'Hip-Hop', + Album: 'Utopia' + }, + ] + }, + { + Album: 'Wish You Were Here', + LaunchDate: new Date('Jul 17, 2020'), + BillboardReview: 5, + USBillboard200: 3, + Artist: 'Naomí Yepes', + Songs: [ + { + Number: 1, + Title: 'Zoom', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Wish You Were Here' + }, + { + Number: 2, + Title: 'Do You?', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Wish You Were Here' + }, + { + Number: 3, + Title: 'No Photos', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Wish You Were Here' + }, + ] + } + ] + }, + { + Artist: 'Babila Ebwélé', + Debut: 2009, + GrammyNominations: 0, + GrammyAwards: 11, + HasGrammyAward: true, + Albums: [ + { + Album: 'Fahrenheit', + LaunchDate: new Date('May 31, 2000'), + BillboardReview: 86, + USBillboard200: 42, + Artist: 'Babila Ebwélé', + Songs: [ + { + Number: 1, + Title: 'Show Out', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Fahrenheit' + }, + { + Number: 2, + Title: 'Mood Swings', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Fahrenheit' + }, + { + Number: 3, + Title: 'Scenario', + Released: new Date('17 Jul 2020'), + Genre: 'Hip-Hop', + Album: 'Fahrenheit' + }, + ] + } + ], + Tours: [ + { + Tour: 'Astroworld', + StartedOn: 'Jul 21', + Location: 'Worldwide', + Headliner: 'NO', + TouredBy: 'Babila Ebwélé', + TourData: [ + { + Country: 'Bulgaria', + TicketsSold: 25000, + Attendants: 19822, + }, + { + Country: 'Romania', + TicketsSold: 65021, + Attendants: 63320, + } + ] + }, + ] + }, + { + Artist: 'Chloe', + Debut: 2015, + GrammyNominations: 3, + GrammyAwards: 1, + HasGrammyAward: true, + } + ]); + /** * Generates simple array of primitve values *