From be33a68cadbdaed0c60b00bdcd123f3a4797fb8a Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Tue, 14 Feb 2023 12:18:04 -0500 Subject: [PATCH] fix(export): Excel export auto-detect number with Formatters.multiple (#902) - this PR will now auto-detect numbers even when using Formatters.multiple, it wasn't at all prior to this PR - also improve parsing of numbers with formatter options, user might provide formatter options like `decimalSeparator: ','` and/or `thousandSeparator: ' '` and the Excel export should be able to parse this number even with these formatter options (it wasn't able to parse at all prior to this PR and was previously returning the string as is) --- .../src/examples/example12.ts | 4 +- .../src/examples/example16.ts | 22 +++- package.json | 2 +- .../__tests__/formatterUtilities.spec.ts | 10 +- .../src/formatters/formatterUtilities.ts | 22 ++-- packages/common/src/global-grid-options.ts | 4 +- .../columnExcelExportOption.interface.ts | 3 +- .../src/excelExport.service.spec.ts | 15 +-- .../excel-export/src/excelExport.service.ts | 9 +- packages/excel-export/src/excelUtils.spec.ts | 106 +++++++++++++++++- packages/excel-export/src/excelUtils.ts | 95 ++++++++++++---- .../src/salesforce-global-grid-options.ts | 3 +- test/cypress/e2e/example16.cy.ts | 2 +- 13 files changed, 227 insertions(+), 70 deletions(-) diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example12.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example12.ts index 614916cf7..1346adb99 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example12.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example12.ts @@ -220,8 +220,8 @@ export class Example12 { id: 'complexity', name: 'Complexity', field: 'complexity', minWidth: 100, type: FieldType.number, sortable: true, filterable: true, columnGroup: 'Analysis', - formatter: (_row, _cell, value) => this.complexityLevelList[value].label, - exportCustomFormatter: (_row, _cell, value) => this.complexityLevelList[value].label, + formatter: (_row, _cell, value) => this.complexityLevelList[value]?.label, + exportCustomFormatter: (_row, _cell, value) => this.complexityLevelList[value]?.label, filter: { model: Filters.multipleSelect, collection: this.complexityLevelList diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example16.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example16.ts index 2349265c5..69d442139 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example16.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example16.ts @@ -128,16 +128,22 @@ export class Example16 { }, }, { - id: 'cost', name: 'Cost', field: 'cost', + id: 'cost', name: 'Cost (in €)', field: 'cost', width: 90, sortable: true, filterable: true, - exportWithFormatter: false, + exportWithFormatter: true, // filter: { model: Filters.compoundInput }, - // formatter: Formatters.dollar, + // formatter: Formatters.currency, formatter: Formatters.multiple, - // params: { formatters: [Formatters.dollar, (row, cell, value) => `${value || ''}`] }, - params: { formatters: [Formatters.dollar, (row, cell, value) => `${value || ''}`] }, + // params: { formatters: [Formatters.currency, (row, cell, value) => `${value || ''}`] }, + params: { + formatters: [ + Formatters.currency, + (row, cell, value) => `${value || ''}` + ], + currencySuffix: ' €' + }, customTooltip: { useRegularTooltip: true, useRegularTooltipFromFormatterOnly: true, @@ -328,6 +334,10 @@ export class Example16 { textExportOptions: { exportWithFormatter: true }, + formatterOptions: { + // decimalSeparator: ',', + thousandSeparator: ' ' + }, // Custom Tooltip options can be defined in a Column or Grid Options or a mixed of both (first options found wins) registerExternalResources: [new SlickCustomTooltip(), new ExcelExportService(), new TextExportService()], customTooltip: { @@ -393,7 +403,7 @@ export class Example16 { percentComplete: Math.floor(Math.random() * (100 - 5 + 1) + 5), start: new Date(randomYear, randomMonth, randomDay), finish: randomFinish < new Date() ? '' : randomFinish, // make sure the random date is earlier than today - cost: (i % 33 === 0) ? null : Math.round(Math.random() * 10000) / 100, + cost: (i % 33 === 0) ? null : Math.round(Math.random() * 1000000) / 100, effortDriven: (i % 5 === 0), prerequisites: (i % 2 === 0) && i !== 0 && i < 50 ? [i, i - 1] : [], }; diff --git a/package.json b/package.json index 2f6e83823..ab02b3d07 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "cypress:ci": "cypress run --config-file test/cypress.config.ts", "predev": "pnpm run -r build:incremental && pnpm run -r sass:copy", "dev": "run-p dev:watch webpack:watch", - "dev:watch": "lerna watch --file-delimiter=\",\" --glob=\"src/**/*.{ts,scss}\" --ignored=\"src/**/*.spec.ts\" -- cross-env-shell pnpm run -r --filter $LERNA_PACKAGE_NAME dev", + "dev:watch": "lerna watch --no-bail --file-delimiter=\",\" --glob=\"src/**/*.{ts,scss}\" --ignored=\"src/**/*.spec.ts\" -- cross-env-shell pnpm run -r --filter $LERNA_PACKAGE_NAME dev", "webpack:watch": "pnpm -r --parallel run webpack:dev", "preview:publish": "lerna publish from-package --dry-run", "preview:version": "lerna version --dry-run", diff --git a/packages/common/src/formatters/__tests__/formatterUtilities.spec.ts b/packages/common/src/formatters/__tests__/formatterUtilities.spec.ts index 1caad128f..e3d39ec08 100644 --- a/packages/common/src/formatters/__tests__/formatterUtilities.spec.ts +++ b/packages/common/src/formatters/__tests__/formatterUtilities.spec.ts @@ -59,27 +59,23 @@ describe('formatterUtilities', () => { describe('getValueFromParamsOrGridOptions method', () => { it('should return options found in the Grid Option when not found in Column Definition "params" property', () => { const gridOptions = { formatterOptions: { minDecimal: 2 } } as GridOption; - const gridSpy = (gridStub.getOptions as jest.Mock).mockReturnValue(gridOptions); - const output = getValueFromParamsOrFormatterOptions('minDecimal', {} as Column, gridStub, -1); + const output = getValueFromParamsOrFormatterOptions('minDecimal', {} as Column, gridOptions, -1); - expect(gridSpy).toHaveBeenCalled(); expect(output).toBe(2); }); it('should return options found in the Column Definition "params" even if exist in the Grid Option as well', () => { const gridOptions = { formatterOptions: { minDecimal: 2 } } as GridOption; - const gridSpy = (gridStub.getOptions as jest.Mock).mockReturnValue(gridOptions); - const output = getValueFromParamsOrFormatterOptions('minDecimal', { params: { minDecimal: 3 } } as Column, gridStub, -1); + const output = getValueFromParamsOrFormatterOptions('minDecimal', { params: { minDecimal: 3 } } as Column, gridOptions, -1); - expect(gridSpy).toHaveBeenCalled(); expect(output).toBe(3); }); it('should return default value when not found in "params" (columnDef) neither the "formatterOptions" (gridOption)', () => { const defaultValue = 5; - const output = getValueFromParamsOrFormatterOptions('minDecimal', { field: 'column1' } as Column, {} as unknown as SlickGrid, defaultValue); + const output = getValueFromParamsOrFormatterOptions('minDecimal', { field: 'column1' } as Column, {} as unknown as GridOption, defaultValue); expect(output).toBe(defaultValue); }); }); diff --git a/packages/common/src/formatters/formatterUtilities.ts b/packages/common/src/formatters/formatterUtilities.ts index 41846d1a8..aa19cb15e 100644 --- a/packages/common/src/formatters/formatterUtilities.ts +++ b/packages/common/src/formatters/formatterUtilities.ts @@ -59,17 +59,18 @@ export function retrieveFormatterOptions(columnDef: Column, grid: SlickGrid, num default: break; } - const minDecimal = getValueFromParamsOrFormatterOptions('minDecimal', columnDef, grid, defaultMinDecimal); - const maxDecimal = getValueFromParamsOrFormatterOptions('maxDecimal', columnDef, grid, defaultMaxDecimal); - const decimalSeparator = getValueFromParamsOrFormatterOptions('decimalSeparator', columnDef, grid, Constants.DEFAULT_NUMBER_DECIMAL_SEPARATOR); - const thousandSeparator = getValueFromParamsOrFormatterOptions('thousandSeparator', columnDef, grid, Constants.DEFAULT_NUMBER_THOUSAND_SEPARATOR); - const wrapNegativeNumber = getValueFromParamsOrFormatterOptions('displayNegativeNumberWithParentheses', columnDef, grid, Constants.DEFAULT_NEGATIVE_NUMBER_WRAPPED_IN_BRAQUET); - const currencyPrefix = getValueFromParamsOrFormatterOptions('currencyPrefix', columnDef, grid, ''); - const currencySuffix = getValueFromParamsOrFormatterOptions('currencySuffix', columnDef, grid, ''); + const gridOptions = ((grid && typeof grid.getOptions === 'function') ? grid.getOptions() : {}) as GridOption; + const minDecimal = getValueFromParamsOrFormatterOptions('minDecimal', columnDef, gridOptions, defaultMinDecimal); + const maxDecimal = getValueFromParamsOrFormatterOptions('maxDecimal', columnDef, gridOptions, defaultMaxDecimal); + const decimalSeparator = getValueFromParamsOrFormatterOptions('decimalSeparator', columnDef, gridOptions, Constants.DEFAULT_NUMBER_DECIMAL_SEPARATOR); + const thousandSeparator = getValueFromParamsOrFormatterOptions('thousandSeparator', columnDef, gridOptions, Constants.DEFAULT_NUMBER_THOUSAND_SEPARATOR); + const wrapNegativeNumber = getValueFromParamsOrFormatterOptions('displayNegativeNumberWithParentheses', columnDef, gridOptions, Constants.DEFAULT_NEGATIVE_NUMBER_WRAPPED_IN_BRAQUET); + const currencyPrefix = getValueFromParamsOrFormatterOptions('currencyPrefix', columnDef, gridOptions, ''); + const currencySuffix = getValueFromParamsOrFormatterOptions('currencySuffix', columnDef, gridOptions, ''); if (formatterType === 'cell') { - numberPrefix = getValueFromParamsOrFormatterOptions('numberPrefix', columnDef, grid, ''); - numberSuffix = getValueFromParamsOrFormatterOptions('numberSuffix', columnDef, grid, ''); + numberPrefix = getValueFromParamsOrFormatterOptions('numberPrefix', columnDef, gridOptions, ''); + numberSuffix = getValueFromParamsOrFormatterOptions('numberSuffix', columnDef, gridOptions, ''); } return { minDecimal, maxDecimal, decimalSeparator, thousandSeparator, wrapNegativeNumber, currencyPrefix, currencySuffix, numberPrefix, numberSuffix }; @@ -81,8 +82,7 @@ export function retrieveFormatterOptions(columnDef: Column, grid: SlickGrid, num * 2- Grid Options "formatterOptions" * 3- nothing found, return default value provided */ -export function getValueFromParamsOrFormatterOptions(optionName: string, columnDef: Column, grid: SlickGrid, defaultValue?: any) { - const gridOptions = ((grid && typeof grid.getOptions === 'function') ? grid.getOptions() : {}) as GridOption; +export function getValueFromParamsOrFormatterOptions(optionName: string, columnDef: Column, gridOptions: GridOption, defaultValue?: any) { const params = columnDef && columnDef.params; if (params && params.hasOwnProperty(optionName)) { diff --git a/packages/common/src/global-grid-options.ts b/packages/common/src/global-grid-options.ts index 977be6af4..a46befc88 100644 --- a/packages/common/src/global-grid-options.ts +++ b/packages/common/src/global-grid-options.ts @@ -157,7 +157,7 @@ export const GlobalGridOptions: GridOption = { groupCollapsedSymbol: '⮞', groupExpandedSymbol: '⮟', groupingAggregatorRowText: '', - sanitizeDataExport: false, + sanitizeDataExport: true, }, textExportOptions: { delimiter: DelimiterType.comma, @@ -166,7 +166,7 @@ export const GlobalGridOptions: GridOption = { format: FileType.csv, groupingColumnHeaderTitle: 'Group By', groupingAggregatorRowText: '', - sanitizeDataExport: false, + sanitizeDataExport: true, useUtf8WithBom: true }, gridAutosizeColsMode: GridAutosizeColsMode.none, diff --git a/packages/common/src/interfaces/columnExcelExportOption.interface.ts b/packages/common/src/interfaces/columnExcelExportOption.interface.ts index 7e6cb4991..7aabf0946 100644 --- a/packages/common/src/interfaces/columnExcelExportOption.interface.ts +++ b/packages/common/src/interfaces/columnExcelExportOption.interface.ts @@ -1,5 +1,6 @@ import { Column } from './column.interface'; import { ExcelCellFormat } from './excelCellFormat.interface'; +import { GridOption } from './gridOption.interface'; /** Excel custom export options (formatting & width) that can be applied to a column */ export interface ColumnExcelExportOption { @@ -27,7 +28,7 @@ export interface GroupTotalExportOption { valueParserCallback?: GetGroupTotalValueCallback; } -export type GetDataValueCallback = (data: Date | string | number, columnDef: Column, excelFormatterId: number | undefined, excelStylesheet: unknown) => Date | string | number | ExcelCellFormat; +export type GetDataValueCallback = (data: Date | string | number, columnDef: Column, excelFormatterId: number | undefined, excelStylesheet: unknown, gridOptions: GridOption) => Date | string | number | ExcelCellFormat; export type GetGroupTotalValueCallback = (totals: any, columnDef: Column, groupType: string, excelStylesheet: unknown) => Date | string | number; /** diff --git a/packages/excel-export/src/excelExport.service.spec.ts b/packages/excel-export/src/excelExport.service.spec.ts index eb8b42f57..5d8d5be8e 100644 --- a/packages/excel-export/src/excelExport.service.spec.ts +++ b/packages/excel-export/src/excelExport.service.spec.ts @@ -819,13 +819,14 @@ describe('ExcelExportService', () => { let mockItem1; let mockItem2; let mockGroup1; - let parserCallbackSpy = jest.fn(); - let groupTotalParserCallbackSpy = jest.fn(); + const parserCallbackSpy = jest.fn(); + const groupTotalParserCallbackSpy = jest.fn(); beforeEach(() => { mockGridOptions.enableGrouping = true; mockGridOptions.enableTranslate = false; mockGridOptions.excelExportOptions = { sanitizeDataExport: true, addGroupIndentation: true }; + mockGridOptions.formatterOptions = { decimalSeparator: ',' }; mockColumns = [ { id: 'id', field: 'id', excludeFromExport: true }, @@ -863,7 +864,7 @@ describe('ExcelExportService', () => { }; mockItem1 = { id: 0, userId: '1E06', firstName: 'John', lastName: 'X', position: 'SALES_REP', order: 10, cost: 22 }; - mockItem2 = { id: 1, userId: '2B02', firstName: 'Jane', lastName: 'Doe', position: 'FINANCE_MANAGER', order: 10, cost: 33 }; + mockItem2 = { id: 1, userId: '2B02', firstName: 'Jane', lastName: 'Doe', position: 'FINANCE_MANAGER', order: 10, cost: '$33,01' }; mockGroup1 = { collapsed: 0, count: 2, groupingKey: '10', groups: null, level: 0, selectChecked: false, rows: [mockItem1, mockItem2], @@ -908,8 +909,8 @@ describe('ExcelExportService', () => { { metadata: { style: 1, }, value: 'Cost', }, ], ['⮟ Order: 20 (2 items)'], - ['', '1E06', 'John', 'X', 'SALES_REP', { metadata: { style: 3, type: "number", }, value: 10, }, 8888], - ['', '2B02', 'Jane', 'DOE', 'FINANCE_MANAGER', { metadata: { style: 3, type: "number", }, value: 10, }, 8888], + ['', '1E06', 'John', 'X', 'SALES_REP', { metadata: { style: 3, type: 'number', }, value: 10, }, 8888], + ['', '2B02', 'Jane', 'DOE', 'FINANCE_MANAGER', { metadata: { style: 3, type: 'number', }, value: 10, }, 8888], ['', '', '', '', '', { value: 20, metadata: { style: 5, type: 'number' } }, ''], ] }); @@ -921,7 +922,7 @@ describe('ExcelExportService', () => { numFmtId: 103, } }); - expect(parserCallbackSpy).toHaveBeenCalledWith(22, mockColumns[6], undefined, expect.anything()); + expect(parserCallbackSpy).toHaveBeenCalledWith(22, mockColumns[6], undefined, expect.anything(), mockGridOptions); }); }); @@ -1028,7 +1029,7 @@ describe('ExcelExportService', () => { let mockGroup2; let mockGroup3; let mockGroup4; - let groupTotalParserCallbackSpy = jest.fn(); + const groupTotalParserCallbackSpy = jest.fn(); beforeEach(() => { mockGridOptions.enableGrouping = true; diff --git a/packages/excel-export/src/excelExport.service.ts b/packages/excel-export/src/excelExport.service.ts index 54babc94a..bd0fe44a7 100644 --- a/packages/excel-export/src/excelExport.service.ts +++ b/packages/excel-export/src/excelExport.service.ts @@ -239,7 +239,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ * All other browsers will use plain javascript on client side to produce a file download. * @param options */ - startDownloadFile(options: { filename: string, blob: Blob, data: any[] }) { + startDownloadFile(options: { filename: string, blob: Blob, data: any[]; }) { // when using IE/Edge, then use different download call if (typeof (navigator as any).msSaveOrOpenBlob === 'function') { (navigator as any).msSaveOrOpenBlob(options.blob, options.filename); @@ -583,14 +583,15 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ } this._regularCellExcelFormats[columnDef.id] = cellStyleFormat; } - const { stylesheetFormatterId, getDataValueParser } = this._regularCellExcelFormats[columnDef.id]; - itemData = getDataValueParser(itemData, columnDef, stylesheetFormatterId, this._stylesheet); - // does the user want to sanitize the output data (remove HTML tags)? + // sanitize early, when enabled, any HTML tags (remove HTML tags) if (typeof itemData === 'string' && (columnDef.sanitizeDataExport || this._excelExportOptions.sanitizeDataExport)) { itemData = sanitizeHtmlToText(itemData as string); } + const { stylesheetFormatterId, getDataValueParser } = this._regularCellExcelFormats[columnDef.id]; + itemData = getDataValueParser(itemData, columnDef, stylesheetFormatterId, this._stylesheet, this._gridOptions); + rowOutputStrings.push(itemData); idx++; } diff --git a/packages/excel-export/src/excelUtils.spec.ts b/packages/excel-export/src/excelUtils.spec.ts index c938cbe35..482fe52e6 100644 --- a/packages/excel-export/src/excelUtils.spec.ts +++ b/packages/excel-export/src/excelUtils.spec.ts @@ -1,6 +1,6 @@ import { Column, ExcelStylesheet, FieldType, Formatters, GridOption, GroupTotalFormatters, SlickGrid } from '@slickgrid-universal/common'; -import { getExcelFormatFromGridFormatter, getNumericFormatterOptions, isColumnDateType, useCellFormatByFieldType } from './excelUtils'; +import { getExcelFormatFromGridFormatter, getExcelNumberCallback, getNumericFormatterOptions, useCellFormatByFieldType } from './excelUtils'; const mockGridOptions = { enableExcelExport: true, @@ -20,7 +20,7 @@ const stylesheetStub = { } as unknown as ExcelStylesheet; describe('excelUtils', () => { - let mockedFormatId = 135; + const mockedFormatId = 135; let createFormatSpy: any; beforeEach(() => { @@ -31,6 +31,32 @@ describe('excelUtils', () => { jest.clearAllMocks(); }); + describe('getExcelNumberCallback() method', () => { + it('should return same data when input not a number', () => { + const output = getExcelNumberCallback('something else', {} as Column, 3, {}, mockGridOptions); + expect(output).toEqual({ metadata: { style: 3 }, value: 'something else' }); + }); + + it('should return same data when input value is already a number', () => { + const output = getExcelNumberCallback(9.33, {} as Column, 3, {}, mockGridOptions); + expect(output).toEqual({ metadata: { style: 3 }, value: 9.33 }); + }); + + it('should return parsed number when input value can be parsed to a number', () => { + const output = getExcelNumberCallback('$1,209.33', {} as Column, 3, {}, mockGridOptions); + expect(output).toEqual({ metadata: { style: 3 }, value: 1209.33 }); + }); + + it('should be able to provide a number with different decimal separator as formatter options and return parsed number when input value can be parsed to a number', () => { + const output = getExcelNumberCallback( + '1 244 209,33€', {} as Column, 3, {}, + { + ...mockGridOptions, formatterOptions: { decimalSeparator: ',', thousandSeparator: ' ' } + }); + expect(output).toEqual({ metadata: { style: 3 }, value: 1244209.33 }); + }); + }); + describe('decimal formatter', () => { afterEach(() => { jest.clearAllMocks(); @@ -341,6 +367,26 @@ describe('excelUtils', () => { }); }); + it('should get formatter options for Formatters.dollarColoredBold when using Formatters.multiple and 1 of its formatter is dollarColoredBold formatter', () => { + const column = { + type: FieldType.number, formatter: Formatters.multiple, + params: { formatters: [Formatters.dollarColoredBold, Formatters.bold], displayNegativeNumberWithParentheses: true, thousandSeparator: ',' } + } as Column; + const output = getNumericFormatterOptions(column, gridStub, 'cell'); + + expect(output).toEqual({ + currencyPrefix: '', + currencySuffix: '', + decimalSeparator: '.', + maxDecimal: 4, + minDecimal: 2, + numberPrefix: '', + numberSuffix: '', + thousandSeparator: ',', + wrapNegativeNumber: true, + }); + }); + it('should get formatter options for Formatters.dollarColored', () => { const column = { type: FieldType.number, formatter: Formatters.dollarColored, @@ -401,6 +447,26 @@ describe('excelUtils', () => { }); }); + it('should get formatter options for Formatters.percent when using Formatters.multiple and 1 of its formatter is percent formatter', () => { + const column = { + type: FieldType.number, formatter: Formatters.multiple, + params: { formatters: [Formatters.percent, Formatters.bold], displayNegativeNumberWithParentheses: true, thousandSeparator: ',' } + } as Column; + const output = getNumericFormatterOptions(column, gridStub, 'cell'); + + expect(output).toEqual({ + currencyPrefix: '', + currencySuffix: '', + decimalSeparator: '.', + maxDecimal: undefined, + minDecimal: undefined, + numberPrefix: '', + numberSuffix: '', + thousandSeparator: ',', + wrapNegativeNumber: true, + }); + }); + it('should get formatter options for Formatters.percentComplete', () => { const column = { type: FieldType.number, formatter: Formatters.percentComplete, @@ -611,7 +677,8 @@ describe('excelUtils', () => { it('should get excel excel metadata style with regular number format when a custom GroupTotalFormatters is provided', () => { const columnDef = { type: FieldType.number, formatter: Formatters.decimal, - groupTotalsFormatter: (totals: any, columnDef: Column, grid: SlickGrid) => `Some Total: ${totals.sum}`, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + groupTotalsFormatter: (totals: any, _columnDef: Column, _grid: SlickGrid) => `Some Total: ${totals.sum}`, } as Column; const output = getExcelFormatFromGridFormatter(stylesheetStub, { numberFormatter: { id: 3 } }, columnDef, gridStub, 'group'); @@ -716,6 +783,39 @@ describe('excelUtils', () => { expect(output).toEqual({ groupType: '', stylesheetFormatter: { id: 3 } }); }); + + it('should get excel excel metadata style with regular number format when using Formatters.multiple and a custom Formatter is provided', () => { + const columnDef = { + type: FieldType.number, + formatter: Formatters.multiple, + params: { formatters: [() => `Something rendered`, Formatters.bold], }, + } as unknown as Column; + const output = getExcelFormatFromGridFormatter(stylesheetStub, { numberFormatter: { id: 3 } }, columnDef, gridStub, 'cell'); + + expect(output).toEqual({ groupType: '', stylesheetFormatter: { id: 3 } }); + }); + + it('should get excel excel metadata style format for Formatters.currency when using Formatters.multiple and the first multiple formatters is currency formatter', () => { + const column = { + type: FieldType.number, + formatter: Formatters.multiple, + params: { formatters: [Formatters.currency, Formatters.bold], displayNegativeNumberWithParentheses: false, thousandSeparator: ' ' } + } as Column; + const output = getExcelFormatFromGridFormatter(stylesheetStub, {}, column, gridStub, 'cell'); + + expect(output).toEqual({ groupType: '', stylesheetFormatter: { id: 135 } }); + }); + + it('should get excel excel metadata style format for Formatters.dollar when using Formatters.multiple and the last formatter is dollar formatter', () => { + const column = { + type: FieldType.number, + formatter: Formatters.multiple, + params: { formatters: [Formatters.bold, Formatters.dollar], displayNegativeNumberWithParentheses: false, thousandSeparator: ' ' } + } as Column; + const output = getExcelFormatFromGridFormatter(stylesheetStub, {}, column, gridStub, 'cell'); + + expect(output).toEqual({ groupType: '', stylesheetFormatter: { id: 135 } }); + }); }); }); }); \ No newline at end of file diff --git a/packages/excel-export/src/excelUtils.ts b/packages/excel-export/src/excelUtils.ts index db05fb9fe..f56589071 100644 --- a/packages/excel-export/src/excelUtils.ts +++ b/packages/excel-export/src/excelUtils.ts @@ -1,13 +1,16 @@ import { Column, + Constants, ExcelStylesheet, FieldType, + Formatter, Formatters, FormatterType, getColumnFieldType, GetDataValueCallback, + getValueFromParamsOrFormatterOptions, + GridOption, GroupTotalFormatters, - isNumber, retrieveFormatterOptions, sanitizeHtmlToText, SlickGrid, @@ -17,11 +20,24 @@ export type ExcelFormatter = object & { id: number; }; // define all type of potential excel data function callbacks export const getExcelSameInputDataCallback: GetDataValueCallback = (data) => data; -export const getExcelNumberCallback: GetDataValueCallback = (data, _col, excelFormatterId) => ({ - value: isNumber(data) ? +data : data, +export const getExcelNumberCallback: GetDataValueCallback = (data, column, excelFormatterId, _excelSheet, gridOptions) => ({ + value: typeof data === 'string' && /\d/g.test(data) ? parseNumberWithFormatterOptions(data, column, gridOptions) : data, metadata: { style: excelFormatterId } }); +/** Parse a number which the user might have provided formatter options (for example a user might have provided { decimalSeparator: ',', thousandSeparator: ' '}) */ +export function parseNumberWithFormatterOptions(value: any, column: Column, gridOptions: GridOption) { + let outValue = value; + if (typeof value === 'string' && value) { + const decimalSeparator = getValueFromParamsOrFormatterOptions('decimalSeparator', column, gridOptions, Constants.DEFAULT_NUMBER_DECIMAL_SEPARATOR); + const val: number | string = (decimalSeparator === ',') + ? parseFloat(value.replace(/[^0-9\,]+/g, '').replace(',', '.')) + : parseFloat(value.replace(/[^\d\.]/g, '')); + outValue = isNaN(val) ? value : val; + } + return outValue; +} + /** use different Excel Stylesheet Format as per the Field Type */ export function useCellFormatByFieldType(stylesheet: ExcelStylesheet, stylesheetFormatters: any, columnDef: Column, grid: SlickGrid) { const fieldType = getColumnFieldType(columnDef); @@ -71,28 +87,46 @@ export function getNumericFormatterOptions(columnDef: Column, grid: SlickGrid, f break; } } else { - switch (columnDef.formatter) { - case Formatters.currency: - case Formatters.dollar: - case Formatters.dollarColored: - case Formatters.dollarColoredBold: - dataType = 'currency'; - break; - case Formatters.percent: - case Formatters.percentComplete: - case Formatters.percentCompleteBar: - case Formatters.percentCompleteBarWithText: - case Formatters.percentSymbol: - dataType = 'percent'; - break; - case Formatters.decimal: - default: - // use "decimal" instead of "regular" to show optional decimals "##" in Excel - dataType = 'decimal'; - break; + // when formatter is a Formatter.multiple, we need to loop through each of its formatter to find the best numeric data type + if (columnDef.formatter === Formatters.multiple && Array.isArray(columnDef.params?.formatters)) { + dataType = 'decimal'; + for (const formatter of columnDef.params.formatters) { + dataType = getFormatterNumericDataType(formatter); + if (dataType !== 'decimal') { + break; // if we found something different than the default (decimal) then we can assume that we found our type so we can stop & return + } + } + } else { + dataType = getFormatterNumericDataType(columnDef.formatter); } } - return retrieveFormatterOptions(columnDef, grid, dataType, formatterType); + return retrieveFormatterOptions(columnDef, grid, dataType!, formatterType); +} + +export function getFormatterNumericDataType(formatter?: Formatter) { + let dataType: 'currency' | 'decimal' | 'percent' | 'regular'; + + switch (formatter) { + case Formatters.currency: + case Formatters.dollar: + case Formatters.dollarColored: + case Formatters.dollarColoredBold: + dataType = 'currency'; + break; + case Formatters.percent: + case Formatters.percentComplete: + case Formatters.percentCompleteBar: + case Formatters.percentCompleteBarWithText: + case Formatters.percentSymbol: + dataType = 'percent'; + break; + case Formatters.decimal: + default: + // use "decimal" instead of "regular" to show optional decimals "##" in Excel + dataType = 'decimal'; + break; + } + return dataType; } export function getExcelFormatFromGridFormatter(stylesheet: ExcelStylesheet, stylesheetFormatters: any, columnDef: Column, grid: SlickGrid, formatterType: FormatterType) { @@ -134,6 +168,21 @@ export function getExcelFormatFromGridFormatter(stylesheet: ExcelStylesheet, sty switch (fieldType) { case FieldType.number: switch (columnDef.formatter) { + case Formatters.multiple: + // when formatter is a Formatter.multiple, we need to loop through each of its formatter to find the best possible Excel format + if (Array.isArray(columnDef.params?.formatters)) { + for (const formatter of columnDef.params.formatters) { + const { stylesheetFormatter: stylesheetFormatterResult } = getExcelFormatFromGridFormatter(stylesheet, stylesheetFormatters, { ...columnDef, formatter } as Column, grid, formatterType); + if (stylesheetFormatterResult !== stylesheetFormatters.numberFormatter) { + stylesheetFormatter = stylesheetFormatterResult; + break; + } + } + } + if (!stylesheetFormatter) { + stylesheetFormatter = stylesheetFormatters.numberFormatter; + } + break; case Formatters.currency: case Formatters.decimal: case Formatters.dollar: diff --git a/packages/vanilla-force-bundle/src/salesforce-global-grid-options.ts b/packages/vanilla-force-bundle/src/salesforce-global-grid-options.ts index 974fc05e9..e9db5964c 100644 --- a/packages/vanilla-force-bundle/src/salesforce-global-grid-options.ts +++ b/packages/vanilla-force-bundle/src/salesforce-global-grid-options.ts @@ -34,13 +34,12 @@ export const SalesforceGlobalGridOptions = { }, enableExcelExport: true, excelExportOptions: { + exportWithFormatter: true, mimeType: '', // Salesforce doesn't like Excel MIME type (not allowed), but we can bypass the problem by using no type at all sanitizeDataExport: true }, filterTypingDebounce: 250, formatterOptions: { - minDecimal: 0, - maxDecimal: 2, thousandSeparator: ',' }, frozenHeaderWidthCalcDifferential: 2, diff --git a/test/cypress/e2e/example16.cy.ts b/test/cypress/e2e/example16.cy.ts index a5a63c275..1e48b7af5 100644 --- a/test/cypress/e2e/example16.cy.ts +++ b/test/cypress/e2e/example16.cy.ts @@ -1,5 +1,5 @@ describe('Example 16 - Regular & Custom Tooltips', { retries: 1 }, () => { - const titles = ['', 'Title', 'Duration', 'Description', 'Description 2', 'Cost', '% Complete', 'Start', 'Finish', 'Effort Driven', 'Prerequisites', 'Action']; + const titles = ['', 'Title', 'Duration', 'Description', 'Description 2', 'Cost (in €)', '% Complete', 'Start', 'Finish', 'Effort Driven', 'Prerequisites', 'Action']; const GRID_ROW_HEIGHT = 33; it('should display Example title', () => {