diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts index 452aa2dfb..ad6902717 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts @@ -38,15 +38,17 @@ export class Example5 { this.columnDefinitions = [ { id: 'title', name: 'Title', field: 'title', width: 220, cssClass: 'cell-title', - filterable: true, sortable: true, + filterable: true, sortable: true, exportWithFormatter: false, queryFieldSorter: 'id', type: FieldType.string, - formatter: Formatters.tree, + formatter: Formatters.tree, exportCustomFormatter: Formatters.treeExport + }, { id: 'duration', name: 'Duration', field: 'duration', minWidth: 90, filterable: true }, { - id: 'percentComplete', name: '% Complete', field: 'percentComplete', minWidth: 120, maxWidth: 200, + id: 'percentComplete', name: '% Complete', field: 'percentComplete', + minWidth: 120, maxWidth: 200, exportWithFormatter: false, sortable: true, filterable: true, filter: { model: Filters.compoundSlider, operator: '>=' }, - formatter: Formatters.percentCompleteBarWithText, type: FieldType.number, + formatter: Formatters.percentCompleteBar, type: FieldType.number, }, { id: 'start', name: 'Start', field: 'start', minWidth: 60, @@ -62,7 +64,8 @@ export class Example5 { }, { id: 'effortDriven', name: 'Effort Driven', width: 80, minWidth: 20, maxWidth: 80, cssClass: 'cell-effort-driven', field: 'effortDriven', - formatter: Formatters.checkmarkMaterial, cannotTriggerInsert: true, + exportWithFormatter: false, + formatter: Formatters.checkmark, cannotTriggerInsert: true, filterable: true, filter: { collection: [{ value: '', label: '' }, { value: true, label: 'True' }, { value: false, label: 'False' }], @@ -78,10 +81,8 @@ export class Example5 { enableAutoSizeColumns: true, enableAutoResize: true, enableExcelExport: true, - excelExportOptions: { - exportWithFormatter: true, - sanitizeDataExport: true - }, + exportOptions: { exportWithFormatter: true }, + excelExportOptions: { exportWithFormatter: true }, registerExternalResources: [new ExcelExportService()], enableFiltering: true, showCustomFooter: true, // display some metrics in the bottom custom footer diff --git a/packages/common/src/formatters/__tests__/treeExportFormatter.spec.ts b/packages/common/src/formatters/__tests__/treeExportFormatter.spec.ts new file mode 100644 index 000000000..e3880d326 --- /dev/null +++ b/packages/common/src/formatters/__tests__/treeExportFormatter.spec.ts @@ -0,0 +1,132 @@ +import { Column, SlickDataView, GridOption, SlickGrid } from '../../interfaces/index'; +import { treeExportFormatter } from '../treeExportFormatter'; + +const dataViewStub = { + getIdxById: jest.fn(), + getItemByIdx: jest.fn(), + getIdPropertyName: jest.fn(), +} as unknown as SlickDataView; + +const gridStub = { + getData: jest.fn(), + getOptions: jest.fn(), +} as unknown as SlickGrid; + +describe('Tree Export Formatter', () => { + let dataset: any[]; + let mockGridOptions: GridOption; + + beforeEach(() => { + dataset = [ + { id: 0, firstName: 'John', lastName: 'Smith', fullName: 'John Smith', email: 'john.smith@movie.com', address: { zip: 123456 }, parentId: null, indent: 0 }, + { id: 1, firstName: 'Jane', lastName: 'Doe', fullName: 'Jane Doe', email: 'jane.doe@movie.com', address: { zip: 222222 }, parentId: 0, indent: 1 }, + { id: 2, firstName: 'Bob', lastName: 'Cane', fullName: 'Bob Cane', email: 'bob.cane@movie.com', address: { zip: 333333 }, parentId: 1, indent: 2, __collapsed: true }, + { id: 3, firstName: 'Barbara', lastName: 'Cane', fullName: 'Barbara Cane', email: 'barbara.cane@movie.com', address: { zip: 444444 }, parentId: null, indent: 0, __collapsed: true }, + { id: 4, firstName: 'Anonymous', lastName: 'Doe', fullName: 'Anonymous < Doe', email: 'anonymous.doe@anom.com', address: { zip: 556666 }, parentId: null, indent: 0, __collapsed: true }, + ]; + mockGridOptions = { + treeDataOptions: { levelPropName: 'indent' } + } as GridOption; + jest.spyOn(gridStub, 'getOptions').mockReturnValue(mockGridOptions); + }); + + it('should throw an error when oarams are mmissing', () => { + expect(() => treeExportFormatter(1, 1, 'blah', {} as Column, {}, gridStub)) + .toThrowError('You must provide valid "treeDataOptions" in your Grid Options and it seems that there are no tree level found in this row'); + }); + + it('should return empty string when DataView is not correctly formed', () => { + const output = treeExportFormatter(1, 1, '', {} as Column, dataset[1], gridStub); + expect(output).toBe(''); + }); + + it('should return empty string when value is null', () => { + const output = treeExportFormatter(1, 1, null, {} as Column, dataset[1], gridStub); + expect(output).toBe(''); + }); + + it('should return empty string when value is undefined', () => { + const output = treeExportFormatter(1, 1, undefined, {} as Column, dataset[1], gridStub); + expect(output).toBe(''); + }); + + it('should return empty string when item is undefined', () => { + const output = treeExportFormatter(1, 1, 'blah', {} as Column, undefined, gridStub); + expect(output).toBe(''); + }); + + it('should return a span without any icon and ', () => { + jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); + jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); + jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[0]); + + const output = treeExportFormatter(1, 1, dataset[0]['firstName'], {} as Column, dataset[0], gridStub); + expect(output).toBe(`John`); + }); + + it('should return a span without any icon and 15px indentation of a tree level 1', () => { + jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); + jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); + jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); + + const output = treeExportFormatter(1, 1, dataset[1]['firstName'], {} as Column, dataset[1], gridStub); + expect(output).toBe(`. Jane`); + }); + + it('should return a span without any icon and 30px indentation of a tree level 2', () => { + jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); + jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); + jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); + + const output = treeExportFormatter(1, 1, dataset[2]['firstName'], {} as Column, dataset[2], gridStub); + expect(output).toBe(`. Bob`); + }); + + it('should return a span with expanded icon and 15px indentation of a tree level 1 when current item is greater than next item', () => { + jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); + jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); + jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); + + const output = treeExportFormatter(1, 1, dataset[1]['firstName'], {} as Column, dataset[1], gridStub); + expect(output).toBe(`⮟ Jane`); + }); + + it('should return a span with collapsed icon and 0px indentation of a tree level 0 when current item is lower than next item', () => { + jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); + jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); + jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); + + const output = treeExportFormatter(1, 1, dataset[3]['firstName'], {} as Column, dataset[3], gridStub); + expect(output).toBe(`⮞ Barbara`); + }); + + it('should execute "queryFieldNameGetterFn" callback to get field name to use when it is defined', () => { + jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); + jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); + jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); + + const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: (dataContext) => 'fullName' } as Column; + const output = treeExportFormatter(1, 1, null, mockColumn as Column, dataset[3], gridStub); + expect(output).toBe(`⮞ Barbara Cane`); + }); + + it('should execute "queryFieldNameGetterFn" callback to get field name and also apply html encoding when output value includes a character that should be encoded', () => { + jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); + jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(2); + jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); + + const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: (dataContext) => 'fullName' } as Column; + const output = treeExportFormatter(1, 1, null, mockColumn as Column, dataset[4], gridStub); + expect(output).toBe(`⮞ Anonymous < Doe`); + }); + + it('should execute "queryFieldNameGetterFn" callback to get field name, which has (.) dot notation reprensenting complex object', () => { + jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); + jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); + jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); + + const mockColumn = { id: 'zip', field: 'zip', queryFieldNameGetterFn: (dataContext) => 'address.zip' } as Column; + const output = treeExportFormatter(1, 1, null, mockColumn as Column, dataset[3], gridStub); + expect(output).toBe(`⮞ 444444`); + }); +}); diff --git a/packages/common/src/formatters/formatters.index.ts b/packages/common/src/formatters/formatters.index.ts index 6589e1d50..833782ce5 100644 --- a/packages/common/src/formatters/formatters.index.ts +++ b/packages/common/src/formatters/formatters.index.ts @@ -32,6 +32,7 @@ import { percentCompleteFormatter } from './percentCompleteFormatter'; import { percentSymbolFormatter } from './percentSymbolFormatter'; import { progressBarFormatter } from './progressBarFormatter'; import { translateFormatter } from './translateFormatter'; +import { treeExportFormatter } from './treeExportFormatter'; import { treeFormatter } from './treeFormatter'; import { translateBooleanFormatter } from './translateBooleanFormatter'; import { uppercaseFormatter } from './uppercaseFormatter'; @@ -237,6 +238,9 @@ export const Formatters = { /** Formatter that must be use with a Tree Data column */ tree: treeFormatter, + /** Formatter that must be use with a Tree Data column for Exporting the data */ + treeExport: treeExportFormatter, + /** Takes a value and displays it all uppercase */ uppercase: uppercaseFormatter, diff --git a/packages/common/src/formatters/index.ts b/packages/common/src/formatters/index.ts index 7fc2f3e72..8fae8b29c 100644 --- a/packages/common/src/formatters/index.ts +++ b/packages/common/src/formatters/index.ts @@ -32,6 +32,7 @@ export * from './percentSymbolFormatter'; export * from './progressBarFormatter'; export * from './translateFormatter'; export * from './translateBooleanFormatter'; +export * from './treeExportFormatter'; export * from './treeFormatter'; export * from './uppercaseFormatter'; export * from './yesNoFormatter'; diff --git a/packages/common/src/formatters/treeExportFormatter.ts b/packages/common/src/formatters/treeExportFormatter.ts new file mode 100644 index 000000000..f61a35164 --- /dev/null +++ b/packages/common/src/formatters/treeExportFormatter.ts @@ -0,0 +1,48 @@ +import { SlickDataView, Formatter } from './../interfaces/index'; +import { addWhiteSpaces, getDescendantProperty } from '../services/utilities'; + +/** Formatter that must be use with a Tree Data column */ +export const treeExportFormatter: Formatter = (_row, _cell, value, columnDef, dataContext, grid) => { + const dataView = grid?.getData(); + const gridOptions = grid?.getOptions(); + const treeDataOptions = gridOptions?.treeDataOptions; + const treeLevelPropName = treeDataOptions?.levelPropName ?? '__treeLevel'; + const indentMarginLeft = treeDataOptions?.exportIndentMarginLeft ?? 4; + const groupCollapsedSymbol = gridOptions?.excelExportOptions?.groupCollapsedSymbol ?? '⮞'; + const groupExpandedSymbol = gridOptions?.excelExportOptions?.groupExpandedSymbol ?? '⮟'; + let outputValue = value; + + if (typeof columnDef.queryFieldNameGetterFn === 'function') { + const fieldName = columnDef.queryFieldNameGetterFn(dataContext); + if (fieldName?.indexOf('.') >= 0) { + outputValue = getDescendantProperty(dataContext, fieldName); + } else { + outputValue = dataContext.hasOwnProperty(fieldName) ? dataContext[fieldName] : value; + } + } + if (outputValue === null || outputValue === undefined || dataContext === undefined) { + return ''; + } + + if (!dataContext.hasOwnProperty(treeLevelPropName)) { + throw new Error('You must provide valid "treeDataOptions" in your Grid Options and it seems that there are no tree level found in this row'); + } + + if (dataView?.getItemByIdx) { + const identifierPropName = dataView.getIdPropertyName() || 'id'; + const treeLevel = dataContext[treeLevelPropName] || 0; + const spacer = addWhiteSpaces(indentMarginLeft * treeLevel); + const idx = dataView.getIdxById(dataContext[identifierPropName]); + const nextItemRow = dataView.getItemByIdx((idx || 0) + 1); + + if (nextItemRow?.[treeLevelPropName] > treeLevel) { + if (dataContext.__collapsed) { + return `${groupCollapsedSymbol} ${spacer} ${outputValue}`; + } else { + return `${groupExpandedSymbol} ${spacer} ${outputValue}`; + } + } + return treeLevel === 0 ? outputValue : `.${spacer} ${outputValue}`; + } + return ''; +}; diff --git a/packages/common/src/global-grid-options.ts b/packages/common/src/global-grid-options.ts index d363a2065..c234e066f 100644 --- a/packages/common/src/global-grid-options.ts +++ b/packages/common/src/global-grid-options.ts @@ -1,5 +1,5 @@ import { DelimiterType, EventNamingStyle, FileType, GridAutosizeColsMode, OperatorType } from './enums/index'; -import { Column, GridOption } from './interfaces/index'; +import { Column, GridOption, TreeDataOption } from './interfaces/index'; import { Filters } from './filters'; /** Global Grid Options Defaults */ @@ -137,8 +137,8 @@ export const GlobalGridOptions: GridOption = { filename: 'export', format: FileType.xlsx, groupingColumnHeaderTitle: 'Group By', - groupCollapsedSymbol: '\u25B9', - groupExpandedSymbol: '\u25BF', + groupCollapsedSymbol: '⮞', + groupExpandedSymbol: '⮟', groupingAggregatorRowText: '', sanitizeDataExport: false, }, @@ -228,6 +228,10 @@ export const GlobalGridOptions: GridOption = { resizeFormatterPaddingWidthInPx: 0, resizeDefaultRatioForStringType: 0.88, resizeMaxItemToInspectCellContentWidth: 1000, + treeDataOptions: { + exportIndentMarginLeft: 4, + exportIndentationLeadingChar: '.', + } as unknown as TreeDataOption }; /** diff --git a/packages/common/src/interfaces/excelExportOption.interface.ts b/packages/common/src/interfaces/excelExportOption.interface.ts index cf4385204..449d1ca35 100644 --- a/packages/common/src/interfaces/excelExportOption.interface.ts +++ b/packages/common/src/interfaces/excelExportOption.interface.ts @@ -27,10 +27,10 @@ export interface ExcelExportOption { /** The default text to display in 1st column of the File Export, which will identify that the current row is a Grouping Aggregator */ groupingAggregatorRowText?: string; - /** Symbol use to show that the group title is collapsed (you can use unicode like '\u25B9' or '\u25B7') */ + /** Symbol use to show that the group title is collapsed (you can use unicode like '⮞' or '\u25B7') */ groupCollapsedSymbol?: string; - /** Symbol use to show that the group title is expanded (you can use unicode like '\u25BF' or '\u25BD') */ + /** Symbol use to show that the group title is expanded (you can use unicode like '⮟' or '\u25BD') */ groupExpandedSymbol?: string; /** Defaults to false, which leads to Sanitizing all data (striping out any HTML tags) when being evaluated on export. */ diff --git a/packages/common/src/interfaces/treeDataOption.interface.ts b/packages/common/src/interfaces/treeDataOption.interface.ts index 7dbcb074e..58177f120 100644 --- a/packages/common/src/interfaces/treeDataOption.interface.ts +++ b/packages/common/src/interfaces/treeDataOption.interface.ts @@ -40,4 +40,16 @@ export interface TreeDataOption { * For example if tree depth level is 2, the calculation will be (2 * 15 = 30), so the column will be displayed 30px from the left */ indentMarginLeft?: number; + + /** + * Defaults to 4, indentation spaces to add from the left (calculated by the tree level multiplied by this number). + * For example if tree depth level is 2, the calculation will be (2 * 15 = 30), so the column will be displayed 30px from the left + */ + exportIndentMarginLeft?: number; + + /** + * Defaults to dot (.), we added this because Excel seems to trim spaces leading character + * and if we add a regular character like a dot then it keeps all tree level indentation spaces + */ + exportIndentationLeadingChar?: string; } diff --git a/packages/excel-export/src/excelExport.service.spec.ts b/packages/excel-export/src/excelExport.service.spec.ts index 52aa3c558..bc25184ec 100644 --- a/packages/excel-export/src/excelExport.service.spec.ts +++ b/packages/excel-export/src/excelExport.service.spec.ts @@ -724,7 +724,7 @@ describe('ExcelExportService', () => { { metadata: { style: 1, }, value: 'Position', }, { metadata: { style: 1, }, value: 'Order', }, ], - ['▿ Order: 20 (2 items)'], + ['⮟ Order: 20 (2 items)'], ['', '1E06', 'John', 'Z', 'SALES_REP', '10'], ['', '2B02', 'Jane', 'DOE', 'FINANCE_MANAGER', '10'], ['', '', '', '', '', 'Custom: 20'], @@ -818,7 +818,7 @@ describe('ExcelExportService', () => { { metadata: { style: 1, }, value: 'Position', }, { metadata: { style: 1, }, value: 'Order', }, ], - ['▿ Order: 20 (2 items)'], + ['⮟ Order: 20 (2 items)'], ['', '1E06', 'John', 'Z', 'Sales Rep.', '10'], ['', '2B02', 'Jane', 'DOE', 'Finance Manager', '10'], ['', '', '', '', '', '20'], @@ -941,12 +941,12 @@ describe('ExcelExportService', () => { { metadata: { style: 1, }, value: 'Position', }, { metadata: { style: 1, }, value: 'Order', }, ], - ['▿ Order: 20 (2 items)'], - ['▿ Last Name: Z (1 items)'], // expanded + ['⮟ Order: 20 (2 items)'], + ['⮟ Last Name: Z (1 items)'], // expanded ['', '1E06', 'John', 'Z', 'Sales Rep.', '10'], - ['▿ Last Name: Doe (1 items)'], // expanded + ['⮟ Last Name: Doe (1 items)'], // expanded ['', '2B02', 'Jane', 'DOE', 'Finance Manager', '10'], - ['▹ Last Name: null (0 items)'], // collapsed + ['⮞ Last Name: null (0 items)'], // collapsed ['', '', '', '', '', '20'], ['', '', '', '', '', '10'], ] @@ -1365,8 +1365,8 @@ describe('ExcelExportService', () => { describe('grid with colspan', () => { let mockCollection; - let oddMetatadata = { columns: { lastName: { colspan: 2 } } } as ItemMetadata; - let evenMetatadata = { columns: { 0: { colspan: '*' } } } as ItemMetadata; + const oddMetatadata = { columns: { lastName: { colspan: 2 } } } as ItemMetadata; + const evenMetatadata = { columns: { 0: { colspan: '*' } } } as ItemMetadata; beforeEach(() => { mockGridOptions.enableTranslate = true; diff --git a/packages/excel-export/src/excelExport.service.ts b/packages/excel-export/src/excelExport.service.ts index 81c28f128..a4816dcd7 100644 --- a/packages/excel-export/src/excelExport.service.ts +++ b/packages/excel-export/src/excelExport.service.ts @@ -595,8 +595,8 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ const groupName = sanitizeHtmlToText(itemObj.title); if (this._excelExportOptions && this._excelExportOptions.addGroupIndentation) { - const collapsedSymbol = this._excelExportOptions && this._excelExportOptions.groupCollapsedSymbol || '\u25B9'; - const expandedSymbol = this._excelExportOptions && this._excelExportOptions.groupExpandedSymbol || '\u25BF'; + const collapsedSymbol = this._excelExportOptions && this._excelExportOptions.groupCollapsedSymbol || '⮞'; + const expandedSymbol = this._excelExportOptions && this._excelExportOptions.groupExpandedSymbol || '⮟'; const chevron = itemObj.collapsed ? collapsedSymbol : expandedSymbol; return chevron + ' ' + addWhiteSpaces(5 * itemObj.level) + groupName; } diff --git a/packages/excel-export/src/interfaces/excelExportOption.interface.ts b/packages/excel-export/src/interfaces/excelExportOption.interface.ts index 2f16f17fb..a23f1a5d3 100644 --- a/packages/excel-export/src/interfaces/excelExportOption.interface.ts +++ b/packages/excel-export/src/interfaces/excelExportOption.interface.ts @@ -27,10 +27,10 @@ export interface ExcelExportOption { /** The default text to display in 1st column of the File Export, which will identify that the current row is a Grouping Aggregator */ groupingAggregatorRowText?: string; - /** Symbol use to show that the group title is collapsed (you can use unicode like '\u25B9' or '\u25B7') */ + /** Symbol use to show that the group title is collapsed (you can use unicode like '⮞' or '\u25B7') */ groupCollapsedSymbol?: string; - /** Symbol use to show that the group title is expanded (you can use unicode like '\u25BF' or '\u25BD') */ + /** Symbol use to show that the group title is expanded (you can use unicode like '⮟' or '\u25BD') */ groupExpandedSymbol?: string; /** Defaults to false, which leads to Sanitizing all data (striping out any HTML tags) when being evaluated on export. */