From 884b6e0c8f9bbff736517b4b8ab131d7141aaff5 Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Sat, 25 May 2024 16:43:13 -0400 Subject: [PATCH] feat(export): add missing `valueParserCallback` dataContext & new demo (#1543) * feat(export): add missing `valueParserCallback` dataContext * fix(excel): the `excelExportOptions.style` should work on regular cells * fix(export): Excel export of Group should work with Excel metadata --- docs/grid-functionalities/Export-to-Excel.md | 93 +++- .../src/app-routing.ts | 2 + .../vite-demo-vanilla-bundle/src/app.html | 3 + .../src/examples/example23.html | 42 ++ .../src/examples/example23.scss | 19 + .../src/examples/example23.ts | 452 ++++++++++++++++++ .../columnExcelExportOption.interface.ts | 10 +- .../src/excelExport.service.spec.ts | 17 +- .../excel-export/src/excelExport.service.ts | 18 +- packages/excel-export/src/excelUtils.spec.ts | 12 +- packages/excel-export/src/excelUtils.ts | 8 +- test/cypress/e2e/example23.cy.ts | 194 ++++++++ 12 files changed, 824 insertions(+), 46 deletions(-) create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example23.html create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example23.scss create mode 100644 examples/vite-demo-vanilla-bundle/src/examples/example23.ts create mode 100644 test/cypress/e2e/example23.cy.ts diff --git a/docs/grid-functionalities/Export-to-Excel.md b/docs/grid-functionalities/Export-to-Excel.md index e6bbc0bdb..c919215f9 100644 --- a/docs/grid-functionalities/Export-to-Excel.md +++ b/docs/grid-functionalities/Export-to-Excel.md @@ -313,8 +313,29 @@ Below is a preview of the previous customizations shown above ![image](https://user-images.githubusercontent.com/643976/208590003-b637dcda-5164-42cc-bfad-e921a22c1837.png) + +### Cell Format Auto-Detect Disable +##### requires `v3.2.0` or higher +The system will auto-detect the Excel format to use for Date and Number field types, if for some reason you wish to disable it then you provide the excel export options below + +```ts +// via column +this.columnDefinitions = [ + { + id: 'cost', name: 'Cost', field: 'cost', type: FieldType.number + excelExportOptions: { autoDetectCellFormat: false } + } +]; + +// OR via grid options (column option always win) +this.gridOptions = { + // ... + excelExportOptions: { autoDetectCellFormat: false } +}; +``` + ### Cell Value Parser -This is not recommended but if you have no other ways, you can also provide a cell value parser function callback to override what the system detected. +This is not recommended but if you have no other ways, you can also provide a `valueParserCallback` callback function to override what the system detected. This callback function is available for both `excelExportOptions` (regular cells) and `groupTotalsExcelExportOptions` (grouping total cells) ```ts this.columnDefinitions = [ @@ -341,27 +362,71 @@ this.columnDefinitions = [ const groupType = 'sum'; const fieldName = columnDef.field; return totals[groupType][fieldName]; - }, + }, + } } ]; ``` -### Cell Format Auto-Detect Disable -##### requires `v3.2.0` or higher -The system will auto-detect the Excel format to use for Date and Number field types, if for some reason you wish to disable it then you provide the excel export options below +By using `valueParserCallback`, there a lot of extra customizations that you can do with it. You could even use Excel Formula to do calculation even based on other fields on your item data context, the code below is calculating Sub-Total and Total. It's a lot of code but it shows the real power customization that exist. If you want to go with even more customization, the new [Example 23](https://ghiscoding.github.io/slickgrid-universal/#/example23) even shows you how to summarize Groups with Excel Formulas (but be warned, it does take a fair amount of code and logic to implement by yourself) ```ts -// via column this.columnDefinitions = [ { - id: 'cost', name: 'Cost', field: 'cost', type: FieldType.number - excelExportOptions: { autoDetectCellFormat: false } + id: 'cost', name: 'Cost', field: 'cost', width: 80, + type: FieldType.number, + + // use Formatters in the UI + formatter: Formatters.dollar, + groupTotalsFormatter: GroupTotalFormatters.sumTotalsDollar, + + // but use the parser callback to customize our Excel export by using Excel Formulas + excelExportOptions: { + // you can also style the Excel cells (note again that HTML color "#" is escaped as "FF" prefix) + style: { + font: { bold: true, color: 'FF215073' }, + format: '$0.00', // currency dollar format + }, + width: 12, + valueParserCallback: (_data, columnDef: Column, excelFormatterId: number | undefined, _stylesheet, _gridOptions, dataRowIdx: number, dataContext: GroceryItem) => { + // assuming that we want to calculate: (Price * Qty) => Sub-Total + const colOffset = !this.isDataGrouped ? 1 : 0; // col offset of 1x because we skipped 1st column OR 0 offset if we use a Group because the Group column replaces the skip + const rowOffset = 3; // row offset of 3x because: 1x Title, 1x Headers and Excel row starts at 1 => 3 + const priceIdx = this.sgb.slickGrid?.getColumnIndex('price') || 0; + const qtyIdx = this.sgb.slickGrid?.getColumnIndex('qty') || 0; + const taxesIdx = this.sgb.slickGrid?.getColumnIndex('taxes') || 0; + + // the code below calculates Excel column position dynamically, technically Price is at "B" and Qty is "C" + // Note: if you know the Excel column (A, B, C, ...) then portion of the code below could be skipped (the code below is fully dynamic) + const excelPriceCol = `${String.fromCharCode('A'.charCodeAt(0) + priceIdx - colOffset)}${dataRowIdx + rowOffset}`; + const excelQtyCol = `${String.fromCharCode('A'.charCodeAt(0) + qtyIdx - colOffset)}${dataRowIdx + rowOffset}`; + const excelTaxesCol = `${String.fromCharCode('A'.charCodeAt(0) + taxesIdx - colOffset)}${dataRowIdx + rowOffset}`; + + // `value` is our Excel cells to calculat (e.g.: "B4*C4") + // metadata `type` has to be set to "formula" and the `style` is what we defined in `excelExportOptions.style` which is `excelFormatterId` in the callback arg + + let excelVal = ''; + switch (columnDef.id) { + case 'subTotal': + excelVal = `${excelPriceCol}*${excelQtyCol}`; // like "C4*D4" + break; + case 'taxes': + excelVal = (dataContext.taxable) + ? `${excelPriceCol}*${excelQtyCol}*${this.taxRate / 100}` + : ''; + break; + case 'total': + excelVal = `(${excelPriceCol}*${excelQtyCol})+${excelTaxesCol}`; + break; + } + + // use "formula" as "metadata", the "style" is a formatter id that comes from any custom "style" defined outside of our callback + return { value: excelVal, metadata: { type: 'formula', style: excelFormatterId } }; + } + }, } ]; +``` -// OR via grid options (column option always win) -this.gridOptions = { - // ... - excelExportOptions: { autoDetectCellFormat: false } -}; -``` \ No newline at end of file +#### use Excel Formulas to calculate Totals by using other dataContext props +![image](https://github.com/ghiscoding/slickgrid-universal/assets/643976/871c2d84-33b2-41af-ac55-1f7eadb79cb8) diff --git a/examples/vite-demo-vanilla-bundle/src/app-routing.ts b/examples/vite-demo-vanilla-bundle/src/app-routing.ts index 3c71f2ed6..f09d80d6c 100644 --- a/examples/vite-demo-vanilla-bundle/src/app-routing.ts +++ b/examples/vite-demo-vanilla-bundle/src/app-routing.ts @@ -23,6 +23,7 @@ import Example19 from './examples/example19'; import Example20 from './examples/example20'; import Example21 from './examples/example21'; import Example22 from './examples/example22'; +import Example23 from './examples/example23'; export class AppRouting { constructor(private config: RouterConfig) { @@ -51,6 +52,7 @@ export class AppRouting { { route: 'example20', name: 'example20', view: './examples/example20.html', viewModel: Example20, title: 'Example20', }, { route: 'example21', name: 'example21', view: './examples/example21.html', viewModel: Example21, title: 'Example21', }, { route: 'example22', name: 'example22', view: './examples/example22.html', viewModel: Example22, title: 'Example22', }, + { route: 'example23', name: 'example23', view: './examples/example23.html', viewModel: Example23, title: 'Example23', }, { route: '', redirect: 'example01' }, { route: '**', redirect: 'example01' } ]; diff --git a/examples/vite-demo-vanilla-bundle/src/app.html b/examples/vite-demo-vanilla-bundle/src/app.html index 12f0efdde..21b88503e 100644 --- a/examples/vite-demo-vanilla-bundle/src/app.html +++ b/examples/vite-demo-vanilla-bundle/src/app.html @@ -102,6 +102,9 @@

Slickgrid-Universal

Example22 - Row Based Editing + + Example23 - Excel Export Formulas + diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example23.html b/examples/vite-demo-vanilla-bundle/src/examples/example23.html new file mode 100644 index 000000000..3f931d548 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example23.html @@ -0,0 +1,42 @@ +

+ Example 23 - Excel Export Formulas + +

+ +
+ Calculate Totals via Formatters in the UI, but use Excel Formula when exporting via excelExportOptions.valueParserCallback +
+
+ When Grouped we will also calculate the Group Totals in the UI via Group Formatter and we again use Excel Formula to calculate the Group Totals (sum) dynamically. For Grouping we need to use groupTotalsExcelExportOptions.valueParserCallback instead. +
+ +
+
+ + + + + + Tax Rate: + + + + +
+
+ +
+
\ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example23.scss b/examples/vite-demo-vanilla-bundle/src/examples/example23.scss new file mode 100644 index 000000000..27be2a240 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example23.scss @@ -0,0 +1,19 @@ +.grid23 { + .slick-row:not(.slick-group) >.cell-unselectable { + background: #ececec !important; + font-weight: bold; + } + + .text-sub-total { + font-style: italic; + color: rgb(33, 80, 115); + } + .text-taxes { + font-style: italic; + color: rgb(198, 89, 17); + } + .text-total { + font-weight: bold; + color: rgb(0, 90, 158); + } +} \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example23.ts b/examples/vite-demo-vanilla-bundle/src/examples/example23.ts new file mode 100644 index 000000000..c8ee671f0 --- /dev/null +++ b/examples/vite-demo-vanilla-bundle/src/examples/example23.ts @@ -0,0 +1,452 @@ +import { + type Aggregator, + Aggregators, + type Column, + Editors, + FieldType, + type Formatter, + Formatters, + type GridOption, + type Grouping, + GroupTotalFormatters, + type SlickGrid, + type SlickGroupTotals, +} from '@slickgrid-universal/common'; +import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; +import { ExcelExportService } from '@slickgrid-universal/excel-export'; + +import { ExampleGridOptions } from './example-grid-options'; +import './example23.scss'; +import { BindingEventService } from '@slickgrid-universal/binding'; + +interface GroceryItem { + id: number; + name: string; + qty: number; + price: number; + taxable: boolean; + subTotal: number; + taxes: number; + total: number; +} + +/** Check if the current item (cell) is editable or not */ +function checkItemIsEditable(_dataContext: GroceryItem, columnDef: Column, grid: SlickGrid) { + const gridOptions = grid.getOptions(); + const hasEditor = columnDef.editor; + const isGridEditable = gridOptions.editable; + const isEditable = (isGridEditable && hasEditor); + + return isEditable; +} + +const customEditableInputFormatter: Formatter = (_row, _cell, value, columnDef, dataContext: GroceryItem, grid) => { + const isEditableItem = checkItemIsEditable(dataContext, columnDef, grid); + value = (value === null || value === undefined) ? '' : value; + const divElm = document.createElement('div'); + divElm.className = 'editing-field'; + if (value instanceof HTMLElement) { + divElm.appendChild(value); + } else { + divElm.textContent = value; + } + return isEditableItem ? divElm : value; +}; + +/** Create a Custom Aggregator in order to calculate all Totals by accessing other fields of the item dataContext */ +export class CustomSumAggregator implements Aggregator { + private _sum = 0; + private _type = 'sum' as const; + + constructor(public readonly field: number | string, public taxRate: number) { } + + get type(): string { + return this._type; + } + + init() { + this._sum = 0; + } + + accumulate(item: GroceryItem) { + if (this.field === 'taxes' && item['taxable']) { + this._sum += item['price'] * item['qty'] * (this.taxRate / 100); + } + if (this.field === 'subTotal') { + this._sum += item['price'] * item['qty']; + } + if (this.field === 'total') { + let taxes = 0; + if (item['taxable']) { + taxes = item['price'] * item['qty'] * (this.taxRate / 100); + } + this._sum += item['price'] * item['qty'] + taxes; + } + } + + storeResult(groupTotals: any) { + if (!groupTotals || groupTotals[this._type] === undefined) { + groupTotals[this._type] = {}; + } + groupTotals[this._type][this.field] = this._sum; + } +} + +export default class Example19 { + private _bindingEventService: BindingEventService; + columnDefinitions: Column[] = []; + dataset: any[] = []; + gridOptions!: GridOption; + gridContainerElm: HTMLDivElement; + sgb: SlickVanillaGridBundle; + excelExportService: ExcelExportService; + isDataGrouped = false; + taxRate = 7.5; + + constructor() { + this.excelExportService = new ExcelExportService(); + this._bindingEventService = new BindingEventService(); + } + + attached() { + // define the grid options & columns and then create the grid itself + this.defineGrid(); + + // mock some data (different in each dataset) + this.dataset = this.getData(); + this.gridContainerElm = document.querySelector('.grid23') as HTMLDivElement; + this._bindingEventService.bind(this.gridContainerElm, 'oncellchange', this.invalidateAll.bind(this)); + + this.sgb = new Slicker.GridBundle(document.querySelector('.grid23') as HTMLDivElement, this.columnDefinitions, { ...ExampleGridOptions, ...this.gridOptions }, this.dataset); + document.body.classList.add('salesforce-theme'); + } + + dispose() { + this._bindingEventService.unbindAll(); + this.sgb?.dispose(); + this.gridContainerElm.remove(); + document.body.classList.remove('salesforce-theme'); + } + + /* Define grid Options and Columns */ + defineGrid() { + this.columnDefinitions = [ + { + id: 'sel', name: '#', field: 'id', + headerCssClass: 'header-centered', + cssClass: 'cell-unselectable', + excludeFromExport: true, + maxWidth: 30, + }, + { + id: 'name', name: 'Name', field: 'name', sortable: true, width: 140, filterable: true, + excelExportOptions: { width: 18 } + }, + { + id: 'price', name: 'Price', field: 'price', type: FieldType.number, + editor: { model: Editors.float, decimal: 2 }, sortable: true, width: 70, filterable: true, + formatter: Formatters.dollar, groupTotalsFormatter: GroupTotalFormatters.sumTotalsDollarBold, + groupTotalsExcelExportOptions: { + groupType: 'sum', + style: { + font: { bold: true }, + format: '$0.00', // currency format + }, + valueParserCallback: this.excelGroupCellParser.bind(this), + } + }, + { + id: 'qty', name: 'Quantity', field: 'qty', type: FieldType.number, + groupTotalsFormatter: GroupTotalFormatters.sumTotalsBold, + groupTotalsExcelExportOptions: { + groupType: 'sum', + style: { font: { bold: true } }, + valueParserCallback: this.excelGroupCellParser.bind(this), + }, + params: { minDecimal: 0, maxDecimal: 0 }, + editor: { model: Editors.integer }, sortable: true, width: 60, filterable: true + }, + { + id: 'subTotal', name: 'Sub-Total', field: 'subTotal', cssClass: 'text-sub-total', + type: FieldType.number, sortable: true, width: 70, filterable: true, + exportWithFormatter: false, + formatter: Formatters.multiple, groupTotalsFormatter: GroupTotalFormatters.sumTotalsDollarBold, + params: { + formatters: [ + (_row, _cell, _value, _coldef, dataContext) => dataContext.price * dataContext.qty, + Formatters.dollar + ] + }, + excelExportOptions: { + style: { + font: { outline: true, italic: true, color: 'FF215073' }, + format: '$0.00', // currency format + }, + width: 12, + valueParserCallback: this.excelRegularCellParser.bind(this), + }, + groupTotalsExcelExportOptions: { + groupType: 'sum', + style: { + font: { bold: true }, + format: '$0.00', // currency format + }, + valueParserCallback: this.excelGroupCellParser.bind(this), + } + }, + { + id: 'taxable', name: 'Taxable', field: 'taxable', cssClass: 'text-center', sortable: true, width: 60, filterable: true, + formatter: Formatters.checkmarkMaterial, + exportCustomFormatter: (_row, _cell, val) => val ? '✓' : '', + excelExportOptions: { + style: { + alignment: { horizontal: 'center' }, + }, + } + }, + { + id: 'taxes', name: 'Taxes', field: 'taxes', cssClass: 'text-taxes', + type: FieldType.number, sortable: true, width: 70, filterable: true, + formatter: Formatters.multiple, groupTotalsFormatter: GroupTotalFormatters.sumTotalsDollarBold, + params: { + formatters: [ + (_row, _cell, _value, _coldef, dataContext) => { + if (dataContext.taxable) { + return dataContext.price * dataContext.qty * (this.taxRate / 100); + } + return null; + }, + Formatters.dollar + ] + }, + excelExportOptions: { + style: { + font: { outline: true, italic: true, color: 'FFC65911' }, + format: '$0.00', // currency format + }, + width: 12, + valueParserCallback: this.excelRegularCellParser.bind(this), + }, + groupTotalsExcelExportOptions: { + groupType: 'sum', + style: { + font: { bold: true }, + format: '$0.00', // currency format + }, + valueParserCallback: this.excelGroupCellParser.bind(this), + } + }, + { + id: 'total', name: 'Total', field: 'total', type: FieldType.number, sortable: true, width: 70, filterable: true, + cssClass: 'text-total', formatter: Formatters.multiple, groupTotalsFormatter: GroupTotalFormatters.sumTotalsDollarBold, + params: { + formatters: [ + (_row, _cell, _value, _coldef, dataContext) => { + let subTotal = dataContext.price * dataContext.qty; + if (dataContext.taxable) { + subTotal += subTotal * (this.taxRate / 100); + } + return subTotal; + }, + Formatters.dollar + ] + }, + excelExportOptions: { + style: { + font: { outline: true, bold: true, color: 'FF005A9E' }, + format: '$0.00', // currency format + }, + width: 12, + valueParserCallback: this.excelRegularCellParser.bind(this), + }, + groupTotalsExcelExportOptions: { + groupType: 'sum', + style: { + font: { bold: true }, + format: '$0.00', + }, + valueParserCallback: this.excelGroupCellParser.bind(this), + } + }, + ]; + + this.gridOptions = { + autoAddCustomEditorFormatter: customEditableInputFormatter, + gridHeight: 410, + gridWidth: 750, + enableCellNavigation: true, + autoEdit: true, + autoCommitEdit: true, + editable: true, + rowHeight: 33, + formatterOptions: { + maxDecimal: 2, + minDecimal: 2, + }, + externalResources: [this.excelExportService], + enableExcelExport: true, + excelExportOptions: { + filename: 'grocery-list', + sanitizeDataExport: true, + sheetName: 'Grocery List', + columnHeaderStyle: { + font: { color: 'FFFFFFFF' }, + fill: { type: 'pattern', patternType: 'solid', fgColor: 'FF4a6c91' } + }, + + // optionally pass a custom header to the Excel Sheet + // a lot of the info can be found on Web Archive of Excel-Builder + // https://ghiscoding.gitbook.io/excel-builder-vanilla/cookbook/fonts-and-colors + customExcelHeader: (workbook, sheet) => { + const formatterId = workbook.getStyleSheet().createFormat({ + // every color is prefixed with FF, then regular HTML color + font: { size: 18, fontName: 'Calibri', bold: true, color: 'FFFFFFFF' }, + alignment: { wrapText: true, horizontal: 'center' }, + fill: { type: 'pattern', patternType: 'solid', fgColor: 'FF203764' }, + }); + sheet.setRowInstructions(0, { height: 40 }); // change height of row 0 + + // excel cells start with A1 which is upper left corner + const customTitle = 'Grocery Shopping List'; + const lastCellMerge = this.isDataGrouped ? 'H1' : 'G1'; + sheet.mergeCells('A1', lastCellMerge); + sheet.data.push([{ value: customTitle, metadata: { style: formatterId.id } }]); + }, + }, + }; + } + + invalidateAll() { + // make sure to call both refresh/invalid in this order so that whenever a cell changes we recalculate all Groups + this.sgb.dataView?.refresh(); + this.sgb.slickGrid?.invalidate(); + } + + updateTaxRate() { + // since Aggregator are cached and we provided the Tax Rate to our custom Aggregator, + // we need to recompile them by resetting the Group + if (this.isDataGrouped) { + this.groupByTaxable(); + } + + this.invalidateAll(); + } + + exportToExcel() { + this.excelExportService.exportToExcel(); + } + + excelGroupCellParser(totals: SlickGroupTotals, columnDef: Column, _groupType, excelFormatterId: number | undefined, _stylesheet, dataRowIdx: number) { + const colOffset = 0; // col offset of 1x because we skipped 1st column OR 0 offset if we use a Group because the Group column replaces the skip + const rowOffset = 3; // row offset of 3x because: 1x Title, 1x Headers and Excel row starts at 1 => 3 + const priceIdx = this.sgb.slickGrid?.getColumnIndex('price') || 0; + const qtyIdx = this.sgb.slickGrid?.getColumnIndex('qty') || 0; + const taxesIdx = this.sgb.slickGrid?.getColumnIndex('taxes') || 0; + const subTotalIdx = this.sgb.slickGrid?.getColumnIndex('subTotal') || 0; + const totalIdx = this.sgb.slickGrid?.getColumnIndex('total') || 0; + const groupItemCount = totals?.group?.count || 0; + + // the code below calculates Excel column position dynamically, technically Price is at "B" and Qty is "C" + const excelPriceCol = `${String.fromCharCode('A'.charCodeAt(0) + priceIdx - colOffset)}`; + const excelQtyCol = `${String.fromCharCode('A'.charCodeAt(0) + qtyIdx - colOffset)}`; + const excelSubTotalCol = `${String.fromCharCode('A'.charCodeAt(0) + subTotalIdx - colOffset)}`; + const excelTaxesCol = `${String.fromCharCode('A'.charCodeAt(0) + taxesIdx - colOffset)}`; + const excelTotalCol = `${String.fromCharCode('A'.charCodeAt(0) + totalIdx - colOffset)}`; + + let excelCol = ''; + switch (columnDef.id) { + case 'price': + excelCol = excelPriceCol; + break; + case 'qty': + excelCol = excelQtyCol; + break; + case 'subTotal': + excelCol = excelSubTotalCol; + break; + case 'taxes': + excelCol = excelTaxesCol; + break; + case 'total': + excelCol = excelTotalCol; + break; + } + return { value: `SUM(${excelCol}${dataRowIdx + rowOffset - groupItemCount}:${excelCol}${dataRowIdx + rowOffset - 1})`, metadata: { type: 'formula', style: excelFormatterId } }; + } + + /** We'll use a generic parser to reuse similar logic for all 3 calculable columns (SubTotal, Taxes, Total) */ + excelRegularCellParser(_data, columnDef: Column, excelFormatterId: number | undefined, _stylesheet, _gridOptions, dataRowIdx: number, dataContext: GroceryItem) { + // assuming that we want to calculate: (Price * Qty) => Sub-Total + const colOffset = !this.isDataGrouped ? 1 : 0; // col offset of 1x because we skipped 1st column OR 0 offset if we use a Group because the Group column replaces the skip + const rowOffset = 3; // row offset of 3x because: 1x Title, 1x Headers and Excel row starts at 1 => 3 + const priceIdx = this.sgb.slickGrid?.getColumnIndex('price') || 0; + const qtyIdx = this.sgb.slickGrid?.getColumnIndex('qty') || 0; + const taxesIdx = this.sgb.slickGrid?.getColumnIndex('taxes') || 0; + + // the code below calculates Excel column position dynamically, technically Price is at "B" and Qty is "C" + const excelPriceCol = `${String.fromCharCode('A'.charCodeAt(0) + priceIdx - colOffset)}${dataRowIdx + rowOffset}`; + const excelQtyCol = `${String.fromCharCode('A'.charCodeAt(0) + qtyIdx - colOffset)}${dataRowIdx + rowOffset}`; + const excelTaxesCol = `${String.fromCharCode('A'.charCodeAt(0) + taxesIdx - colOffset)}${dataRowIdx + rowOffset}`; + + // `value` is our Excel cells to calculat (e.g.: "B4*C4") + // metadata `type` has to be set to "formula" and the `style` is what we defined in `excelExportOptions.style` which is `excelFormatterId` in the callback arg + + let excelVal = ''; + switch (columnDef.id) { + case 'subTotal': + excelVal = `${excelPriceCol}*${excelQtyCol}`; // like "C4*D4" + break; + case 'taxes': + excelVal = (dataContext.taxable) + ? `${excelPriceCol}*${excelQtyCol}*${this.taxRate / 100}` + : ''; + break; + case 'total': + excelVal = `(${excelPriceCol}*${excelQtyCol})+${excelTaxesCol}`; + break; + } + return { value: excelVal, metadata: { type: 'formula', style: excelFormatterId } }; + } + + getData() { + let i = 1; + const datasetTmp = [ + { id: i++, name: 'Oranges', qty: 4, taxable: false, price: 2.22 }, + { id: i++, name: 'Apples', qty: 3, taxable: false, price: 1.55 }, + { id: i++, name: 'Honeycomb Cereals', qty: 2, taxable: true, price: 4.55 }, + { id: i++, name: 'Raisins', qty: 77, taxable: false, price: 0.23 }, + { id: i++, name: 'Corn Flake Cereals', qty: 1, taxable: true, price: 6.62 }, + { id: i++, name: 'Tomatoes', qty: 3, taxable: false, price: 1.88 }, + { id: i++, name: 'Butter', qty: 1, taxable: false, price: 3.33 }, + { id: i++, name: 'BBQ Chicken', qty: 1, taxable: false, price: 12.33 }, + { id: i++, name: 'Chicken Wings', qty: 12, taxable: true, price: .53 }, + { id: i++, name: 'Drinkable Yogurt', qty: 6, taxable: true, price: 1.22 }, + { id: i++, name: 'Milk', qty: 3, taxable: true, price: 3.11 }, + ]; + + return datasetTmp; + } + + groupByTaxable() { + const checkIcon = 'mdi-check-box-outline'; + const uncheckIcon = 'mdi-checkbox-blank-outline'; + this.isDataGrouped = true; + + this.sgb?.dataView?.setGrouping({ + getter: 'taxable', + formatter: (g) => `Taxable: (${g.count} items)`, + comparer: (a, b) => b.value - a.value, + aggregators: [ + new Aggregators.Sum('price'), + new Aggregators.Sum('qty'), + new CustomSumAggregator('subTotal', this.taxRate), + new CustomSumAggregator('taxes', this.taxRate), + new CustomSumAggregator('total', this.taxRate), + ], + aggregateCollapsed: false, + lazyTotalsCalculation: false, + } as Grouping); + + this.sgb?.dataView?.refresh(); + } +} diff --git a/packages/common/src/interfaces/columnExcelExportOption.interface.ts b/packages/common/src/interfaces/columnExcelExportOption.interface.ts index c378b1acb..b18e5844c 100644 --- a/packages/common/src/interfaces/columnExcelExportOption.interface.ts +++ b/packages/common/src/interfaces/columnExcelExportOption.interface.ts @@ -1,7 +1,8 @@ -import type { ExcelColumnMetadata, ExcelStyleInstruction } from 'excel-builder-vanilla'; +import type { ExcelColumnMetadata, ExcelStyleInstruction, StyleSheet } from 'excel-builder-vanilla'; import type { Column } from './column.interface'; import type { GridOption } from './gridOption.interface'; +import type { SlickGroupTotals } from '../core/index'; /** Excel custom export options (formatting & width) that can be applied to a column */ export interface ColumnExcelExportOption { @@ -30,7 +31,10 @@ export interface GroupTotalExportOption { /** Cell data value parser callback function */ valueParserCallback?: GetGroupTotalValueCallback; + + /** Allows to define a group type (sum, avg, ...) when auto-detect doesn't work when used with `valueParserCallback` without a `groupTotalsFormatter` to auto-detect. */ + groupType?: string; } -export type GetDataValueCallback = (data: Date | string | number, columnDef: Column, excelFormatterId: number | undefined, excelStylesheet: unknown, gridOptions: GridOption) => Date | string | number | ExcelColumnMetadata; -export type GetGroupTotalValueCallback = (totals: any, columnDef: Column, groupType: string, excelStylesheet: unknown) => Date | string | number; +export type GetDataValueCallback = (data: Date | string | number, columnDef: Column, excelFormatterId: number | undefined, excelStylesheet: StyleSheet, gridOptions: GridOption, rowNumber: number, item: any) => Date | string | number | ExcelColumnMetadata; +export type GetGroupTotalValueCallback = (totals: SlickGroupTotals, columnDef: Column, groupType: string, excelFormatterId: number | undefined, excelStylesheet: StyleSheet, rowNumber: number) => Date | string | number | ExcelColumnMetadata; diff --git a/packages/excel-export/src/excelExport.service.spec.ts b/packages/excel-export/src/excelExport.service.spec.ts index 16a9b4156..b007fbeb2 100644 --- a/packages/excel-export/src/excelExport.service.spec.ts +++ b/packages/excel-export/src/excelExport.service.spec.ts @@ -562,8 +562,8 @@ describe('ExcelExportService', () => { { metadata: { style: 1, }, value: 'StartDate', }, { metadata: { style: 1, }, value: 'EndDate', }, ], - ['1E06', 'John', 'X', 'SALES_REP', '2005-12-20T18:19:19.992Z', ''], - ['1E09', 'Jane', 'Doe', 'HUMAN_RESOURCES', '2010-10-09T18:19:19.992Z', '2024-01-02'], + ['1E06', 'John', 'X', { metadata: { style: 4 }, value: 'SALES_REP' }, '2005-12-20T18:19:19.992Z', ''], + ['1E09', 'Jane', 'Doe', { metadata: { style: 4 }, value: 'HUMAN_RESOURCES' }, '2010-10-09T18:19:19.992Z', '2024-01-02'], ] })] }), @@ -898,15 +898,8 @@ describe('ExcelExportService', () => { }), 'export.xlsx', { mimeType: mimeTypeXLSX } ); - expect(service.groupTotalExcelFormats.order).toEqual({ - groupType: 'sum', - stylesheetFormatter: { - fontId: 2, - id: 5, - numFmtId: 103, - } - }); - expect(parserCallbackSpy).toHaveBeenCalledWith(22, mockColumns[6], undefined, expect.anything(), mockGridOptions); + expect(service.groupTotalExcelFormats.order).toEqual({ groupType: 'sum', stylesheetFormatter: { fontId: 2, id: 5, numFmtId: 103 } }); + expect(parserCallbackSpy).toHaveBeenNthCalledWith(1, 22, mockColumns[6], undefined, expect.anything(), mockGridOptions, 1, expect.objectContaining({ firstName: 'John' })); }); }); @@ -1096,7 +1089,7 @@ describe('ExcelExportService', () => { .mockReturnValueOnce(mockCollection[6]) .mockReturnValueOnce(mockCollection[7]); jest.spyOn(dataViewStub, 'getGrouping').mockReturnValue([mockOrderGrouping]); - groupTotalParserCallbackSpy.mockReturnValue(9999); + groupTotalParserCallbackSpy.mockReturnValue({ value: 9999, metadata: { style: 4 } }); }); it(`should have a xlsx export with grouping (same as the grid, WYSIWYG) when "enableGrouping" is set in the grid options and grouping are defined`, async () => { diff --git a/packages/excel-export/src/excelExport.service.ts b/packages/excel-export/src/excelExport.service.ts index 03fbc4803..c4e8cc355 100644 --- a/packages/excel-export/src/excelExport.service.ts +++ b/packages/excel-export/src/excelExport.service.ts @@ -435,13 +435,13 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ // Normal row (not grouped by anything) would have an ID which was predefined in the Grid Columns definition if (itemObj[this._datasetIdPropName] !== null && itemObj[this._datasetIdPropName] !== undefined) { // get regular row item data - originalDaraArray.push(this.readRegularRowData(columns, rowNumber, itemObj)); + originalDaraArray.push(this.readRegularRowData(columns, rowNumber, itemObj, rowNumber)); } else if (this._hasGroupedItems && itemObj.__groupTotals === undefined) { // get the group row originalDaraArray.push([this.readGroupedRowTitle(itemObj)]); } else if (itemObj.__groupTotals) { // else if the row is a Group By and we have agreggators, then a property of '__groupTotals' would exist under that object - originalDaraArray.push(this.readGroupedTotalRows(columns, itemObj)); + originalDaraArray.push(this.readGroupedTotalRows(columns, itemObj, rowNumber)); } } } @@ -454,7 +454,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ * @param {Number} row - row index * @param {Object} itemObj - item datacontext object */ - protected readRegularRowData(columns: Column[], row: number, itemObj: any): string[] { + protected readRegularRowData(columns: Column[], row: number, itemObj: any, dataRowIdx: number): string[] { let idx = 0; const rowOutputStrings = []; const columnsLn = columns.length; @@ -553,7 +553,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ } const { stylesheetFormatterId, getDataValueParser } = this._regularCellExcelFormats[columnDef.id]; - itemData = getDataValueParser(itemData, columnDef, stylesheetFormatterId, this._stylesheet, this._gridOptions); + itemData = getDataValueParser(itemData, columnDef, stylesheetFormatterId, this._stylesheet, this._gridOptions, dataRowIdx, itemObj); rowOutputStrings.push(itemData); idx++; @@ -584,7 +584,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ * For example if we grouped by "salesRep" and we have a Sum Aggregator on "sales", then the returned output would be:: ["Sum 123$"] * @param itemObj */ - protected readGroupedTotalRows(columns: Column[], itemObj: any): Array { + protected readGroupedTotalRows(columns: Column[], itemObj: any, dataRowIdx: number): Array { const groupingAggregatorRowText = this._excelExportOptions.groupingAggregatorRowText || ''; const outputStrings: Array = [groupingAggregatorRowText]; @@ -614,10 +614,10 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ const groupTotalParser = columnDef.groupTotalsExcelExportOptions?.valueParserCallback ?? getGroupTotalValue; if (itemObj[groupCellFormat.groupType]?.[columnDef.field] !== undefined) { - itemData = { - value: groupTotalParser(itemObj, columnDef, groupCellFormat.groupType, this._stylesheet), - metadata: { style: groupCellFormat.stylesheetFormatter?.id } - }; + const groupData = groupTotalParser(itemObj, columnDef, groupCellFormat.groupType, groupCellFormat.stylesheetFormatter?.id, this._stylesheet, dataRowIdx); + itemData = (typeof groupData === 'object' && groupData.hasOwnProperty('metadata')) + ? groupData + : itemData = { value: groupData, metadata: { style: groupCellFormat.stylesheetFormatter?.id } }; } } else if (columnDef.groupTotalsFormatter) { const totalResult = columnDef.groupTotalsFormatter(itemObj, columnDef, this._grid); diff --git a/packages/excel-export/src/excelUtils.spec.ts b/packages/excel-export/src/excelUtils.spec.ts index a4528ddbd..96e40fb35 100644 --- a/packages/excel-export/src/excelUtils.spec.ts +++ b/packages/excel-export/src/excelUtils.spec.ts @@ -43,31 +43,31 @@ describe('excelUtils', () => { describe('getExcelNumberCallback() method', () => { it('should return same data when input not a number', () => { - const output = getExcelNumberCallback('something else', {} as Column, 3, {}, mockGridOptions); + const output = getExcelNumberCallback('something else', {} as Column, 3, stylesheetStub, mockGridOptions, 0, {}); 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); + const output = getExcelNumberCallback(9.33, {} as Column, 3, stylesheetStub, mockGridOptions, 0, {}); 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); + const output = getExcelNumberCallback('$1,209.33', {} as Column, 3, stylesheetStub, mockGridOptions, 0, {}); expect(output).toEqual({ metadata: { style: 3 }, value: 1209.33 }); }); it('should return negative parsed number when input value can be parsed to a number', () => { - const output = getExcelNumberCallback('-$1,209.33', {} as Column, 3, {}, mockGridOptions); + const output = getExcelNumberCallback('-$1,209.33', {} as Column, 3, stylesheetStub, mockGridOptions, 0, {}); 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, {}, + '1 244 209,33€', {} as Column, 3, stylesheetStub, { ...mockGridOptions, formatterOptions: { decimalSeparator: ',', thousandSeparator: ' ' } - }); + }, 0, {}); expect(output).toEqual({ metadata: { style: 3 }, value: 1244209.33 }); }); }); diff --git a/packages/excel-export/src/excelUtils.ts b/packages/excel-export/src/excelUtils.ts index 1ca4f8e5e..67b0b2501 100644 --- a/packages/excel-export/src/excelUtils.ts +++ b/packages/excel-export/src/excelUtils.ts @@ -21,7 +21,11 @@ import { stripTags } from '@slickgrid-universal/utils'; export type ExcelFormatter = object & { id: number; }; // define all type of potential excel data function callbacks -export const getExcelSameInputDataCallback: GetDataValueCallback = (data) => data; +export const getExcelSameInputDataCallback: GetDataValueCallback = (data, _column, excelFormatterId) => { + return excelFormatterId !== undefined + ? { value: data, metadata: { style: excelFormatterId } } + : 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 } @@ -133,7 +137,7 @@ export function getFormatterNumericDataType(formatter?: Formatter) { export function getExcelFormatFromGridFormatter(stylesheet: StyleSheet, stylesheetFormatters: any, columnDef: Column, grid: SlickGrid, formatterType: FormatterType) { let format = ''; - let groupType = ''; + let groupType = columnDef.groupTotalsExcelExportOptions?.groupType || ''; let stylesheetFormatter: undefined | ExcelFormatter; const fieldType = getColumnFieldType(columnDef); diff --git a/test/cypress/e2e/example23.cy.ts b/test/cypress/e2e/example23.cy.ts new file mode 100644 index 000000000..568633e93 --- /dev/null +++ b/test/cypress/e2e/example23.cy.ts @@ -0,0 +1,194 @@ +describe('Example 23 - Excel Export Formula', () => { + const GRID_ROW_HEIGHT = 33; + const fullTitles = ['#', 'Name', 'Price', 'Quantity', 'Sub-Total', 'Taxable', 'Taxes', 'Total']; + + it('should display Example title', () => { + cy.visit(`${Cypress.config('baseUrl')}/example23`); + cy.get('h3').should('contain', 'Example 23 - Excel Export Formula'); + }); + + it('should have exact column titles on grid', () => { + cy.get('.grid23') + .find('.slick-header-columns') + .children() + .each(($child, index) => expect($child.text()).to.eq(fullTitles[index])); + }); + + it('should check first 3 rows with calculated totals', () => { + // 1st row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).contains('1'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(1)`).contains('Oranges'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).contains('$2.22'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3)`).contains('4'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).contains('$8.88'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(5)`).should('have.text', ''); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(6)`).should('have.text', ''); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(7)`).contains('$8.88'); + + // 2nd row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).contains('2'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(1)`).contains('Apples'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(2)`).contains('$1.55'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(3)`).contains('3'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(4)`).contains('$4.65'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(5)`).should('have.text', ''); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(6)`).should('have.text', ''); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(7)`).contains('$4.65'); + + // 3rd row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).contains('3'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(1)`).contains('Honeycomb Cereals'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(2)`).contains('$4.55'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(3)`).contains('2'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(4)`).contains('$9.10'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(4)`).should('have.css', 'color').and('eq', 'rgb(33, 80, 115)'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(5)`).find('.mdi-check'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(6)`).contains('$0.68'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(6)`).should('have.css', 'color').and('eq', 'rgb(198, 89, 17)'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(7)`).contains('$9.78'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(7)`).should('have.css', 'color').and('eq', 'rgb(0, 90, 158)'); + }); + + it('should change Price & Qty on first 3 rows and expect calculated values to be updated', () => { + // 1st row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).contains('1'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2) input`).clear().type('2.44{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3) input`).clear().type('7{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).contains('$17.08'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(7)`).contains('$17.08'); + + // 2nd row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).contains('2'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(2)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(2) input`).clear().type('1.4{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(3)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(3) input`).clear().type('3{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(4)`).contains('$4.20'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(7)`).contains('$4.20'); + + // 3rd row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).contains('3'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(2)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(2) input`).clear().type('4.23{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(3)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(3) input`).clear().type('3{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(4)`).contains('$12.69'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(6)`).contains('$0.95'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(7)`).contains('$13.64'); + }); + + it('should be able to change Tax Rate and for first 3 rows only expect the 3rd one to be updated with different taxes and total', () => { + cy.get('[data-test="taxrate"]').clear().type('6.25'); + cy.get('[data-test="update-btn"]').click(); + + // 1st row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0)`).contains('1'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(2)`).contains('$2.44'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(3)`).contains('7'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(4)`).contains('$17.08'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(7)`).contains('$17.08'); + + // 2nd row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(0)`).contains('2'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(2)`).contains('$1.40'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(3)`).contains('3'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(4)`).contains('$4.20'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 1}px;"] > .slick-cell:nth(7)`).contains('$4.20'); + + // 3rd row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(0)`).contains('3'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(2)`).contains('$4.23'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(3)`).contains('3'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(4)`).contains('$12.69'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(6)`).contains('$0.79'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 2}px;"] > .slick-cell:nth(7)`).contains('$13.48'); + }); + + it('should Group by Taxable and expect calculated totals', () => { + cy.get('[data-test="group-by-btn"]').click(); + + // last and 5th row of first Group + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(0)`).contains('11'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(1)`).contains('Milk'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(2)`).contains('$3.11'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(3)`).contains('3'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(4)`).contains('$9.33'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(5)`).find('.mdi-check'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(6)`).contains('$0.58'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(7)`).contains('$9.91'); + + // Taxable group total row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(2)`).contains('$15.71'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(3)`).contains('25'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(4)`).contains('$42.32'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(6)`).contains('$2.65'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(7)`).contains('$44.97'); + + // Non-Taxable group total row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(2)`).contains('$21.61'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(3)`).contains('92'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(4)`).contains('$60.29'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(6)`).contains('$0.00'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(7)`).contains('$60.29'); + }); + + it('should change Price & Qty of item 10,11 and expect calculated values to be updated in group total', () => { + // item 10 + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(0)`).contains('10'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(1)`).contains('Drinkable Yogurt'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(2)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(2) input`).clear().type('1.96{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(3)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(3) input`).clear().type('4{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(4)`).contains('$7.84'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(6)`).contains('$0.49'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(7)`).contains('$8.33'); + + // item 11 + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(0)`).contains('11'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(1)`).contains('Milk'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(2)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(2) input`).clear().type('3.85{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(3)`).click(); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(3) input`).clear().type('2{enter}'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(4)`).contains('$7.70'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(6)`).contains('$0.48'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(7)`).contains('$8.18'); + + // group total row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(2)`).contains('$17.19'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(3)`).contains('22'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(4)`).contains('$41.21'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(6)`).contains('$2.58'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(7)`).contains('$43.79'); + + // Non-Taxable group total row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(2)`).contains('$21.61'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(3)`).contains('92'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(4)`).contains('$60.29'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(6)`).contains('$0.00'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 14}px;"] > .slick-cell:nth(7)`).contains('$60.29'); + }); + + it('should change Tax Rate again and expect Taxes and Total to be recalculated', () => { + cy.get('[data-test="taxrate"]').clear().type('7'); + cy.get('[data-test="update-btn"]').click(); + + // item 10 + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(6)`).contains('$0.55'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 4}px;"] > .slick-cell:nth(7)`).contains('$8.39'); + + // item 11 + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(6)`).contains('$0.54'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 5}px;"] > .slick-cell:nth(7)`).contains('$8.24'); + + // group total row + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(2)`).contains('$17.19'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(3)`).contains('22'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(4)`).contains('$41.21'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(6)`).contains('$2.88'); + cy.get(`[style="top: ${GRID_ROW_HEIGHT * 6}px;"] > .slick-cell:nth(7)`).contains('$44.09'); + }); +});