From 7562cc5f56f7a600d80d1465025acf7deae73854 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 2 Jul 2025 15:02:21 +0300 Subject: [PATCH 01/99] chore(*): Cell merge POC. --- .../src/lib/grids/common/enums.ts | 14 ++ .../src/lib/grids/common/grid.interface.ts | 4 +- .../src/lib/grids/grid-base.directive.ts | 19 +- .../lib/grids/grid/grid-row.component.html | 48 ++++- .../src/lib/grids/grid/grid.component.html | 3 +- .../src/lib/grids/grid/grid.component.ts | 5 +- .../src/lib/grids/grid/grid.pipes.ts | 40 ++++ .../src/lib/grids/row.directive.ts | 10 + src/app/app.component.ts | 5 + src/app/app.routes.ts | 5 + .../grid-cellMerging.component.html | 15 ++ .../grid-cellMerging.component.scss | 19 ++ .../grid-cellMerging.component.ts | 190 ++++++++++++++++++ 13 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 src/app/grid-cellMerging/grid-cellMerging.component.html create mode 100644 src/app/grid-cellMerging/grid-cellMerging.component.scss create mode 100644 src/app/grid-cellMerging/grid-cellMerging.component.ts diff --git a/projects/igniteui-angular/src/lib/grids/common/enums.ts b/projects/igniteui-angular/src/lib/grids/common/enums.ts index 88d277ccd39..bd66328c97e 100644 --- a/projects/igniteui-angular/src/lib/grids/common/enums.ts +++ b/projects/igniteui-angular/src/lib/grids/common/enums.ts @@ -73,6 +73,20 @@ export const GridSelectionMode = { } as const; export type GridSelectionMode = (typeof GridSelectionMode)[keyof typeof GridSelectionMode]; + +/** + * Enumeration representing different cell merging modes for the grid elements. + * - 'never': Never merge cells. + * - 'always': Always merge adjacent cells based on merge strategy. + * - 'onSort': Only merge cells in column that are sorted. + */ +export const GridCellMergeMode = { + never: 'never', + always: 'always', + onSort: 'onSort' +} as const; +export type GridCellMergeMode = (typeof GridCellMergeMode)[keyof typeof GridCellMergeMode]; + /** Enumeration representing different column display order options. */ export const ColumnDisplayOrder = { Alphabetical: 'Alphabetical', diff --git a/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts b/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts index 3c8d8aa4f38..3b8d8d2e098 100644 --- a/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts +++ b/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts @@ -1,4 +1,4 @@ -import { ColumnPinningPosition, FilterMode, GridPagingMode, GridSelectionMode, GridSummaryCalculationMode, GridSummaryPosition, GridValidationTrigger, RowPinningPosition, Size } from './enums'; +import { ColumnPinningPosition, FilterMode, GridCellMergeMode, GridPagingMode, GridSelectionMode, GridSummaryCalculationMode, GridSummaryPosition, GridValidationTrigger, RowPinningPosition, Size } from './enums'; import { ISearchInfo, IGridCellEventArgs, IRowSelectionEventArgs, IColumnSelectionEventArgs, IPinColumnCancellableEventArgs, IColumnVisibilityChangedEventArgs, IColumnVisibilityChangingEventArgs, @@ -690,6 +690,7 @@ export interface GridServiceType { export interface GridType extends IGridDataBindable { /** Represents the locale of the grid: `USD`, `EUR`, `GBP`, `CNY`, `JPY`, etc. */ locale: string; + cellMergeMode: GridCellMergeMode; resourceStrings: IGridResourceStrings; /* blazorSuppress */ /** Represents the native HTML element itself */ @@ -1180,6 +1181,7 @@ export interface GridType extends IGridDataBindable { getEmptyRecordObjectFor(inRow: RowType): any; isSummaryRow(rec: any): boolean; isRecordPinned(rec: any): boolean; + isRecordMerged(rec: any): boolean; getInitialPinnedIndex(rec: any): number; isRecordPinnedByViewIndex(rowIndex: number): boolean; isColumnGrouped(fieldName: string): boolean; diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 5213151e54e..3398421a771 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -93,7 +93,8 @@ import { RowPinningPosition, GridPagingMode, GridValidationTrigger, - Size + Size, + GridCellMergeMode } from './common/enums'; import { IGridCellEventArgs, @@ -2911,6 +2912,14 @@ export abstract class IgxGridBaseDirective implements GridType, // } } + /** + * Gets/Sets cell merge mode. + * + */ + @WatchChanges() + @Input() + public cellMergeMode: GridCellMergeMode = GridCellMergeMode.never; + /** * Gets/Sets row selection mode * @@ -3635,6 +3644,14 @@ export abstract class IgxGridBaseDirective implements GridType, return this.getInitialPinnedIndex(rec) !== -1; } + /** + * @hidden + * @internal + */ + public isRecordMerged(rec) { + return rec.cellMergeMeta; + } + /** * @hidden * @internal diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index 7289bc16f29..99124fd2124 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -31,7 +31,19 @@ } } - + @if (this.hasMergedCells) { +
+ +
+ } + @else { + + } +
@if (pinnedColumns.length > 0 && !grid.isPinningToStart) { @for (col of pinnedColumns | igxNotGrouped; track trackPinnedColumn(col)) { @@ -158,6 +170,40 @@ + + + + + () }; + for (const col of visibleColumns) { + recData.cellMergeMeta.set(col.field, { rowSpan: 1 }); + //TODO condition can be a strategy or some callback that the user can set. + //TODO can also be limited to only sorted columns + if ( prev && prev.recordRef[col.field] === rec[col.field]) { + const root = prev.cellMergeMeta.get(col.field)?.root ?? prev; + root.cellMergeMeta.get(col.field).rowSpan += 1; + recData.cellMergeMeta.get(col.field).root = root; + } + } + prev = recData; + result.push(recData); + } + return result; + } +} + +export interface IMergeByResult { + rowSpan: number; + root?: any; +} + /** * @hidden */ diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index b2118b1f15e..a353b728c6e 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -27,6 +27,7 @@ import { mergeWith } from 'lodash-es'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { trackByIdentity } from '../core/utils'; +import { IMergeByResult } from './grid/grid.pipes'; @Directive({ selector: '[igxRowBaseComponent]', @@ -117,6 +118,10 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { return this.grid.isRecordPinned(this.data); } + public get hasMergedCells(): boolean { + return this.grid.isRecordMerged(this.data); + } + /** * Gets the expanded state of the row. * ```typescript @@ -592,6 +597,11 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { this.addAnimationEnd.emit(this); } + protected getMergeCellSpan(col: ColumnType){ + const rowCount = this.data.cellMergeMeta.get(col.field).rowSpan; + return `repeat(${rowCount},51px)`; + } + /** * @hidden */ diff --git a/src/app/app.component.ts b/src/app/app.component.ts index c4637552c6b..c3d2d4d104b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -211,6 +211,11 @@ export class AppComponent implements OnInit { icon: 'view_column', name: 'Grid Cell Editing' }, + { + link: '/gridCellMerging', + icon: 'view_column', + name: 'Grid Cell Merging' + }, { link: '/gridClipboard', icon: 'insert_comment', diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 992516f1574..5234d396dc1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -51,6 +51,7 @@ import { TimePickerSampleComponent } from './time-picker/time-picker.sample'; import { ToastShowcaseSampleComponent } from './toast-showcase/toast-showcase.sample'; import { VirtualForSampleComponent } from './virtual-for-directive/virtual-for.sample'; import { GridCellEditingComponent } from './grid-cellEditing/grid-cellEditing.component'; +import { GridCellMergingComponent } from './grid-cellMerging/grid-cellMerging.component'; import { GridSampleComponent } from './grid/grid.sample'; import { GridColumnMovingSampleComponent } from './grid-column-moving/grid-column-moving.sample'; import { GridColumnSelectionSampleComponent } from './grid-column-selection/grid-column-selection.sample'; @@ -419,6 +420,10 @@ export const appRoutes: Routes = [ path: 'gridCellEditing', component: GridCellEditingComponent }, + { + path: 'gridCellMerging', + component: GridCellMergingComponent + }, { path: 'gridConditionalCellStyling', component: GridCellStylingSampleComponent diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html new file mode 100644 index 00000000000..6b2628ac345 --- /dev/null +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -0,0 +1,15 @@ +

Grid with cell merge

+ + + + + + + + + + + + + + diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.scss b/src/app/grid-cellMerging/grid-cellMerging.component.scss new file mode 100644 index 00000000000..3d4037836d0 --- /dev/null +++ b/src/app/grid-cellMerging/grid-cellMerging.component.scss @@ -0,0 +1,19 @@ +.sample-actions { + display: flex; + flex-wrap: wrap; + margin: 1rem 0; + gap: 0.5rem; +} + +.density-chooser { + margin-bottom: 1rem; + + igx-buttongroup { + display: block; + width: 500px; + } +} + +.grid-size { + --ig-size: var(--ig-size-small); +} diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts new file mode 100644 index 00000000000..c2b9d9c0a88 --- /dev/null +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -0,0 +1,190 @@ +import { Component, HostBinding, ViewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + IgxColumnComponent, + IgxGridComponent, +} from 'igniteui-angular'; + +import { data, dataWithoutPK } from '../shared/data'; + +@Component({ + selector: 'app-grid-cellMerging', + templateUrl: 'grid-cellMerging.component.html', + styleUrl: 'grid-cellMerging.component.scss', + imports: [ + FormsModule, + IgxColumnComponent, + IgxGridComponent, + ] +}) +export class GridCellMergingComponent { + public data = [{ + ProductID: 1, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '10 boxes x 20 bags', + UnitPrice: '18.0000', + UnitsInStock: 39, + UnitsOnOrder: 0, + ReorderLevel: 10.567, + Discontinued: false, + OrderDate: null, + OrderDate2: new Date(1991, 2, 12, 18, 40, 50).toISOString() + }, { + ProductID: 2, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 3, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 4, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '20.0000', + UnitsInStock: 20, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 5, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 6, + ProductName: 'Chang', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 7, + ProductName: 'Chang', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 8, + ProductName: 'Chang', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 30, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 9, + ProductName: 'Aniseed Syrup', + SupplierID: 1, + CategoryID: 2, + QuantityPerUnit: '12 - 550 ml bottles', + UnitPrice: '10.0000', + UnitsInStock: 13, + UnitsOnOrder: 70, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2006-03-17').toISOString(), + OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() + }, + { + ProductID: 10, + ProductName: 'Chang', + SupplierID: 1, + CategoryID: 2, + QuantityPerUnit: '12 - 550 ml bottles', + UnitPrice: '10.0000', + UnitsInStock: 13, + UnitsOnOrder: 70, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2006-03-17').toISOString(), + OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() + }, + { + ProductID: 11, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 2, + QuantityPerUnit: '12 - 550 ml bottles', + UnitPrice: '10.0000', + UnitsInStock: 13, + UnitsOnOrder: 70, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2006-03-17').toISOString(), + OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() + }, + { + ProductID: 12, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 2, + QuantityPerUnit: '12 - 550 ml bottles', + UnitPrice: '10.0000', + UnitsInStock: 12, + UnitsOnOrder: 70, + ReorderLevel: 30, + Discontinued: false, + OrderDate: new Date('2006-03-17').toISOString(), + OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() + }]; + +} + From f42bb3c7d6f7f94938351138d24c046bf0ec4422 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 2 Jul 2025 16:47:10 +0300 Subject: [PATCH 02/99] chore(*): Minor tweaks to suggestion. --- .../lib/grids/grid/grid-row.component.html | 2 +- .../src/lib/grids/grid/grid.pipes.ts | 13 +- .../grid-cellMerging.component.ts | 167 ++++++++++++++++++ 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index 99124fd2124..e3b00c36d28 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -194,7 +194,7 @@ [cellSelectionMode]="grid.cellSelection" [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)" [style.height.px]="data.cellMergeMeta.get(col.field).rowSpan * (this.grid.rowHeight + 1)" - [style.zIndex]="100" + [style.zIndex]="data.cellMergeMeta.get(col.field).rowSpan" [style.min-width]="col.resolvedWidth" [style.max-width]="col.resolvedWidth" [style.flex-basis]="col.resolvedWidth" diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts index 2b6304904ff..2aa8eafd495 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts @@ -99,9 +99,15 @@ export class IgxGridCellMergePipe implements PipeTransform { //TODO condition can be a strategy or some callback that the user can set. //TODO can also be limited to only sorted columns if ( prev && prev.recordRef[col.field] === rec[col.field]) { - const root = prev.cellMergeMeta.get(col.field)?.root ?? prev; - root.cellMergeMeta.get(col.field).rowSpan += 1; - recData.cellMergeMeta.get(col.field).root = root; + // const root = prev.cellMergeMeta.get(col.field)?.root ?? prev; + // root.cellMergeMeta.get(col.field).rowSpan += 1; + // recData.cellMergeMeta.get(col.field).root = root; + recData.cellMergeMeta.get(col.field).prev = prev; + let curr = prev; + while(curr) { + curr.cellMergeMeta.get(col.field).rowSpan += 1; + curr = curr.cellMergeMeta.get(col.field).prev; + } } } prev = recData; @@ -114,6 +120,7 @@ export class IgxGridCellMergePipe implements PipeTransform { export interface IMergeByResult { rowSpan: number; root?: any; + prev?: any; } /** diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index c2b9d9c0a88..c4184a997ae 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -171,6 +171,173 @@ export class GridCellMergingComponent { OrderDate: new Date('2006-03-17').toISOString(), OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() }, + { + ProductID: 12, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 2, + QuantityPerUnit: '12 - 550 ml bottles', + UnitPrice: '10.0000', + UnitsInStock: 12, + UnitsOnOrder: 70, + ReorderLevel: 30, + Discontinued: false, + OrderDate: new Date('2006-03-17').toISOString(), + OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() + }, + { + ProductID: 1, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '10 boxes x 20 bags', + UnitPrice: '18.0000', + UnitsInStock: 39, + UnitsOnOrder: 0, + ReorderLevel: 10.567, + Discontinued: false, + OrderDate: null, + OrderDate2: new Date(1991, 2, 12, 18, 40, 50).toISOString() + }, { + ProductID: 2, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 3, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 4, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '20.0000', + UnitsInStock: 20, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 5, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 6, + ProductName: 'Chang', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 7, + ProductName: 'Chang', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 8, + ProductName: 'Chang', + SupplierID: 1, + CategoryID: 1, + QuantityPerUnit: '24 - 12 oz bottles', + UnitPrice: '19.0000', + UnitsInStock: 17, + UnitsOnOrder: 40, + ReorderLevel: 30, + Discontinued: false, + OrderDate: new Date('2003-03-17').toISOString(), + OrderDate2: new Date('2003-03-17').toISOString() + }, + { + ProductID: 9, + ProductName: 'Aniseed Syrup', + SupplierID: 1, + CategoryID: 2, + QuantityPerUnit: '12 - 550 ml bottles', + UnitPrice: '10.0000', + UnitsInStock: 13, + UnitsOnOrder: 70, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2006-03-17').toISOString(), + OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() + }, + { + ProductID: 10, + ProductName: 'Chang', + SupplierID: 1, + CategoryID: 2, + QuantityPerUnit: '12 - 550 ml bottles', + UnitPrice: '10.0000', + UnitsInStock: 13, + UnitsOnOrder: 70, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2006-03-17').toISOString(), + OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() + }, + { + ProductID: 11, + ProductName: 'Chai', + SupplierID: 1, + CategoryID: 2, + QuantityPerUnit: '12 - 550 ml bottles', + UnitPrice: '10.0000', + UnitsInStock: 13, + UnitsOnOrder: 70, + ReorderLevel: 25, + Discontinued: false, + OrderDate: new Date('2006-03-17').toISOString(), + OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() + }, { ProductID: 12, ProductName: 'Chai', From 870e7f61f966e3353502d9f93342d0c1e72caa08 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 3 Jul 2025 17:46:06 +0300 Subject: [PATCH 03/99] chore(*): Implement with rows that are retained outside the virt.frame. --- .../src/lib/grids/grid-base.directive.ts | 5 +++++ .../src/lib/grids/grid/grid-row.component.html | 1 - .../src/lib/grids/grid/grid.component.html | 8 ++++++++ .../src/lib/grids/grid/grid.component.ts | 11 +++++++++++ .../src/lib/grids/grid/grid.pipes.ts | 18 +++++++++--------- .../grid-cellMerging.component.html | 4 +--- 6 files changed, 34 insertions(+), 13 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 3398421a771..659e5e6e18c 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3652,6 +3652,11 @@ export abstract class IgxGridBaseDirective implements GridType, return rec.cellMergeMeta; } + public getMergeCellOffset(rec) { + const index = this.verticalScrollContainer.igxForOf.indexOf(rec); + return -(this.verticalScrollContainer.scrollPosition - this.verticalScrollContainer.getScrollForIndex(index)); + } + /** * @hidden * @internal diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index e3b00c36d28..d477ce770ad 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -199,7 +199,6 @@ [style.max-width]="col.resolvedWidth" [style.flex-basis]="col.resolvedWidth" [width]="col.getCellWidth()" - [style.background]="'white'" #cell>
diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index cf02d0ab7d0..fc0df2e4644 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -47,6 +47,14 @@ } + + + @for (rowData of mergedData; track rowData; let rowIndex = $index) { + + + } @if (data | gridTransaction:id:pipeTrigger diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts index 927692b4a45..d1d0a3fa116 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts @@ -389,6 +389,17 @@ export class IgxGridComponent extends IgxGridBaseDirective implements GridType, private _groupByRowSelectorTemplate: TemplateRef; private _detailTemplate; + public get mergedData() { + const startIndex = this.verticalScrollContainer.state.startIndex; + const chunkSize = this.verticalScrollContainer.state.chunkSize; + const mergeRecs = this.verticalScrollContainer.igxForOf?.filter((x, index) => { + if (!x.cellMergeMeta) { return false;} + const maxSpan = Math.max(...x.cellMergeMeta.values().toArray().map(x => x.rowSpan)); + return startIndex > index && startIndex <= (index + maxSpan) && startIndex + chunkSize >= (index + maxSpan); + }); + return mergeRecs; + } + /** * Gets/Sets the array of data that populates the component. diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts index 2aa8eafd495..d9e5900c410 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts @@ -99,15 +99,15 @@ export class IgxGridCellMergePipe implements PipeTransform { //TODO condition can be a strategy or some callback that the user can set. //TODO can also be limited to only sorted columns if ( prev && prev.recordRef[col.field] === rec[col.field]) { - // const root = prev.cellMergeMeta.get(col.field)?.root ?? prev; - // root.cellMergeMeta.get(col.field).rowSpan += 1; - // recData.cellMergeMeta.get(col.field).root = root; - recData.cellMergeMeta.get(col.field).prev = prev; - let curr = prev; - while(curr) { - curr.cellMergeMeta.get(col.field).rowSpan += 1; - curr = curr.cellMergeMeta.get(col.field).prev; - } + const root = prev.cellMergeMeta.get(col.field)?.root ?? prev; + root.cellMergeMeta.get(col.field).rowSpan += 1; + recData.cellMergeMeta.get(col.field).root = root; + // recData.cellMergeMeta.get(col.field).prev = prev; + // let curr = prev; + // while(curr) { + // curr.cellMergeMeta.get(col.field).rowSpan += 1; + // curr = curr.cellMergeMeta.get(col.field).prev; + // } } } prev = recData; diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index 6b2628ac345..bc1c1f1bc9c 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -1,5 +1,5 @@

Grid with cell merge

- + @@ -10,6 +10,4 @@

Grid with cell merge

- -
From bb4e39fcce4350ab6111f6291c00f5b72537c845 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 7 Jul 2025 14:46:10 +0300 Subject: [PATCH 04/99] chore(*): Fix border styles. --- projects/igniteui-angular/src/lib/grids/common/pipes.ts | 2 +- .../src/lib/grids/grid/grid-row.component.html | 8 +++++--- projects/igniteui-angular/src/lib/grids/row.directive.ts | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/common/pipes.ts b/projects/igniteui-angular/src/lib/grids/common/pipes.ts index 818a0ea3854..371245c96ad 100644 --- a/projects/igniteui-angular/src/lib/grids/common/pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/common/pipes.ts @@ -126,7 +126,7 @@ export class IgxGridRowClassesPipe implements PipeTransform { [dirty, 'igx-grid__tr--edited'], [deleted, 'igx-grid__tr--deleted'], [dragging, 'igx-grid__tr--drag'], - [mrl, 'igx-grid__tr--mrl'], + [mrl || _rowData.cellMergeMeta, 'igx-grid__tr--mrl'], // Tree grid only [filteredOut, 'igx-grid__tr--filtered'] ]; diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index d477ce770ad..6111ac5ec15 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -32,9 +32,10 @@ } @if (this.hasMergedCells) { -
@@ -173,6 +174,7 @@ Date: Mon, 7 Jul 2025 15:25:33 +0300 Subject: [PATCH 05/99] chore(*): Add handling for variable row height. --- .../src/lib/grids/grid/grid-row.component.html | 4 ++-- projects/igniteui-angular/src/lib/grids/row.directive.ts | 6 ++++-- src/app/grid-cellMerging/grid-cellMerging.component.html | 7 +++++++ src/app/grid-cellMerging/grid-cellMerging.component.ts | 4 ++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index 6111ac5ec15..a2391599eed 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -32,11 +32,11 @@ } @if (this.hasMergedCells) { -
diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 7a44f1851d1..9ebf02488d6 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -599,11 +599,13 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { protected getMergeCellSpan(col: ColumnType){ const rowCount = this.data.cellMergeMeta.get(col.field).rowSpan; - return `repeat(${rowCount},51px)`; + const rowH = this.grid.verticalScrollContainer.getSizeAt(this.index); + return `repeat(${rowCount},${rowH}px)`; } protected getRowHeight() { - return this.grid.rowHeight; + const size = this.grid.verticalScrollContainer.getSizeAt(this.index) - 1; + return size || this.grid.rowHeight; } /** diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index bc1c1f1bc9c..b5f66aaf5dc 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -1,6 +1,13 @@

Grid with cell merge

+ +
+ +
+ +
+
diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index c4184a997ae..0d79c827d11 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -1,6 +1,8 @@ import { Component, HostBinding, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { + IgxButtonDirective, + IgxCellTemplateDirective, IgxColumnComponent, IgxGridComponent, } from 'igniteui-angular'; @@ -15,6 +17,8 @@ import { data, dataWithoutPK } from '../shared/data'; FormsModule, IgxColumnComponent, IgxGridComponent, + IgxCellTemplateDirective, + IgxButtonDirective ] }) export class GridCellMergingComponent { From 4667bb083ba7d63982b95b333172fff51f9b5221 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 7 Jul 2025 15:44:31 +0300 Subject: [PATCH 06/99] chore(*): Add handling if different rows have different sizes. --- projects/igniteui-angular/src/lib/grids/row.directive.ts | 8 ++++++-- src/app/grid-cellMerging/grid-cellMerging.component.html | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 9ebf02488d6..d61923064bf 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -599,8 +599,12 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { protected getMergeCellSpan(col: ColumnType){ const rowCount = this.data.cellMergeMeta.get(col.field).rowSpan; - const rowH = this.grid.verticalScrollContainer.getSizeAt(this.index); - return `repeat(${rowCount},${rowH}px)`; + let sizeSpans = ""; + for (let index = this.index; index < this.index + rowCount; index++) { + const size = this.grid.verticalScrollContainer.getSizeAt(index); + sizeSpans += size + 'px '; + } + return `${sizeSpans}`; } protected getRowHeight() { diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index b5f66aaf5dc..a745f0cb5ce 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -4,8 +4,11 @@

Grid with cell merge

-
- + @if (cell.row.index % 2 == 0) { +
+ + } +
From febbd43e19776bcfba9cd44e9e83ec6a42a93595 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 7 Jul 2025 16:53:10 +0300 Subject: [PATCH 07/99] chore(*): Extract hardcoded styles in class. --- .../styles/components/grid/_grid-component.scss | 4 ++++ .../core/styles/components/grid/_grid-theme.scss | 15 ++++++++++----- .../src/lib/grids/grid/grid-row.component.html | 2 -- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss index f446ffbe128..93737fc219c 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss @@ -296,6 +296,10 @@ @extend %igx-grid__td--edited !optional; } + @include e(td, $m: merged) { + @extend %igx-grid__td--merged !optional; + } + @include e(td, $m: editing) { @extend %igx-grid__td--editing !optional; } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index 0166b635863..47123b240b3 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1872,6 +1872,11 @@ } } + %igx-grid__td--merged { + z-index: 1; + grid-row: 1 / -1; + } + %igx-grid__tr--deleted { %grid-cell-text { font-style: italic; @@ -2084,7 +2089,7 @@ .sort-icon { color: var-get($theme, 'header-selected-text-color'); - + ::after { background: var-get($theme, 'header-selected-background'); } @@ -2112,7 +2117,7 @@ &%igx-grid-th--sorted { .sort-icon { color: var-get($theme, 'header-selected-text-color'); - + > igx-icon { color: inherit; } @@ -2120,7 +2125,7 @@ &:focus, &:hover { color: var-get($theme, 'header-selected-text-color'); - + > igx-icon { color: inherit; } @@ -2177,14 +2182,14 @@ .sort-icon { opacity: 1; color: var-get($theme, 'sorted-header-icon-color'); - + > igx-icon { color: inherit; } &:hover { color: var-get($theme, 'sortable-header-icon-hover-color'); - + > igx-icon { color: inherit; } diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index a2391599eed..c87ab17861c 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -195,8 +195,6 @@ [active]="isCellActive(col.visibleIndex)" [cellSelectionMode]="grid.cellSelection" [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)" - [style.gridRow]="'1 / -1'" - [style.zIndex]="data.cellMergeMeta.get(col.field).rowSpan" [style.min-width]="col.resolvedWidth" [style.max-width]="col.resolvedWidth" [style.flex-basis]="col.resolvedWidth" From 4c5d922f1e287f9ac160df2c1385b4f42eb7c70a Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 7 Jul 2025 17:27:53 +0300 Subject: [PATCH 08/99] chore(*): Adjust API and members. --- .../src/lib/grids/columns/column.component.ts | 9 +++++++++ .../src/lib/grids/common/enums.ts | 1 - .../src/lib/grids/common/grid.interface.ts | 1 + .../src/lib/grids/grid-base.directive.ts | 2 +- .../src/lib/grids/grid/grid-row.component.html | 8 ++++---- .../src/lib/grids/grid/grid.pipes.ts | 18 ++++++++---------- .../grid-cellMerging.component.html | 6 +++--- 7 files changed, 26 insertions(+), 19 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts index 3482a5076f1..0809995f477 100644 --- a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts +++ b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts @@ -107,6 +107,15 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy return this._field; } + /** + * Sets/gets whether to merge cells in this column. + * ```html + * + * ``` + * + */ + @Input() + public merge = false; /** * @hidden @internal diff --git a/projects/igniteui-angular/src/lib/grids/common/enums.ts b/projects/igniteui-angular/src/lib/grids/common/enums.ts index bd66328c97e..fd19aa101ca 100644 --- a/projects/igniteui-angular/src/lib/grids/common/enums.ts +++ b/projects/igniteui-angular/src/lib/grids/common/enums.ts @@ -81,7 +81,6 @@ export type GridSelectionMode = (typeof GridSelectionMode)[keyof typeof GridSele * - 'onSort': Only merge cells in column that are sorted. */ export const GridCellMergeMode = { - never: 'never', always: 'always', onSort: 'onSort' } as const; diff --git a/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts b/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts index 3b8d8d2e098..259e621c056 100644 --- a/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts +++ b/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts @@ -453,6 +453,7 @@ export interface ColumnType extends FieldType { pinned: boolean; /** Indicates if the column is currently expanded or collapsed. If the value is true, the column is expanded */ expanded: boolean; + merge: boolean; /** Indicates if the column is currently selected. If the value is true, the column is selected */ selected: boolean; /** Indicates if the column can be selected. If the value is true, the column can be selected */ diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 659e5e6e18c..b5a0123f568 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -2918,7 +2918,7 @@ export abstract class IgxGridBaseDirective implements GridType, */ @WatchChanges() @Input() - public cellMergeMode: GridCellMergeMode = GridCellMergeMode.never; + public cellMergeMode: GridCellMergeMode = GridCellMergeMode.onSort; /** * Gets/Sets row selection mode diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index c87ab17861c..0489078ac7d 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -32,11 +32,11 @@ } @if (this.hasMergedCells) { -
@@ -174,7 +174,7 @@ x.merge && (mergeMode ==='always' || + (mergeMode === 'onSort' && !!sortExpr.find( x=> x.fieldName === x.fieldName))) + ); + if (columnToMerge.length === 0) { return collection; } - const visibleColumns = this.grid.visibleColumns; let prev = null; let result = []; for (const rec of collection) { let recData = { recordRef: rec, cellMergeMeta: new Map() }; - for (const col of visibleColumns) { + for (const col of columnToMerge) { recData.cellMergeMeta.set(col.field, { rowSpan: 1 }); //TODO condition can be a strategy or some callback that the user can set. - //TODO can also be limited to only sorted columns if ( prev && prev.recordRef[col.field] === rec[col.field]) { const root = prev.cellMergeMeta.get(col.field)?.root ?? prev; root.cellMergeMeta.get(col.field).rowSpan += 1; recData.cellMergeMeta.get(col.field).root = root; - // recData.cellMergeMeta.get(col.field).prev = prev; - // let curr = prev; - // while(curr) { - // curr.cellMergeMeta.get(col.field).rowSpan += 1; - // curr = curr.cellMergeMeta.get(col.field).prev; - // } } } prev = recData; diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index a745f0cb5ce..ca4bbb3d153 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -12,11 +12,11 @@

Grid with cell merge

- + - + - + From a42b192b63d62753ffc0f19b30a103ccd9ee7e8f Mon Sep 17 00:00:00 2001 From: MKirova Date: Tue, 8 Jul 2025 16:33:27 +0300 Subject: [PATCH 09/99] chore(*): Add mergeStrategy for grid and merging comparer for column. --- .../src/lib/data-operations/data-util.ts | 19 +++++- .../src/lib/data-operations/merge-strategy.ts | 67 +++++++++++++++++++ .../src/lib/grids/columns/column.component.ts | 26 +++++++ .../src/lib/grids/common/grid.interface.ts | 3 + .../src/lib/grids/grid-base.directive.ts | 13 ++++ .../src/lib/grids/grid/grid.pipes.ts | 23 +------ .../src/lib/grids/row.directive.ts | 1 - .../grid-cellMerging.component.html | 6 +- .../grid-cellMerging.component.ts | 24 +++---- 9 files changed, 143 insertions(+), 39 deletions(-) create mode 100644 projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts diff --git a/projects/igniteui-angular/src/lib/data-operations/data-util.ts b/projects/igniteui-angular/src/lib/data-operations/data-util.ts index cc0e0d5ad1c..d3e66f19248 100644 --- a/projects/igniteui-angular/src/lib/data-operations/data-util.ts +++ b/projects/igniteui-angular/src/lib/data-operations/data-util.ts @@ -8,7 +8,7 @@ import { IGroupingState } from './groupby-state.interface'; import { mergeObjects } from '../core/utils'; import { Transaction, TransactionType, HierarchicalTransaction } from '../services/transaction/transaction'; import { getHierarchy, isHierarchyMatch } from './operations'; -import { GridType } from '../grids/common/grid.interface'; +import { ColumnType, GridType } from '../grids/common/grid.interface'; import { ITreeGridRecord } from '../grids/tree-grid/tree-grid.interfaces'; import { ISortingExpression } from './sorting-strategy'; import { @@ -20,6 +20,7 @@ import { } from '../grids/common/strategy'; import { DefaultDataCloneStrategy, IDataCloneStrategy } from '../data-operations/data-clone-strategy'; import { IGroupingExpression } from './grouping-expression.interface'; +import { DefaultMergeStrategy, IGridMergeStrategy } from './merge-strategy'; /** * @hidden @@ -37,6 +38,13 @@ import { IGroupingExpression } from './grouping-expression.interface'; } as const; export type DataType = (typeof DataType)[keyof typeof DataType]; + +export interface IMergeByResult { + rowSpan: number; + root?: any; + prev?: any; +} + /** * @hidden */ @@ -90,6 +98,15 @@ export class DataUtil { return grouping.groupBy(data, state, grid, groupsRecords, fullResult); } + public static merge(data: T[], columns: ColumnType[], strategy: IGridMergeStrategy = new DefaultMergeStrategy(), grid: GridType = null, + ): any[] { + let result = []; + for (const col of columns) { + strategy.merge(data, col.field, col.mergingComparer, result); + } + return result; +} + public static page(data: T[], state: IPagingState, dataLength?: number): T[] { if (!state) { return data; diff --git a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts new file mode 100644 index 00000000000..c9afd6fc3f9 --- /dev/null +++ b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts @@ -0,0 +1,67 @@ +import { GridCellMergeMode, IMergeByResult } from 'igniteui-angular'; +import type { KeyOfOrString } from '../core/types'; +import { IBaseEventArgs } from '../core/utils'; +import { ColumnType, GridType } from '../grids/common/grid.interface'; + + +export interface IGridMergeStrategy { + /* blazorSuppress */ + merge: ( + data: any[], + field: string, + comparer: (prevRecord: any, currentRecord: any, field: string) => boolean, + result: any[] + ) => any[]; +} + +export class DefaultMergeStrategy implements IGridMergeStrategy { + protected static _instance: DefaultMergeStrategy = null; + + public static instance(): DefaultMergeStrategy { + return this._instance || (this._instance = new this()); + } + + /* blazorSuppress */ + public merge( + data: any[], + field: string, + comparer: (prevRecord: any, record: any, field: string) => boolean = this.comparer, + result: any[] + ) { + let prev = null; + let index = 0; + for (const rec of data) { + const recData = result[index]; + let recToUpdateData = recData ?? { recordRef: rec, cellMergeMeta: new Map() }; + recToUpdateData.cellMergeMeta.set(field, { rowSpan: 1 }); + if (prev && comparer(prev.recordRef, recToUpdateData.recordRef, field)) { + const root = prev.cellMergeMeta.get(field)?.root ?? prev; + root.cellMergeMeta.get(field).rowSpan += 1; + recToUpdateData.cellMergeMeta.get(field).root = root; + } + prev = recToUpdateData; + if (!recData) { + result.push(recToUpdateData); + } + index++; + } + return result; + } + + /* blazorSuppress */ + public comparer(prevRecord: any, record: any, field: string): boolean { + const a = prevRecord[field]; + const b = record[field]; + const an = (a === null || a === undefined); + const bn = (b === null || b === undefined); + if (an) { + if (bn) { + return true; + } + return false; + } else if (bn) { + return false; + } + return a === b; + } +} diff --git a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts index 0809995f477..58b3de78586 100644 --- a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts +++ b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts @@ -1209,6 +1209,30 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy this._sortStrategy = classRef; } + /* blazorSuppress */ + /** + * Gets the function that compares values for merging. + * ```typescript + * let mergingComparer = this.column.mergingComparer' + * ``` + */ + @Input() + public get mergingComparer(): (prevRecord: any, record: any, field: string) => boolean { + return this._mergingComparer; + } + + /* blazorSuppress */ + /** + * Sets a custom function to compare values for merging. + * ```typescript + * this.column.mergingComparer = (prevRecord: any, record: any, field: string) => { return prevRecord[field] === record[field]; } + * ``` + */ + public set mergingComparer(funcRef: (prevRecord: any, record: any, field: string) => boolean) { + this._mergingComparer = funcRef; + } + + /* blazorSuppress */ /** * Gets the function that compares values for grouping. @@ -1849,6 +1873,8 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy * @hidden */ protected _groupingComparer: (a: any, b: any, currRec?: any, groupRec?: any) => number; + + protected _mergingComparer: (prevRecord: any, record: any, field: string) => boolean; /** * @hidden */ diff --git a/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts b/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts index 259e621c056..308c6be4084 100644 --- a/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts +++ b/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts @@ -38,6 +38,7 @@ import { IDimensionsChange, IPivotConfiguration, IPivotDimension, IPivotKeys, IP import { IDataCloneStrategy } from '../../data-operations/data-clone-strategy'; import { FormControl, FormGroup, ValidationErrors } from '@angular/forms'; import { IgxGridValidationService } from '../grid/grid-validation.service'; +import { IGridMergeStrategy } from '../../data-operations/merge-strategy'; export const IGX_GRID_BASE = /*@__PURE__*/new InjectionToken('IgxGridBaseToken'); export const IGX_GRID_SERVICE_BASE = /*@__PURE__*/new InjectionToken('IgxGridServiceBaseToken'); @@ -335,6 +336,7 @@ export interface ColumnType extends FieldType { /** @hidden @internal */ headerCell: any; validators: any[]; + mergingComparer: (prevRecord: any, record: any, field: string) => boolean; /** * The template reference for the custom header of the column @@ -692,6 +694,7 @@ export interface GridType extends IGridDataBindable { /** Represents the locale of the grid: `USD`, `EUR`, `GBP`, `CNY`, `JPY`, etc. */ locale: string; cellMergeMode: GridCellMergeMode; + mergeStrategy: IGridMergeStrategy; resourceStrings: IGridResourceStrings; /* blazorSuppress */ /** Represents the native HTML element itself */ diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index b5a0123f568..b2baeb1cc30 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -185,6 +185,7 @@ import { IgxGridValidationService } from './grid/grid-validation.service'; import { getCurrentResourceStrings } from '../core/i18n/resources'; import { isTree, recreateTree, recreateTreeFromFields } from '../data-operations/expressions-tree-util'; import { getUUID } from './common/random'; +import { IGridMergeStrategy } from '../data-operations/merge-strategy'; interface IMatchInfoCache { row: any; @@ -2494,6 +2495,18 @@ export abstract class IgxGridBaseDirective implements GridType, this._sortingStrategy = value; } + + /** + * Gets/Sets the merge strategy of the grid. + * + * @example + * ```html + * + * ``` + */ + @Input() + public mergeStrategy: IGridMergeStrategy; + /** * Gets/Sets the sorting options - single or multiple sorting. * Accepts an `ISortingOptions` object with any of the `mode` properties. diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts index 73d87e9c00d..43267186900 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts @@ -95,32 +95,11 @@ export class IgxGridCellMergePipe implements PipeTransform { if (columnToMerge.length === 0) { return collection; } - let prev = null; - let result = []; - for (const rec of collection) { - let recData = { recordRef: rec, cellMergeMeta: new Map() }; - for (const col of columnToMerge) { - recData.cellMergeMeta.set(col.field, { rowSpan: 1 }); - //TODO condition can be a strategy or some callback that the user can set. - if ( prev && prev.recordRef[col.field] === rec[col.field]) { - const root = prev.cellMergeMeta.get(col.field)?.root ?? prev; - root.cellMergeMeta.get(col.field).rowSpan += 1; - recData.cellMergeMeta.get(col.field).root = root; - } - } - prev = recData; - result.push(recData); - } + const result = DataUtil.merge(cloneArray(collection), columnToMerge, this.grid.mergeStrategy, this.grid); return result; } } -export interface IMergeByResult { - rowSpan: number; - root?: any; - prev?: any; -} - /** * @hidden */ diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index d61923064bf..38dde79226c 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -27,7 +27,6 @@ import { mergeWith } from 'lodash-es'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { trackByIdentity } from '../core/utils'; -import { IMergeByResult } from './grid/grid.pipes'; @Directive({ selector: '[igxRowBaseComponent]', diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index ca4bbb3d153..9d7d1814595 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -3,11 +3,11 @@

Grid with cell merge

- - @if (cell.row.index % 2 == 0) { + +
diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index 0d79c827d11..7443240d731 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -190,7 +190,7 @@ export class GridCellMergingComponent { OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() }, { - ProductID: 1, + ProductID: 13, ProductName: 'Chai', SupplierID: 1, CategoryID: 1, @@ -203,7 +203,7 @@ export class GridCellMergingComponent { OrderDate: null, OrderDate2: new Date(1991, 2, 12, 18, 40, 50).toISOString() }, { - ProductID: 2, + ProductID: 14, ProductName: 'Chai', SupplierID: 1, CategoryID: 1, @@ -217,7 +217,7 @@ export class GridCellMergingComponent { OrderDate2: new Date('2003-03-17').toISOString() }, { - ProductID: 3, + ProductID: 15, ProductName: 'Chai', SupplierID: 1, CategoryID: 1, @@ -231,7 +231,7 @@ export class GridCellMergingComponent { OrderDate2: new Date('2003-03-17').toISOString() }, { - ProductID: 4, + ProductID: 16, ProductName: 'Chai', SupplierID: 1, CategoryID: 1, @@ -245,7 +245,7 @@ export class GridCellMergingComponent { OrderDate2: new Date('2003-03-17').toISOString() }, { - ProductID: 5, + ProductID: 17, ProductName: 'Chai', SupplierID: 1, CategoryID: 1, @@ -259,7 +259,7 @@ export class GridCellMergingComponent { OrderDate2: new Date('2003-03-17').toISOString() }, { - ProductID: 6, + ProductID: 18, ProductName: 'Chang', SupplierID: 1, CategoryID: 1, @@ -273,7 +273,7 @@ export class GridCellMergingComponent { OrderDate2: new Date('2003-03-17').toISOString() }, { - ProductID: 7, + ProductID: 19, ProductName: 'Chang', SupplierID: 1, CategoryID: 1, @@ -287,7 +287,7 @@ export class GridCellMergingComponent { OrderDate2: new Date('2003-03-17').toISOString() }, { - ProductID: 8, + ProductID: 20, ProductName: 'Chang', SupplierID: 1, CategoryID: 1, @@ -301,7 +301,7 @@ export class GridCellMergingComponent { OrderDate2: new Date('2003-03-17').toISOString() }, { - ProductID: 9, + ProductID: 21, ProductName: 'Aniseed Syrup', SupplierID: 1, CategoryID: 2, @@ -315,7 +315,7 @@ export class GridCellMergingComponent { OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() }, { - ProductID: 10, + ProductID: 22, ProductName: 'Chang', SupplierID: 1, CategoryID: 2, @@ -329,7 +329,7 @@ export class GridCellMergingComponent { OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() }, { - ProductID: 11, + ProductID: 23, ProductName: 'Chai', SupplierID: 1, CategoryID: 2, @@ -343,7 +343,7 @@ export class GridCellMergingComponent { OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() }, { - ProductID: 12, + ProductID: 24, ProductName: 'Chai', SupplierID: 1, CategoryID: 2, From 13619527242455f4da928093c97ec0c63b7bec48 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 9 Jul 2025 13:12:20 +0300 Subject: [PATCH 10/99] chore(*): Adjust pipe triggers. --- .../src/lib/grids/grid/grid.component.html | 2 +- .../igniteui-angular/src/lib/grids/grid/grid.pipes.ts | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index fc0df2e4644..3af125f1e03 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -89,7 +89,7 @@ | gridDetails:hasDetails:expansionStates:pipeTrigger | gridAddRow:false:pipeTrigger | gridRowPinning:id:false:pipeTrigger - | gridCellMerge:pipeTrigger" + | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:pipeTrigger" let-rowIndex="index" [igxForScrollOrientation]="'vertical'" [igxForScrollContainer]="verticalScroll" [igxForContainerSize]="calcHeight" [igxForItemSize]="hasColumnLayouts ? rowHeight * multiRowLayoutRowSize + 1 : renderedRowHeight" diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts index 43267186900..77d52bcbad1 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts @@ -5,7 +5,7 @@ import { IGroupByExpandState } from '../../data-operations/groupby-expand-state. import { IGroupByResult } from '../../data-operations/grouping-result.interface'; import { IFilteringExpressionsTree, FilteringExpressionsTree } from '../../data-operations/filtering-expressions-tree'; import { IGroupingExpression } from '../../data-operations/grouping-expression.interface'; -import { GridType, IGX_GRID_BASE } from '../common/grid.interface'; +import { ColumnType, GridType, IGX_GRID_BASE } from '../common/grid.interface'; import { FilterUtil, IFilteringStrategy } from '../../data-operations/filtering-strategy'; import { ISortingExpression } from '../../data-operations/sorting-strategy'; import { IGridSortingStrategy, IGridGroupingStrategy } from '../common/strategy'; @@ -85,10 +85,8 @@ export class IgxGridCellMergePipe implements PipeTransform { constructor(@Inject(IGX_GRID_BASE) private grid: GridType) { } - public transform(collection: any, _pipeTrigger: number) { - const mergeMode = this.grid.cellMergeMode; - const sortExpr = this.grid.sortingExpressions; - const columnToMerge = this.grid.visibleColumns.filter( + public transform(collection: any, visibleColumns: ColumnType[], mergeMode: GridCellMergeMode, sortExpr: ISortingExpression[], _pipeTrigger: number) { + const columnToMerge = visibleColumns.filter( x => x.merge && (mergeMode ==='always' || (mergeMode === 'onSort' && !!sortExpr.find( x=> x.fieldName === x.fieldName))) ); From 407357f5e58adcd6eb26a18d9d11cdae605cad88 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 9 Jul 2025 14:42:42 +0300 Subject: [PATCH 11/99] chore(*): Re-use cell templates. --- .../src/lib/grids/common/pipes.ts | 3 +- .../src/lib/grids/grid-base.directive.ts | 2 +- .../lib/grids/grid/grid-row.component.html | 42 +++---------------- .../src/lib/grids/grid/grid.component.html | 14 +++---- .../src/lib/grids/grid/grid.component.ts | 5 ++- .../hierarchical-grid.component.html | 4 +- .../pivot-grid/pivot-grid.component.html | 2 +- .../src/lib/grids/row.directive.ts | 10 ++++- .../grids/tree-grid/tree-grid.component.html | 4 +- .../grid-cellMerging.component.html | 6 ++- .../grid-cellMerging.component.ts | 4 +- 11 files changed, 39 insertions(+), 57 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/common/pipes.ts b/projects/igniteui-angular/src/lib/grids/common/pipes.ts index 371245c96ad..27d0667fbeb 100644 --- a/projects/igniteui-angular/src/lib/grids/common/pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/common/pipes.ts @@ -115,6 +115,7 @@ export class IgxGridRowClassesPipe implements PipeTransform { dragging: boolean, index: number, mrl: boolean, + merged: boolean, filteredOut: boolean, _rowData: any, _: number @@ -126,7 +127,7 @@ export class IgxGridRowClassesPipe implements PipeTransform { [dirty, 'igx-grid__tr--edited'], [deleted, 'igx-grid__tr--deleted'], [dragging, 'igx-grid__tr--drag'], - [mrl || _rowData.cellMergeMeta, 'igx-grid__tr--mrl'], + [mrl || merged, 'igx-grid__tr--mrl'], // Tree grid only [filteredOut, 'igx-grid__tr--filtered'] ]; diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index b2baeb1cc30..44db042c792 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3662,7 +3662,7 @@ export abstract class IgxGridBaseDirective implements GridType, * @internal */ public isRecordMerged(rec) { - return rec.cellMergeMeta; + return rec?.cellMergeMeta; } public getMergeCellOffset(rec) { diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index 0489078ac7d..dd1eac61f3f 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -32,13 +32,13 @@ } @if (this.hasMergedCells) { -
- +
} @else { @@ -108,6 +108,7 @@ - - - - - } @@ -105,15 +105,15 @@ - - + - + diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts index d1d0a3fa116..8395d09cf5e 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts @@ -960,13 +960,14 @@ export class IgxGridComponent extends IgxGridBaseDirective implements GridType, } } return { - $implicit: this.isGhostRecord(rowData) ? rowData.recordRef : rowData, + $implicit: this.isGhostRecord(rowData) || this.isRecordMerged(rowData) ? rowData.recordRef : rowData, index: this.getDataViewIndex(rowIndex, pinned), templateID: { type: this.isGroupByRecord(rowData) ? 'groupRow' : this.isSummaryRow(rowData) ? 'summaryRow' : 'dataRow', id: null }, - disabled: this.isGhostRecord(rowData) + disabled: this.isGhostRecord(rowData), + metaData: rowData }; } diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html index d5f31ada6b4..59be7cefe5b 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html @@ -75,14 +75,14 @@ diff --git a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.component.html b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.component.html index 9ca659192f9..95089b96554 100644 --- a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.component.html @@ -52,7 +52,7 @@
diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 38dde79226c..d4e4b6ae5c6 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -45,6 +45,12 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { @HostBinding('attr.role') public role = 'row'; + /** + * @hidden + */ + @Input() + public metaData: any; + /** * The data passed to the row component. * @@ -118,7 +124,7 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { } public get hasMergedCells(): boolean { - return this.grid.isRecordMerged(this.data); + return this.grid.isRecordMerged(this.metaData); } /** @@ -597,7 +603,7 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { } protected getMergeCellSpan(col: ColumnType){ - const rowCount = this.data.cellMergeMeta.get(col.field).rowSpan; + const rowCount = this.metaData.cellMergeMeta.get(col.field).rowSpan; let sizeSpans = ""; for (let index = this.index; index < this.index + rowCount; index++) { const size = this.grid.verticalScrollContainer.getSizeAt(index); diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index 2521cc28e58..5db61b5629c 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -78,13 +78,13 @@ diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index 9d7d1814595..da15f25881d 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -1,5 +1,5 @@

Grid with cell merge

- +
@@ -12,7 +12,7 @@

Grid with cell merge

- + @@ -20,4 +20,6 @@

Grid with cell merge

+ +
diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index 7443240d731..527180f1a09 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -5,6 +5,7 @@ import { IgxCellTemplateDirective, IgxColumnComponent, IgxGridComponent, + IgxPaginatorComponent, } from 'igniteui-angular'; import { data, dataWithoutPK } from '../shared/data'; @@ -18,7 +19,8 @@ import { data, dataWithoutPK } from '../shared/data'; IgxColumnComponent, IgxGridComponent, IgxCellTemplateDirective, - IgxButtonDirective + IgxButtonDirective, + IgxPaginatorComponent ] }) export class GridCellMergingComponent { From f23dd6cf4ed5915851605635c4c541d9397acdde Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 9 Jul 2025 14:51:01 +0300 Subject: [PATCH 12/99] chore(*): Fix row indexes in merged row area. --- .../igniteui-angular/src/lib/grids/grid/grid.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index 2f84f207d22..fdb8366e80b 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -50,7 +50,7 @@ @for (rowData of mergedData; track rowData; let rowIndex = $index) { - From f6ffa428065517c39a3168dd4260468c29463dce Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 9 Jul 2025 18:22:03 +0300 Subject: [PATCH 13/99] chore(*): Adjust some styles for row selection. --- .../core/styles/components/grid/_grid-theme.scss | 14 ++++++++++++++ .../grid-cellMerging.component.html | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index 47123b240b3..37d8794db01 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1825,6 +1825,20 @@ color: var-get($theme, 'row-selected-text-color'); background: var-get($theme, 'row-selected-background'); + &%grid-row--mrl { + background: transparent; + %grid-mrl-block { + color: var-get($theme, 'row-selected-text-color'); + background: var-get($theme, 'row-selected-background'); + %igx-grid__td--merged { + color: var-get($theme, 'row-selected-text-color'); + background: var-get($theme, 'row-selected-background'); + } + } + } + + + %grid-cell--selected, %grid-cell--pinned-selected { color: var-get($theme, 'cell-selected-within-text-color'); diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index da15f25881d..21d28741a49 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -1,5 +1,5 @@

Grid with cell merge

- +
From 4d1b981a518b1a430903b35c4d3c02029a8b4807 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 10 Jul 2025 16:15:28 +0300 Subject: [PATCH 14/99] chore(*): Merged cells navigation. --- .../src/lib/grids/grid-base.directive.ts | 18 +++ .../grids/grid-merge-navigation.service.ts | 108 ++++++++++++++++++ .../src/lib/grids/grid/grid.component.ts | 3 +- .../src/lib/grids/row.directive.ts | 6 + 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 projects/igniteui-angular/src/lib/grids/grid-merge-navigation.service.ts diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 44db042c792..e9d3ec5710c 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -186,6 +186,7 @@ import { getCurrentResourceStrings } from '../core/i18n/resources'; import { isTree, recreateTree, recreateTreeFromFields } from '../data-operations/expressions-tree-util'; import { getUUID } from './common/random'; import { IGridMergeStrategy } from '../data-operations/merge-strategy'; +import { IgxGridMergeNavigationService } from './grid-merge-navigation.service'; interface IMatchInfoCache { row: any; @@ -3984,6 +3985,23 @@ export abstract class IgxGridBaseDirective implements GridType, if (this.actionStrip) { this.actionStrip.menuOverlaySettings.outlet = this.outlet; } + this._setupNavigationService(); + } + + protected _setupNavigationService() { + if (this.hasCellsToMerge) { + this.navigation = new IgxGridMergeNavigationService(this.platform); + this.navigation.grid = this; + } + } + + protected get hasCellsToMerge() { + const columnToMerge = this.visibleColumns.filter( + x => x.merge && (this.cellMergeMode ==='always' || + (this.cellMergeMode === 'onSort' && !!this.sortingExpressions + .find( x=> x.fieldName === x.fieldName))) + ); + return columnToMerge.length > 0; } /** diff --git a/projects/igniteui-angular/src/lib/grids/grid-merge-navigation.service.ts b/projects/igniteui-angular/src/lib/grids/grid-merge-navigation.service.ts new file mode 100644 index 00000000000..295f100a30d --- /dev/null +++ b/projects/igniteui-angular/src/lib/grids/grid-merge-navigation.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@angular/core'; +import { IgxGridNavigationService } from './grid-navigation.service'; +import { first } from 'rxjs/operators'; +/** @hidden */ +@Injectable() +export class IgxGridMergeNavigationService extends IgxGridNavigationService { + public override shouldPerformVerticalScroll(targetRowIndex: number, visibleColIndex: number): boolean { + const targetRec = this.grid.verticalScrollContainer.igxForOf[targetRowIndex]; + const field = this.grid.visibleColumns[visibleColIndex]?.field; + const rowSpan = targetRec?.cellMergeMeta?.get(field)?.rowSpan; + if (rowSpan > 1) { + const targetRow = super.getRowElementByIndex(targetRowIndex); + const containerHeight = this.grid.calcHeight ? Math.ceil(this.grid.calcHeight) : 0; + const scrollPos = this.getVerticalScrollPositions(targetRowIndex, rowSpan); + return (!targetRow || targetRow.offsetTop < Math.abs(this.containerTopOffset) + || containerHeight && containerHeight < scrollPos.rowBottom - Math.ceil(this.scrollTop)); + } else { + return super.shouldPerformVerticalScroll(targetRowIndex, visibleColIndex); + } + } + + public override performVerticalScrollToCell(rowIndex: number, visibleColIndex: number, cb?: () => void) { + const targetRec = this.grid.verticalScrollContainer.igxForOf[rowIndex]; + const field = this.grid.visibleColumns[visibleColIndex]?.field; + const rowSpan = targetRec?.cellMergeMeta?.get(field)?.rowSpan; + if (rowSpan > 1) { + const containerHeight = this.grid.calcHeight ? Math.ceil(this.grid.calcHeight) : 0; + const pos = this.getVerticalScrollPositions(rowIndex, rowSpan); + const row = super.getRowElementByIndex(rowIndex); + if ((this.scrollTop > pos.rowTop) && (!row || row.offsetTop < Math.abs(this.containerTopOffset))) { + if (pos.topOffset === 0) { + this.grid.verticalScrollContainer.scrollTo(rowIndex); + } else { + this.grid.verticalScrollContainer.scrollPosition = pos.rowTop; + } + } else { + this.grid.verticalScrollContainer.addScrollTop(Math.abs(pos.rowBottom - this.scrollTop - containerHeight)); + } + this.grid.verticalScrollContainer.chunkLoad + .pipe(first()).subscribe(() => { + if (cb) { + cb(); + } + }); + } else { + super.performVerticalScrollToCell(rowIndex, visibleColIndex, cb); + } + } + + protected override getNextPosition(rowIndex: number, colIndex: number, key: string, shift: boolean, ctrl: boolean, event: KeyboardEvent) { + + const field = this.grid.visibleColumns[colIndex]?.field; + switch (key) { + case 'tab': + case ' ': + case 'spacebar': + case 'space': + case 'escape': + case 'esc': + case 'enter': + case 'f2': + case 'left': + case 'arrowleft': + case 'arrowright': + case 'right': + // same as base for these keys + return super.getNextPosition(rowIndex, colIndex, key, shift, ctrl, event); + break; + case 'end': + rowIndex = ctrl ? this.findLastDataRowIndex() : this.activeNode.row; + colIndex = this.lastColumnIndex; + break; + case 'home': + rowIndex = ctrl ? this.findFirstDataRowIndex() : this.activeNode.row; + colIndex = 0; + break; + case 'arrowup': + case 'up': + const prevRec = this.grid.verticalScrollContainer.igxForOf[this.activeNode.row - 1]; + const root = prevRec?.cellMergeMeta?.get(field)?.root; + const prev = this.activeNode.row - (root?.cellMergeMeta?.get(field).rowSpan || 1); + colIndex = this.activeNode.column !== undefined ? this.activeNode.column : 0; + rowIndex = ctrl ? this.findFirstDataRowIndex() : prev; + break; + case 'arrowdown': + case 'down': + const rec = this.grid.verticalScrollContainer.igxForOf[this.activeNode.row]; + const next = this.activeNode.row + (rec?.cellMergeMeta?.get(field)?.rowSpan || 1); + colIndex = this.activeNode.column !== undefined ? this.activeNode.column : 0; + rowIndex = ctrl ? this.findLastDataRowIndex() : next; + break; + default: + return; + } + return { rowIndex, colIndex }; + } + + private getVerticalScrollPositions(rowIndex: number, rowSpan: number) { + const rowTop = this.grid.verticalScrollContainer.sizesCache[rowIndex]; + const rowBottom = this.grid.verticalScrollContainer.sizesCache[rowIndex + rowSpan]; + const topOffset = rowBottom - rowTop; + return { topOffset, rowTop, rowBottom }; + } + + private get scrollTop(): number { + return Math.abs(this.grid.verticalScrollContainer.getScroll().scrollTop); + } +} diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts index 8395d09cf5e..b3ea155bd0c 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts @@ -1357,11 +1357,12 @@ export class IgxGridComponent extends IgxGridBaseDirective implements GridType, this._gridAPI.sort_groupBy_multiple(this._groupingExpressions); } - private _setupNavigationService() { + protected override _setupNavigationService() { if (this.hasColumnLayouts) { this.navigation = new IgxGridMRLNavigationService(this.platform); this.navigation.grid = this; } + super._setupNavigationService(); } private checkIfNoColumnField(expression: IGroupingExpression | Array | any): boolean { diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index d4e4b6ae5c6..1cdcacc5c3e 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -522,6 +522,12 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { public isCellActive(visibleColumnIndex) { const node = this.grid.navigation.activeNode; + const field = this.grid.visibleColumns[visibleColumnIndex].field; + const rowSpan = this.metaData?.cellMergeMeta?.get(field)?.rowSpan; + if (rowSpan > 1) { + return node ? (node.row >= this.index && node.row < this.index + rowSpan) + && node.column === visibleColumnIndex : false; + } return node ? node.row === this.index && node.column === visibleColumnIndex : false; } From aca57ee8a9a9d0fb09c394654daeb8bed62e6d52 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 10 Jul 2025 17:03:50 +0300 Subject: [PATCH 15/99] chore(*): Adjust for scenarios after horizontal nav into merged cell. --- .../src/lib/grids/grid-merge-navigation.service.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-merge-navigation.service.ts b/projects/igniteui-angular/src/lib/grids/grid-merge-navigation.service.ts index 295f100a30d..870c26e34b5 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-merge-navigation.service.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-merge-navigation.service.ts @@ -48,8 +48,10 @@ export class IgxGridMergeNavigationService extends IgxGridNavigationService { } protected override getNextPosition(rowIndex: number, colIndex: number, key: string, shift: boolean, ctrl: boolean, event: KeyboardEvent) { - const field = this.grid.visibleColumns[colIndex]?.field; + const currentRec = this.grid.verticalScrollContainer.igxForOf[this.activeNode.row]; + const currentRootRec = currentRec?.cellMergeMeta?.get(field)?.root; + const currentIndex = currentRootRec ? this.grid.verticalScrollContainer.igxForOf.indexOf(currentRootRec) : this.activeNode.row; switch (key) { case 'tab': case ' ': @@ -76,16 +78,15 @@ export class IgxGridMergeNavigationService extends IgxGridNavigationService { break; case 'arrowup': case 'up': - const prevRec = this.grid.verticalScrollContainer.igxForOf[this.activeNode.row - 1]; - const root = prevRec?.cellMergeMeta?.get(field)?.root; - const prev = this.activeNode.row - (root?.cellMergeMeta?.get(field).rowSpan || 1); + const prevRec = this.grid.verticalScrollContainer.igxForOf[currentIndex - 1]; + const prevRoot = prevRec?.cellMergeMeta?.get(field)?.root; + const prev = currentIndex - (prevRoot?.cellMergeMeta?.get(field).rowSpan || 1); colIndex = this.activeNode.column !== undefined ? this.activeNode.column : 0; rowIndex = ctrl ? this.findFirstDataRowIndex() : prev; break; case 'arrowdown': case 'down': - const rec = this.grid.verticalScrollContainer.igxForOf[this.activeNode.row]; - const next = this.activeNode.row + (rec?.cellMergeMeta?.get(field)?.rowSpan || 1); + const next = currentIndex + ((currentRootRec || currentRec)?.cellMergeMeta?.get(field)?.rowSpan || 1); colIndex = this.activeNode.column !== undefined ? this.activeNode.column : 0; rowIndex = ctrl ? this.findLastDataRowIndex() : next; break; From e168c3a30ad05133a6eccc48a072bf2674fbeb08 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 10 Jul 2025 18:42:05 +0300 Subject: [PATCH 16/99] chore(*): Add integration with pinning. --- .../src/lib/data-operations/merge-strategy.ts | 10 ++++++++++ .../src/lib/grids/grid-base.directive.ts | 6 +++++- .../src/lib/grids/grid/grid.component.ts | 2 +- .../igniteui-angular/src/lib/grids/row.directive.ts | 6 ++++-- .../grid-cellMerging/grid-cellMerging.component.html | 5 ++++- src/app/grid-cellMerging/grid-cellMerging.component.ts | 6 +++++- 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts index c9afd6fc3f9..183b29ed4c6 100644 --- a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts +++ b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts @@ -31,7 +31,17 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { let prev = null; let index = 0; for (const rec of data) { + const recData = result[index]; + // if this is some special record type - add and skip merging + if (rec.ghostRecord) { + if(!recData) { + result.push(rec); + } + prev = null; + index++; + continue; + } let recToUpdateData = recData ?? { recordRef: rec, cellMergeMeta: new Map() }; recToUpdateData.cellMergeMeta.set(field, { rowSpan: 1 }); if (prev && comparer(prev.recordRef, recToUpdateData.recordRef, field)) { diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index e9d3ec5710c..a2a5116ce34 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3668,7 +3668,11 @@ export abstract class IgxGridBaseDirective implements GridType, public getMergeCellOffset(rec) { const index = this.verticalScrollContainer.igxForOf.indexOf(rec); - return -(this.verticalScrollContainer.scrollPosition - this.verticalScrollContainer.getScrollForIndex(index)); + let offset = this.verticalScrollContainer.scrollPosition - this.verticalScrollContainer.getScrollForIndex(index); + if (this.hasPinnedRecords && this.isRowPinningToTop) { + offset -= this.pinnedRowHeight; + } + return -offset; } /** diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts index b3ea155bd0c..e48438fad91 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts @@ -967,7 +967,7 @@ export class IgxGridComponent extends IgxGridBaseDirective implements GridType, id: null }, disabled: this.isGhostRecord(rowData), - metaData: rowData + metaData: this.isRecordMerged(rowData) ? rowData : null }; } diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 1cdcacc5c3e..94df28fb9f0 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -611,7 +611,8 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { protected getMergeCellSpan(col: ColumnType){ const rowCount = this.metaData.cellMergeMeta.get(col.field).rowSpan; let sizeSpans = ""; - for (let index = this.index; index < this.index + rowCount; index++) { + const indexInData = this.grid.verticalScrollContainer.igxForOf.indexOf(this.metaData); + for (let index = indexInData; index < indexInData + rowCount; index++) { const size = this.grid.verticalScrollContainer.getSizeAt(index); sizeSpans += size + 'px '; } @@ -619,7 +620,8 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { } protected getRowHeight() { - const size = this.grid.verticalScrollContainer.getSizeAt(this.index) - 1; + const indexInData = this.grid.verticalScrollContainer.igxForOf.indexOf(this.metaData); + const size = this.grid.verticalScrollContainer.getSizeAt(indexInData) - 1; return size || this.grid.rowHeight; } diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index 21d28741a49..a98f0885a7a 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -3,7 +3,7 @@

Grid with cell merge

- + - - + - - + diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.ts b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.ts index 69695e21ee2..ea8a0a04543 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.ts @@ -50,7 +50,7 @@ import { IgxGridValidationService } from '../grid/grid-validation.service'; import { IgxGridHierarchicalPipe, IgxGridHierarchicalPagingPipe } from './hierarchical-grid.pipes'; import { IgxSummaryDataPipe } from '../summaries/grid-root-summary.pipe'; import { IgxGridTransactionPipe, IgxHasVisibleColumnsPipe, IgxGridRowPinningPipe, IgxGridAddRowPipe, IgxGridRowClassesPipe, IgxGridRowStylesPipe, IgxStringReplacePipe } from '../common/pipes'; -import { IgxGridSortingPipe, IgxGridFilteringPipe } from '../grid/grid.pipes'; +import { IgxGridSortingPipe, IgxGridFilteringPipe, IgxGridCellMergePipe } from '../grid/grid.pipes'; import { IgxGridColumnResizerComponent } from '../resizing/resizer.component'; import { IgxRowEditTabStopDirective } from '../grid.rowEdit.directive'; import { IgxIconComponent } from '../../icon/icon.component'; @@ -350,7 +350,8 @@ export class IgxChildGridRowComponent implements AfterViewInit, OnInit { IgxSummaryDataPipe, IgxGridHierarchicalPipe, IgxGridHierarchicalPagingPipe, - IgxStringReplacePipe + IgxStringReplacePipe, + IgxGridCellMergePipe ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) @@ -942,7 +943,7 @@ export class IgxHierarchicalGridComponent extends IgxHierarchicalGridBaseDirecti /** * @hidden */ - public isChildGridRecord(record: any): boolean { + public override isChildGridRecord(record: any): boolean { // Can be null when there is defined layout but no child data was found return record?.childGridsData !== undefined; } @@ -986,13 +987,14 @@ export class IgxHierarchicalGridComponent extends IgxHierarchicalGridBaseDirecti } } else { return { - $implicit: this.isGhostRecord(rowData) ? rowData.recordRef : rowData, + $implicit: this.isGhostRecord(rowData) || this.isRecordMerged(rowData) ? rowData.recordRef : rowData, templateID: { type: 'dataRow', id: null }, index: this.getDataViewIndex(rowIndex, pinned), - disabled: this.isGhostRecord(rowData) + disabled: this.isGhostRecord(rowData), + metaData: this.isRecordMerged(rowData) ? rowData : null }; } } diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html index 42d4a19c5d3..4c286034977 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html @@ -54,32 +54,19 @@ } - - + @if (this.hasMergedCells) { +
+ +
+ } + @else { + + }
@if (pinnedColumns.length > 0 && !grid.isPinningToStart) { @@ -132,3 +119,35 @@ } + + + + + diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index 0be130b991f..f73e2c33de8 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -1,15 +1,16 @@

Grid with cell merge

- + -
- - -
+
@@ -33,3 +34,31 @@

Grid with cell merge

+ +

Hierarchical grid with cell merge

+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index f60a23cd7a9..e44ce6a7179 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -12,8 +12,11 @@ import { IgxGridToolbarExporterComponent, IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent, + IgxHierarchicalGridComponent, IgxPaginatorComponent, + IgxRowIslandComponent, } from 'igniteui-angular'; +import { HIERARCHICAL_DATA } from '../shared/hierarchicalData'; import { data, dataWithoutPK } from '../shared/data'; @@ -34,10 +37,13 @@ import { data, dataWithoutPK } from '../shared/data'; IgxGridToolbarActionsComponent, IgxGridToolbarPinningComponent, IgxGridToolbarHidingComponent, - IgxGridToolbarExporterComponent + IgxGridToolbarExporterComponent, + IgxHierarchicalGridComponent, + IgxRowIslandComponent ] }) export class GridCellMergingComponent { + public hierarchicalData = HIERARCHICAL_DATA.concat(HIERARCHICAL_DATA).concat(HIERARCHICAL_DATA); public data = [{ ProductID: 1, ProductName: 'Chai', From 5c4587fb89e879df1e0e46e33c3ed66696fafaa4 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 16 Jul 2025 12:45:33 +0300 Subject: [PATCH 29/99] chore(*): Implement for tree grid. Add 2 strategies. --- .../src/lib/data-operations/merge-strategy.ts | 41 ++++ .../src/lib/grids/grid-base.directive.ts | 13 +- .../tree-grid/tree-grid-row.component.html | 223 ++++++++---------- .../grids/tree-grid/tree-grid.component.html | 26 +- .../grids/tree-grid/tree-grid.component.ts | 11 +- .../grid-cellMerging.component.html | 13 +- .../grid-cellMerging.component.ts | 6 +- 7 files changed, 194 insertions(+), 139 deletions(-) diff --git a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts index d18a6c43be5..4d20587775e 100644 --- a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts +++ b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts @@ -79,3 +79,44 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { return a === b; } } + + +export class DefaultTreeGridMergeStrategy extends DefaultMergeStrategy { + /* blazorSuppress */ + public override comparer(prevRecord: any, record: any, field: string): boolean { + const a = prevRecord.data[field]; + const b = record.data[field]; + const an = (a === null || a === undefined); + const bn = (b === null || b === undefined); + if (an) { + if (bn) { + return true; + } + return false; + } else if (bn) { + return false; + } + return a === b; + } +} + +export class ByLevelTreeGridMergeStrategy extends DefaultMergeStrategy { + /* blazorSuppress */ + public override comparer(prevRecord: any, record: any, field: string): boolean { + const a = prevRecord.data[field]; + const b = record.data[field]; + const levelA = prevRecord.level; + const levelB = record.level; + const an = (a === null || a === undefined); + const bn = (b === null || b === undefined); + if (an) { + if (bn) { + return true; + } + return false; + } else if (bn) { + return false; + } + return a === b && levelA === levelB; + } +} diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index a453a3af36c..97a625a806e 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -185,7 +185,7 @@ import { IgxGridValidationService } from './grid/grid-validation.service'; import { getCurrentResourceStrings } from '../core/i18n/resources'; import { isTree, recreateTree, recreateTreeFromFields } from '../data-operations/expressions-tree-util'; import { getUUID } from './common/random'; -import { IGridMergeStrategy } from '../data-operations/merge-strategy'; +import { DefaultMergeStrategy, IGridMergeStrategy } from '../data-operations/merge-strategy'; interface IMatchInfoCache { row: any; @@ -2505,7 +2505,12 @@ export abstract class IgxGridBaseDirective implements GridType, * ``` */ @Input() - public mergeStrategy: IGridMergeStrategy; + get mergeStrategy() { + return this._mergeStrategy; + } + set mergeStrategy(value) { + this._mergeStrategy = value; + } /** * Gets/Sets the sorting options - single or multiple sorting. @@ -3181,6 +3186,10 @@ export abstract class IgxGridBaseDirective implements GridType, protected _columnPinning = false; protected _pinnedRecordIDs = []; + /** + * @hidden + */ + protected _mergeStrategy: IGridMergeStrategy = new DefaultMergeStrategy(); /** * @hidden diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html index 05bbb9e49c4..fd1fa6737fb 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html @@ -23,66 +23,19 @@ } - - - - - - - - - + @if (this.hasMergedCells) { +
+ +
+ } + @else { + + }
@if (pinnedColumns.length > 0 && !grid.isPinningToStart) { @@ -106,69 +59,91 @@ @for (col of pinnedColumns | igxNotGrouped; track trackPinnedColumn(col)) { - - - - - - - - - + @if (this.hasMergedCells) { +
+ +
+ } + @else { + + } }
+ + + + + + + + + + diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index 5db61b5629c..3560107c5ce 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -30,6 +30,14 @@ [igxColumnMovingDrop]="headerContainer" [attr.droppable]="true" id="left" class="igx-grid__scroll-on-drag-pinned" [style.left.px]="pinnedWidth"> } + + @for (rowData of mergedDataInView; track rowData.record;) { + + + } + @if (data | treeGridTransaction:pipeTrigger @@ -38,7 +46,8 @@ | treeGridAddRow:true:pipeTrigger | gridRowPinning:id:true:pipeTrigger | treeGridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:pipeTrigger:filteringPipeTrigger:true - | treeGridSorting:sortingExpressions:treeGroupArea?.expressions:sortStrategy:pipeTrigger:true; as pinnedData + | treeGridSorting:sortingExpressions:treeGroupArea?.expressions:sortStrategy:pipeTrigger:true + | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndex:pipeTrigger; as pinnedData ) { @if (pinnedData.length > 0) {
@@ -76,15 +86,15 @@ - - + - - + diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.ts b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.ts index 30f413e00e4..32a424a37a7 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.ts @@ -82,6 +82,8 @@ import { IgxGridDragSelectDirective } from '../selection/drag-select.directive'; import { IgxGridBodyDirective } from '../grid.common'; import { IgxGridHeaderRowComponent } from '../headers/grid-header-row.component'; import { IgxTextHighlightService } from '../../directives/text-highlight/text-highlight.service'; +import { IgxGridCellMergePipe } from '../grid/grid.pipes'; +import { DefaultTreeGridMergeStrategy, IGridMergeStrategy } from '../../data-operations/merge-strategy'; let NEXT_ID = 0; @@ -168,7 +170,8 @@ let NEXT_ID = 0; IgxTreeGridSummaryPipe, IgxTreeGridNormalizeRecordsPipe, IgxTreeGridAddRowPipe, - IgxStringReplacePipe + IgxStringReplacePipe, + IgxGridCellMergePipe ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) @@ -348,6 +351,7 @@ export class IgxTreeGridComponent extends IgxGridBaseDirective implements GridTy protected override _filterStrategy = new TreeGridFilteringStrategy(); protected override _transactions: HierarchicalTransactionService; + protected override _mergeStrategy: IGridMergeStrategy = new DefaultTreeGridMergeStrategy(); private _data; private _rowLoadingIndicatorTemplate: TemplateRef; private _expansionDepth = Infinity; @@ -699,13 +703,14 @@ export class IgxTreeGridComponent extends IgxGridBaseDirective implements GridTy */ public getContext(rowData: any, rowIndex: number, pinned?: boolean): any { return { - $implicit: this.isGhostRecord(rowData) ? rowData.recordRef : rowData, + $implicit: this.isGhostRecord(rowData) || this.isRecordMerged(rowData) ? rowData.recordRef : rowData, index: this.getDataViewIndex(rowIndex, pinned), templateID: { type: this.isSummaryRow(rowData) ? 'summaryRow' : 'dataRow', id: null }, - disabled: this.isGhostRecord(rowData) ? rowData.recordRef.isFilteredOutParent === undefined : false + disabled: this.isGhostRecord(rowData) ? rowData.recordRef.isFilteredOutParent === undefined : false, + metaData: this.isRecordMerged(rowData) ? rowData : null }; } diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index f73e2c33de8..5dd64944ae1 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -37,7 +37,7 @@

Grid with cell merge

Hierarchical grid with cell merge

- @@ -62,3 +62,14 @@

Hierarchical grid with cell merge

+ +

Tree grid with cell merge

+ + + + + + + + diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index e44ce6a7179..19acbca30da 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -15,10 +15,12 @@ import { IgxHierarchicalGridComponent, IgxPaginatorComponent, IgxRowIslandComponent, + IgxTreeGridComponent } from 'igniteui-angular'; import { HIERARCHICAL_DATA } from '../shared/hierarchicalData'; import { data, dataWithoutPK } from '../shared/data'; +import { HIERARCHICAL_SAMPLE_DATA } from '../shared/sample-data'; @Component({ selector: 'app-grid-cellMerging', @@ -39,11 +41,13 @@ import { data, dataWithoutPK } from '../shared/data'; IgxGridToolbarHidingComponent, IgxGridToolbarExporterComponent, IgxHierarchicalGridComponent, - IgxRowIslandComponent + IgxRowIslandComponent, + IgxTreeGridComponent ] }) export class GridCellMergingComponent { public hierarchicalData = HIERARCHICAL_DATA.concat(HIERARCHICAL_DATA).concat(HIERARCHICAL_DATA); + public treeData = HIERARCHICAL_SAMPLE_DATA; public data = [{ ProductID: 1, ProductName: 'Chai', From 7ac17e45e2ef5803cb0ec4cdfe903fceb291ea11 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 16 Jul 2025 14:39:55 +0300 Subject: [PATCH 30/99] chore(*): Export strategies in package. --- projects/igniteui-angular-elements/src/public_api.ts | 3 ++- projects/igniteui-angular/src/public_api.ts | 1 + src/app/grid-cellMerging/grid-cellMerging.component.html | 2 +- src/app/grid-cellMerging/grid-cellMerging.component.ts | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular-elements/src/public_api.ts b/projects/igniteui-angular-elements/src/public_api.ts index d8081637986..aa796e8c5b2 100644 --- a/projects/igniteui-angular-elements/src/public_api.ts +++ b/projects/igniteui-angular-elements/src/public_api.ts @@ -12,7 +12,7 @@ import { IgxPivotDateDimension } from 'projects/igniteui-angular/src/lib/grids/p import { PivotDimensionType } from 'projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-grid.interface'; import { IgxDateSummaryOperand, IgxNumberSummaryOperand, IgxSummaryOperand, IgxTimeSummaryOperand } from 'projects/igniteui-angular/src/lib/grids/summaries/grid-summary'; import { HorizontalAlignment, VerticalAlignment } from 'projects/igniteui-angular/src/lib/services/overlay/utilities'; - +import { ByLevelTreeGridMergeStrategy } from 'projects/igniteui-angular/src/lib/data-operations/merge-strategy'; /** Export Public API, TODO: reorganize, Generate all w/ renames? */ export { @@ -35,6 +35,7 @@ export { NoopSortingStrategy as IgcNoopSortingStrategy, NoopFilteringStrategy as IgcNoopFilteringStrategy, + ByLevelTreeGridMergeStrategy as IgcByLevelTreeGridMergeStrategy, // Pivot API IgxPivotDateDimension as IgcPivotDateDimension, diff --git a/projects/igniteui-angular/src/public_api.ts b/projects/igniteui-angular/src/public_api.ts index 0e844761ba2..d4a70104a95 100644 --- a/projects/igniteui-angular/src/public_api.ts +++ b/projects/igniteui-angular/src/public_api.ts @@ -52,6 +52,7 @@ export * from './lib/data-operations/filtering-expressions-tree'; export * from './lib/data-operations/filtering-condition'; export * from './lib/data-operations/filtering-state.interface'; export * from './lib/data-operations/filtering-strategy'; +export * from './lib/data-operations/merge-strategy'; export { ExpressionsTreeUtil } from './lib/data-operations/expressions-tree-util'; export * from './lib/data-operations/groupby-expand-state.interface'; export * from './lib/data-operations/groupby-record.interface'; diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index 5dd64944ae1..f9c41c1580e 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -65,7 +65,7 @@

Hierarchical grid with cell merge

Tree grid with cell merge

- diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index 19acbca30da..497e4001350 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -21,6 +21,7 @@ import { HIERARCHICAL_DATA } from '../shared/hierarchicalData'; import { data, dataWithoutPK } from '../shared/data'; import { HIERARCHICAL_SAMPLE_DATA } from '../shared/sample-data'; +import { ByLevelTreeGridMergeStrategy } from 'igniteui-angular'; @Component({ selector: 'app-grid-cellMerging', @@ -48,6 +49,7 @@ import { HIERARCHICAL_SAMPLE_DATA } from '../shared/sample-data'; export class GridCellMergingComponent { public hierarchicalData = HIERARCHICAL_DATA.concat(HIERARCHICAL_DATA).concat(HIERARCHICAL_DATA); public treeData = HIERARCHICAL_SAMPLE_DATA; + public treeGridMergeStrategy = new ByLevelTreeGridMergeStrategy(); public data = [{ ProductID: 1, ProductName: 'Chai', From 401370e6e9b4c0ba1e70662ff498724efedf3ae4 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 16 Jul 2025 16:39:25 +0300 Subject: [PATCH 31/99] chore(*): Fix chip displaying when in merged cell. --- .../src/lib/grids/grid/grid-row.component.html | 8 ++++---- .../hierarchical-grid/hierarchical-row.component.html | 4 ++-- .../src/lib/grids/pivot-grid/pivot-row.component.html | 2 +- projects/igniteui-angular/src/lib/grids/row.directive.ts | 4 ++-- .../src/lib/grids/tree-grid/tree-grid-row.component.html | 4 ++-- src/app/grid-cellMerging/grid-cellMerging.component.html | 1 - src/app/grid-cellMerging/grid-cellMerging.component.ts | 2 ++ 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index b8e27a76e34..e8f2a00cab7 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -162,7 +162,7 @@ [lastSearchInfo]="grid.lastSearchInfo" [active]="isCellActive(col.visibleIndex)" [cellSelectionMode]="grid.cellSelection" - [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)" + [displayPinnedChip]="shouldDisplayPinnedChip(col)" #cell> @@ -194,7 +194,7 @@ [lastSearchInfo]="grid.lastSearchInfo" [active]="isCellActive(col.visibleIndex)" [cellSelectionMode]="grid.cellSelection" - [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)" + [displayPinnedChip]="shouldDisplayPinnedChip(col)" #cell> @@ -226,7 +226,7 @@ [lastSearchInfo]="grid.lastSearchInfo" [active]="isCellActive(col.visibleIndex)" [cellSelectionMode]="grid.cellSelection" - [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)" + [displayPinnedChip]="shouldDisplayPinnedChip(col)" #cell> @@ -258,7 +258,7 @@ [lastSearchInfo]="grid.lastSearchInfo" [active]="isCellActive(col.visibleIndex)" [cellSelectionMode]="grid.cellSelection" - [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)" + [displayPinnedChip]="shouldDisplayPinnedChip(col)" #cell> diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html index 4c286034977..1f0b9e60507 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html @@ -114,7 +114,7 @@ [cellValidationErrorTemplate]="col.errorTemplate" [lastSearchInfo]="grid.lastSearchInfo" [cellSelectionMode]="grid.cellSelection" - [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)"> + [displayPinnedChip]="shouldDisplayPinnedChip(col)"> } @@ -148,6 +148,6 @@ [cellValidationErrorTemplate]="col.errorTemplate" [lastSearchInfo]="grid.lastSearchInfo" [cellSelectionMode]="grid.cellSelection" - [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)"> + [displayPinnedChip]="shouldDisplayPinnedChip(col)"> diff --git a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row.component.html b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row.component.html index 39d6a48bd63..a6a93da2e92 100644 --- a/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/pivot-grid/pivot-row.component.html @@ -16,7 +16,7 @@ [style.flex-basis]="col.resolvedWidth" [width]="col.getCellWidth()" [visibleColumnIndex]="col.visibleIndex" [value]="pivotAggregationData[col.field] | dataMapper:col.field:grid.pipeTrigger:pivotAggregationData[col.field]:col.hasNestedPath" [cellTemplate]="col.bodyTemplate" [lastSearchInfo]="grid.lastSearchInfo" - [cellSelectionMode]="grid.cellSelection" [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)" + [cellSelectionMode]="grid.cellSelection" [displayPinnedChip]="shouldDisplayPinnedChip(col)" (pointerdown)="grid.navigation.focusOutRowHeader($event)"> diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index f094531c37f..4794d494075 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -578,8 +578,8 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { /** * @hidden */ - public shouldDisplayPinnedChip(visibleColumnIndex: number): boolean { - return this.pinned && this.disabled && visibleColumnIndex === 0; + public shouldDisplayPinnedChip(col: ColumnType): boolean { + return this.pinned && this.disabled && col.visibleIndex === 0 && !this.metaData?.cellMergeMeta?.get(col.field)?.root; } /** diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html index fd1fa6737fb..16ccc6ae995 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html @@ -105,7 +105,7 @@ [lastSearchInfo]="grid.lastSearchInfo" [active]="isCellActive(col.visibleIndex)" [cellSelectionMode]="grid.cellSelection" - [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)" + [displayPinnedChip]="shouldDisplayPinnedChip(col)" #treeCell> @@ -142,7 +142,7 @@ [lastSearchInfo]="grid.lastSearchInfo" [active]="isCellActive(col.visibleIndex)" [cellSelectionMode]="grid.cellSelection" - [displayPinnedChip]="shouldDisplayPinnedChip(col.visibleIndex)" + [displayPinnedChip]="shouldDisplayPinnedChip(col)" #treeCell> diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index f9c41c1580e..e2054dac1a7 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -59,7 +59,6 @@

Hierarchical grid with cell merge

- diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index 497e4001350..ec6eed2b1a3 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -50,6 +50,8 @@ export class GridCellMergingComponent { public hierarchicalData = HIERARCHICAL_DATA.concat(HIERARCHICAL_DATA).concat(HIERARCHICAL_DATA); public treeData = HIERARCHICAL_SAMPLE_DATA; public treeGridMergeStrategy = new ByLevelTreeGridMergeStrategy(); + public alignBottom = { alignItems: "flex-end", paddingBottom: "12px"}; + public alignTop= { alignItems: "flex-start", paddingTop: "12px" }; public data = [{ ProductID: 1, ProductName: 'Chai', From 5e1b702f619c4b3d9313df1309a3dc920749916d Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 16 Jul 2025 17:16:15 +0300 Subject: [PATCH 32/99] chore(*): Minor tweaks. Console warn on invalid setup. --- .../src/lib/data-operations/merge-strategy.ts | 1 + .../src/lib/grids/columns/column.component.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts index 4d20587775e..1c16f7df755 100644 --- a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts +++ b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts @@ -14,6 +14,7 @@ export interface IGridMergeStrategy { activeRowIndex? : number, grid?: GridType ) => any[]; + comparer: (prevRecord: any, record: any, field: string) => boolean; } export class DefaultMergeStrategy implements IGridMergeStrategy { diff --git a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts index 58b3de78586..7aa2e7807b7 100644 --- a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts +++ b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts @@ -115,7 +115,17 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy * */ @Input() - public merge = false; + public get merge() { + return this._merge; + } + + public set merge(value) { + if (this.grid.hasColumnLayouts) { + console.warn('Merging is not supported with multi-row layouts.'); + return; + } + this._merge = value; + } /** * @hidden @internal @@ -1911,6 +1921,10 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy * @hidden */ protected _groupable = false; + /** + * @hidden + */ + protected _merge = false; /** * @hidden */ From 58e6bfac1c39e6b505efeb13965233bec2b9d140 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 17 Jul 2025 13:39:30 +0300 Subject: [PATCH 33/99] chore(*): Fix lint errors. --- .../igniteui-angular/src/lib/grids/grid-base.directive.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 97a625a806e..325f9db900f 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -2505,10 +2505,10 @@ export abstract class IgxGridBaseDirective implements GridType, * ``` */ @Input() - get mergeStrategy() { + public get mergeStrategy() { return this._mergeStrategy; } - set mergeStrategy(value) { + public set mergeStrategy(value) { this._mergeStrategy = value; } @@ -3974,7 +3974,7 @@ export abstract class IgxGridBaseDirective implements GridType, public get columnsToMerge() : ColumnType[] { return this.visibleColumns.filter( x => x.merge && (this.cellMergeMode ==='always' || - (this.cellMergeMode === 'onSort' && !!this.sortingExpressions.find( x=> x.fieldName === x.fieldName))) + (this.cellMergeMode === 'onSort' && !!this.sortingExpressions.find( y => y.fieldName === x.field))) ); } @@ -4058,7 +4058,7 @@ export abstract class IgxGridBaseDirective implements GridType, const columnToMerge = this.visibleColumns.filter( x => x.merge && (this.cellMergeMode ==='always' || (this.cellMergeMode === 'onSort' && !!this.sortingExpressions - .find( x=> x.fieldName === x.fieldName))) + .find(y => y.fieldName === x.field))) ); return columnToMerge.length > 0; } From 5e81bf91320d989045f9f13e7f4adb8e8904fa20 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 17 Jul 2025 13:44:02 +0300 Subject: [PATCH 34/99] chore(*): Fix theming lint. --- .../src/lib/core/styles/components/grid/_grid-theme.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index bf9d1774dd5..605fcda9a59 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1830,10 +1830,12 @@ %igx-grid__td--merged { color: var-get($theme, 'row-selected-text-color'); background: var-get($theme, 'row-selected-background'); + &:hover { background: var-get($theme, 'row-selected-hover-background'); color: var-get($theme, 'row-selected-hover-text-color'); } + &%igx-grid__td--merged-hovered { background: var-get($theme, 'row-selected-hover-background'); color: var-get($theme, 'row-selected-hover-text-color'); From b2b61a7492cdcb6f072cabef5cd8bd95d425aff8 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 17 Jul 2025 15:02:18 +0300 Subject: [PATCH 35/99] chore(*): Fix build and imports. --- .../src/lib/data-operations/data-util.ts | 7 ------- .../src/lib/data-operations/merge-strategy.ts | 13 +++++++++---- .../src/lib/grids/grid/grid.pipes.ts | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/projects/igniteui-angular/src/lib/data-operations/data-util.ts b/projects/igniteui-angular/src/lib/data-operations/data-util.ts index 429321ffbef..bdd51b5d07f 100644 --- a/projects/igniteui-angular/src/lib/data-operations/data-util.ts +++ b/projects/igniteui-angular/src/lib/data-operations/data-util.ts @@ -38,13 +38,6 @@ import { DefaultMergeStrategy, IGridMergeStrategy } from './merge-strategy'; } as const; export type DataType = (typeof DataType)[keyof typeof DataType]; - -export interface IMergeByResult { - rowSpan: number; - root?: any; - prev?: any; -} - /** * @hidden */ diff --git a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts index 1c16f7df755..6339f0064d9 100644 --- a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts +++ b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts @@ -1,9 +1,14 @@ -import { GridCellMergeMode, IMergeByResult } from 'igniteui-angular'; -import type { KeyOfOrString } from '../core/types'; -import { IBaseEventArgs } from '../core/utils'; -import { ColumnType, GridType } from '../grids/common/grid.interface'; +import { GridType } from '../grids/common/grid.interface'; + + +export interface IMergeByResult { + rowSpan: number; + root?: any; + prev?: any; +} + export interface IGridMergeStrategy { /* blazorSuppress */ merge: ( diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts index dbfb9d676c1..8cef2a8034e 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts @@ -9,7 +9,7 @@ import { ColumnType, GridType, IGX_GRID_BASE } from '../common/grid.interface'; import { FilterUtil, IFilteringStrategy } from '../../data-operations/filtering-strategy'; import { ISortingExpression } from '../../data-operations/sorting-strategy'; import { IGridSortingStrategy, IGridGroupingStrategy } from '../common/strategy'; -import { GridCellMergeMode } from 'igniteui-angular'; +import { GridCellMergeMode } from '../common/enums'; /** * @hidden From 9a7a2d25bfe2ed796ee5b19aa8c8395a4d996c07 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 17 Jul 2025 15:13:16 +0300 Subject: [PATCH 36/99] chore(*): Add null check. --- projects/igniteui-angular/src/lib/grids/row.directive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 4794d494075..0e6c12ef6b7 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -524,7 +524,7 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { public isCellActive(visibleColumnIndex) { const node = this.grid.navigation.activeNode; - const field = this.grid.visibleColumns[visibleColumnIndex].field; + const field = this.grid.visibleColumns[visibleColumnIndex]?.field; const rowSpan = this.metaData?.cellMergeMeta?.get(field)?.rowSpan; if (rowSpan > 1) { return node ? (node.row >= this.index && node.row < this.index + rowSpan) From ce0c854c386590ac1321c20730cd2eee87821ea8 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 17 Jul 2025 16:53:59 +0300 Subject: [PATCH 37/99] chore(*): Null check for activeRowIndex. --- projects/igniteui-angular/src/lib/grids/grid-base.directive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 325f9db900f..13c24649a0b 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -4051,7 +4051,7 @@ export abstract class IgxGridBaseDirective implements GridType, protected get activeRowIndex() { - return this.navigation.activeNode.row; + return this.navigation.activeNode?.row; } protected get hasCellsToMerge() { From a6fc511f5ab346aad330b17267bdb555307f41b8 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 17 Jul 2025 17:07:49 +0300 Subject: [PATCH 38/99] chore(*): Generate elements config. --- .../src/analyzer/elements.config.ts | 3 +++ .../igniteui-angular/src/lib/grids/grid-base.directive.ts | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular-elements/src/analyzer/elements.config.ts b/projects/igniteui-angular-elements/src/analyzer/elements.config.ts index be2f3f4d6d5..e78d3492d9d 100644 --- a/projects/igniteui-angular-elements/src/analyzer/elements.config.ts +++ b/projects/igniteui-angular-elements/src/analyzer/elements.config.ts @@ -95,6 +95,7 @@ export var registerConfig = [ ], numericProps: ["rowEnd", "colEnd", "rowStart", "colStart"], boolProps: [ + "merge", "sortable", "selectable", "groupable", @@ -158,6 +159,7 @@ export var registerConfig = [ "expanded", "searchable", "hidden", + "merge", "sortable", "groupable", "editable", @@ -213,6 +215,7 @@ export var registerConfig = [ "collapsible", "expanded", "searchable", + "merge", "sortable", "groupable", "editable", diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 13c24649a0b..8b34b98060e 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3695,7 +3695,7 @@ export abstract class IgxGridBaseDirective implements GridType, return rec?.cellMergeMeta; } - public getMergeCellOffset(rowData) { + protected getMergeCellOffset(rowData) { const index = rowData.index; let offset = this.verticalScrollContainer.scrollPosition - this.verticalScrollContainer.getScrollForIndex(index); if (this.hasPinnedRecords && this.isRowPinningToTop) { @@ -3971,6 +3971,10 @@ export abstract class IgxGridBaseDirective implements GridType, } } + /** + * @hidden + * @internal + */ public get columnsToMerge() : ColumnType[] { return this.visibleColumns.filter( x => x.merge && (this.cellMergeMode ==='always' || From d0254734c210cabbedbfe65d02e5e5d5f6ca803c Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 17 Jul 2025 18:03:04 +0300 Subject: [PATCH 39/99] chore(*): Break up merge groups on cell selection. --- .../igniteui-angular/src/lib/data-operations/data-util.ts | 4 ++-- .../src/lib/data-operations/merge-strategy.ts | 6 +++--- .../igniteui-angular/src/lib/grids/grid-base.directive.ts | 6 ++++-- .../igniteui-angular/src/lib/grids/grid/grid.component.html | 4 ++-- projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts | 4 ++-- .../hierarchical-grid/hierarchical-grid.component.html | 4 ++-- .../src/lib/grids/tree-grid/tree-grid.component.html | 4 ++-- src/app/grid-cellMerging/grid-cellMerging.component.html | 2 +- 8 files changed, 18 insertions(+), 16 deletions(-) diff --git a/projects/igniteui-angular/src/lib/data-operations/data-util.ts b/projects/igniteui-angular/src/lib/data-operations/data-util.ts index bdd51b5d07f..0066cd58f4e 100644 --- a/projects/igniteui-angular/src/lib/data-operations/data-util.ts +++ b/projects/igniteui-angular/src/lib/data-operations/data-util.ts @@ -91,11 +91,11 @@ export class DataUtil { return grouping.groupBy(data, state, grid, groupsRecords, fullResult); } - public static merge(data: T[], columns: ColumnType[], strategy: IGridMergeStrategy = new DefaultMergeStrategy(), activeRowIndex = -1, grid: GridType = null, + public static merge(data: T[], columns: ColumnType[], strategy: IGridMergeStrategy = new DefaultMergeStrategy(), activeRowIndexes = [], grid: GridType = null, ): any[] { let result = []; for (const col of columns) { - strategy.merge(data, col.field, col.mergingComparer, result, activeRowIndex, grid); + strategy.merge(data, col.field, col.mergingComparer, result, activeRowIndexes, grid); } return result; } diff --git a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts index 6339f0064d9..d1e5b5f9b5a 100644 --- a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts +++ b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts @@ -16,7 +16,7 @@ export interface IGridMergeStrategy { field: string, comparer: (prevRecord: any, currentRecord: any, field: string) => boolean, result: any[], - activeRowIndex? : number, + activeRowIndexes : number[], grid?: GridType ) => any[]; comparer: (prevRecord: any, record: any, field: string) => boolean; @@ -35,7 +35,7 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { field: string, comparer: (prevRecord: any, record: any, field: string) => boolean = this.comparer, result: any[], - activeRowIndex?: number, + activeRowIndexes : number[], grid?: GridType ) { let prev = null; @@ -44,7 +44,7 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { const recData = result[index]; // if this is active row or some special record type - add and skip merging - if (activeRowIndex === index || (grid && grid.isDetailRecord(rec) || grid.isGroupByRecord(rec) || grid.isChildGridRecord(rec))) { + if (activeRowIndexes.indexOf(index) != -1 || (grid && grid.isDetailRecord(rec) || grid.isGroupByRecord(rec) || grid.isChildGridRecord(rec))) { if(!recData) { result.push(rec); } diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 8b34b98060e..b41b24eb7bb 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -4054,8 +4054,10 @@ export abstract class IgxGridBaseDirective implements GridType, } - protected get activeRowIndex() { - return this.navigation.activeNode?.row; + protected get activeRowIndexes(): number[] { + const activeRow = this.navigation.activeNode?.row; + const selectedCellIndexes = (this.selectionService.selection?.keys() as any)?.toArray(); + return [activeRow, ...selectedCellIndexes]; } protected get hasCellsToMerge() { diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index 04915e5f558..4e86643da62 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -63,7 +63,7 @@ | gridRowPinning:id:true:pipeTrigger | gridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:id:pipeTrigger:filteringPipeTrigger:true | gridSort:sortingExpressions:groupingExpressions:sortStrategy:id:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndex:pipeTrigger; as pinnedData) { + | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:pipeTrigger; as pinnedData) { @if (pinnedData.length > 0) {
0) {
diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index 3560107c5ce..9e16f82611d 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -47,7 +47,7 @@ | gridRowPinning:id:true:pipeTrigger | treeGridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:pipeTrigger:filteringPipeTrigger:true | treeGridSorting:sortingExpressions:treeGroupArea?.expressions:sortStrategy:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndex:pipeTrigger; as pinnedData + | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:pipeTrigger; as pinnedData ) { @if (pinnedData.length > 0) {
diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index e2054dac1a7..15a44d78af3 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -1,5 +1,5 @@

Grid with cell merge

- From 190f29d52afa0635f30619ad87a17f9caad249da Mon Sep 17 00:00:00 2001 From: MKirova Date: Fri, 18 Jul 2025 11:58:07 +0300 Subject: [PATCH 40/99] chore(*): Update active indexes on events. Cache result to limit pipe trigger. --- .../src/lib/grids/grid-base.directive.ts | 20 ++++++++++++++++--- .../lib/grids/selection/selection.service.ts | 9 ++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index b41b24eb7bb..43cd98538a7 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3246,6 +3246,7 @@ export abstract class IgxGridBaseDirective implements GridType, private _filteredSortedData = null; private _filteredData = null; private _mergedDataInView = null; + private _activeRowIndexes = null; private _customDragIndicatorIconTemplate: TemplateRef; private _excelStyleHeaderIconTemplate: TemplateRef; @@ -3907,6 +3908,14 @@ export abstract class IgxGridBaseDirective implements GridType, this.autoSizeColumnsInView(); this._firstAutoResize = false; }); + + this.activeNodeChange.pipe(filter(() => !this._init), destructor).subscribe(() => { + this._activeRowIndexes = null; + }); + + this.selectionService.selectedRangeChange.pipe(filter(() => !this._init), destructor).subscribe(() => { + this._activeRowIndexes = null; + }); } /** @@ -4055,9 +4064,14 @@ export abstract class IgxGridBaseDirective implements GridType, protected get activeRowIndexes(): number[] { - const activeRow = this.navigation.activeNode?.row; - const selectedCellIndexes = (this.selectionService.selection?.keys() as any)?.toArray(); - return [activeRow, ...selectedCellIndexes]; + if (this._activeRowIndexes) { + return this._activeRowIndexes; + } else { + const activeRow = this.navigation.activeNode?.row; + const selectedCellIndexes = (this.selectionService.selection?.keys() as any)?.toArray(); + this._activeRowIndexes = [activeRow, ...selectedCellIndexes]; + return this._activeRowIndexes; + } } protected get hasCellsToMerge() { diff --git a/projects/igniteui-angular/src/lib/grids/selection/selection.service.ts b/projects/igniteui-angular/src/lib/grids/selection/selection.service.ts index 83287824d16..0a92780d866 100644 --- a/projects/igniteui-angular/src/lib/grids/selection/selection.service.ts +++ b/projects/igniteui-angular/src/lib/grids/selection/selection.service.ts @@ -35,6 +35,11 @@ export class IgxGridSelectionService { */ public selectedRowsChange = new Subject(); + /** + * @hidden @internal + */ + public selectedRangeChange = new Subject>>(); + /** * Toggled when a pointerdown event is triggered inside the grid body (cells). * When `false` the drag select behavior is disabled. @@ -355,6 +360,8 @@ export class IgxGridSelectionService { } } } + + this.selectedRangeChange.next(collection); } public dragSelect(node: ISelectionNode, state: SelectionState): void { @@ -637,7 +644,7 @@ export class IgxGridSelectionService { if (this.areEqualCollections(currSelection, newSelection)) { return; } - + const args: IRowSelectionEventArgs = { owner: this.grid, oldSelection: currSelection, From 50e7d85a7dae9575aa97636f939da85580b4dc2f Mon Sep 17 00:00:00 2001 From: MKirova Date: Fri, 18 Jul 2025 17:14:00 +0300 Subject: [PATCH 41/99] chore(*): When searching, mark merged cells as a single result. --- .../src/lib/grids/grid-base.directive.ts | 28 ++++++----- .../grid-cellMerging.component.html | 46 ++++++++++++++++++- .../grid-cellMerging.component.ts | 28 ++++++++++- 3 files changed, 89 insertions(+), 13 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 43cd98538a7..c563367f506 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -38,7 +38,7 @@ import { IgcTrialWatermark } from 'igniteui-trial-watermark'; import { Subject, pipe, fromEvent, animationFrameScheduler, merge } from 'rxjs'; import { takeUntil, first, filter, throttleTime, map, shareReplay, takeWhile } from 'rxjs/operators'; import { cloneArray, mergeObjects, compareMaps, resolveNestedPath, isObject, PlatformUtil } from '../core/utils'; -import { GridColumnDataType } from '../data-operations/data-util'; +import { DataUtil, GridColumnDataType } from '../data-operations/data-util'; import { FilteringLogic } from '../data-operations/filtering-expression.interface'; import { IGroupByRecord } from '../data-operations/groupby-record.interface'; import { IForOfDataChangeEventArgs, IgxGridForOfDirective } from '../directives/for-of/for_of.directive'; @@ -3911,10 +3911,12 @@ export abstract class IgxGridBaseDirective implements GridType, this.activeNodeChange.pipe(filter(() => !this._init), destructor).subscribe(() => { this._activeRowIndexes = null; + this.refreshSearch(); }); this.selectionService.selectedRangeChange.pipe(filter(() => !this._init), destructor).subscribe(() => { this._activeRowIndexes = null; + this.refreshSearch(); }); } @@ -7977,25 +7979,29 @@ export abstract class IgxGridBaseDirective implements GridType, const caseSensitive = this._lastSearchInfo.caseSensitive; const exactMatch = this._lastSearchInfo.exactMatch; const searchText = caseSensitive ? this._lastSearchInfo.searchText : this._lastSearchInfo.searchText.toLowerCase(); - const data = this.filteredSortedData; + let data = this.filteredSortedData; + if (this.hasCellsToMerge) { + data = DataUtil.merge(cloneArray(this.filteredSortedData), this.columnsToMerge, this.mergeStrategy, this.activeRowIndexes, this); + } const columnItems = this.visibleColumns.filter((c) => !c.columnGroup).sort((c1, c2) => c1.visibleIndex - c2.visibleIndex); const columnsPathParts = columnItems.map(col => columnFieldPath(col.field)); data.forEach((dataRow, rowIndex) => { + const currentRowData = this.isRecordMerged(dataRow) ? dataRow.recordRef : dataRow; columnItems.forEach((c, cid) => { const pipeArgs = this.getColumnByName(c.field).pipeArgs; - const value = c.formatter ? c.formatter(resolveNestedPath(dataRow, columnsPathParts[cid]), dataRow) : - c.dataType === 'number' ? formatNumber(resolveNestedPath(dataRow, columnsPathParts[cid]) as number, this.locale, pipeArgs.digitsInfo) : + const value = c.formatter ? c.formatter(resolveNestedPath(currentRowData, columnsPathParts[cid]), currentRowData) : + c.dataType === 'number' ? formatNumber(resolveNestedPath(currentRowData, columnsPathParts[cid]) as number, this.locale, pipeArgs.digitsInfo) : c.dataType === 'date' - ? formatDate(resolveNestedPath(dataRow, columnsPathParts[cid]) as string, pipeArgs.format, this.locale, pipeArgs.timezone) - : resolveNestedPath(dataRow, columnsPathParts[cid]); + ? formatDate(resolveNestedPath(currentRowData, columnsPathParts[cid]) as string, pipeArgs.format, this.locale, pipeArgs.timezone) + : resolveNestedPath(currentRowData, columnsPathParts[cid]); if (value !== undefined && value !== null && c.searchable) { let searchValue = caseSensitive ? String(value) : String(value).toLowerCase(); - + const isMergePlaceHolder = this.isRecordMerged(dataRow) ? !!dataRow?.cellMergeMeta.get(c.field)?.root : false; if (exactMatch) { - if (searchValue === searchText) { + if (searchValue === searchText && !isMergePlaceHolder) { const mic: IMatchInfoCache = { - row: dataRow, + row: currentRowData, column: c.field, index: 0, metadata: new Map([['pinned', this.isRecordPinnedByIndex(rowIndex)]]) @@ -8007,9 +8013,9 @@ export abstract class IgxGridBaseDirective implements GridType, let occurrenceIndex = 0; let searchIndex = searchValue.indexOf(searchText); - while (searchIndex !== -1) { + while (searchIndex !== -1 && !isMergePlaceHolder) { const mic: IMatchInfoCache = { - row: dataRow, + row: currentRowData, column: c.field, index: occurrenceIndex++, metadata: new Map([['pinned', this.isRecordPinnedByIndex(rowIndex)]]) diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index 15a44d78af3..b522b1af332 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -1,5 +1,49 @@

Grid with cell merge

- + + + @if (searchText.length === 0) { + search + } + @if (searchText.length > 0) { + clear + } + + + + + @if (searchText.length > 0) { + + @if (grid.lastSearchInfo) { +
+ @if (grid.lastSearchInfo.matchInfoCache.length > 0) { + + {{ grid.lastSearchInfo.activeMatchIndex + 1 }} of {{ grid.lastSearchInfo.matchInfoCache.length }} + results + + } + @if (grid.lastSearchInfo.matchInfoCache.length === 0) { + + No results + + } +
+ } +
+ + +
+
+ } +
+
+ diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index ec6eed2b1a3..89b86f37a5e 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -13,8 +13,13 @@ import { IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent, IgxHierarchicalGridComponent, + IgxIconComponent, + IgxInputDirective, + IgxInputGroupComponent, IgxPaginatorComponent, + IgxPrefixDirective, IgxRowIslandComponent, + IgxSuffixDirective, IgxTreeGridComponent } from 'igniteui-angular'; import { HIERARCHICAL_DATA } from '../shared/hierarchicalData'; @@ -43,7 +48,12 @@ import { ByLevelTreeGridMergeStrategy } from 'igniteui-angular'; IgxGridToolbarExporterComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, - IgxTreeGridComponent + IgxTreeGridComponent, + IgxInputGroupComponent, + IgxPrefixDirective, + IgxSuffixDirective, + IgxIconComponent, + IgxInputDirective ] }) export class GridCellMergingComponent { @@ -52,6 +62,8 @@ export class GridCellMergingComponent { public treeGridMergeStrategy = new ByLevelTreeGridMergeStrategy(); public alignBottom = { alignItems: "flex-end", paddingBottom: "12px"}; public alignTop= { alignItems: "flex-start", paddingTop: "12px" }; + public searchText: string =''; + @ViewChild('grid1', { static: true }) public grid: IgxGridComponent; public data = [{ ProductID: 1, ProductName: 'Chai', @@ -387,5 +399,19 @@ export class GridCellMergingComponent { OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() }]; + public searchKeyDown(ev) { + if (ev.key === 'Enter' || ev.key === 'ArrowDown' || ev.key === 'ArrowRight') { + ev.preventDefault(); + this.grid.findNext(this.searchText, true, false); + } else if (ev.key === 'ArrowUp' || ev.key === 'ArrowLeft') { + ev.preventDefault(); + this.grid.findPrev(this.searchText, true, false); + } + } + + public clearSearch() { + this.searchText = ''; + this.grid.clearSearch(); + } } From 1e560e8aca89e839fa8e87728949903fe488bc5c Mon Sep 17 00:00:00 2001 From: MKirova Date: Fri, 18 Jul 2025 17:33:47 +0300 Subject: [PATCH 42/99] chore(*): Refresh search if needed only. --- .../igniteui-angular/src/lib/grids/grid-base.directive.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index c563367f506..8b3889b317c 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3911,12 +3911,16 @@ export abstract class IgxGridBaseDirective implements GridType, this.activeNodeChange.pipe(filter(() => !this._init), destructor).subscribe(() => { this._activeRowIndexes = null; - this.refreshSearch(); + if (this.hasCellsToMerge) { + this.refreshSearch(); + } }); this.selectionService.selectedRangeChange.pipe(filter(() => !this._init), destructor).subscribe(() => { this._activeRowIndexes = null; - this.refreshSearch(); + if (this.hasCellsToMerge) { + this.refreshSearch(); + } }); } From 7db787aaa2477999633e98516bc164870f0507a1 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 21 Jul 2025 13:53:19 +0300 Subject: [PATCH 43/99] chore(*): Fix scrollTo when scrolling to a merged cell that has larger rowspan. --- .../src/lib/grids/grid-base.directive.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 8b3889b317c..e5bcaa27c3e 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -7633,15 +7633,21 @@ export abstract class IgxGridBaseDirective implements GridType, this.page = page; } } - + let targetRowIndex = (typeof (row) === 'number' ? row : this.unpinnedDataView.indexOf(row)); + const virtRec = this.verticalScrollContainer.igxForOf[targetRowIndex]; + const col = typeof (column) === 'number' ? this.visibleColumns[column] : column; + const rowSpan = this.isRecordMerged(virtRec) ? virtRec?.cellMergeMeta.get(col)?.rowSpan : 1; + if (rowSpan > 1) { + targetRowIndex += Math.floor(rowSpan/2); + } if (delayScrolling) { this.verticalScrollContainer.dataChanged.pipe(first(), takeUntil(this.destroy$)).subscribe(() => { this.scrollDirective(this.verticalScrollContainer, - typeof (row) === 'number' ? row : this.unpinnedDataView.indexOf(row)); + targetRowIndex); }); } else { this.scrollDirective(this.verticalScrollContainer, - typeof (row) === 'number' ? row : this.unpinnedDataView.indexOf(row)); + targetRowIndex); } this.scrollToHorizontally(column); From 86bc021d837cc0f1fe5ee20a0f1ef512ae5a73c0 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 21 Jul 2025 16:54:29 +0300 Subject: [PATCH 44/99] chore(*): Add basic merging tests. --- .../src/lib/grids/grid/cell-merge.spec.ts | 227 ++++++++++++++++++ .../src/lib/test-utils/grid-functions.spec.ts | 17 +- 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts new file mode 100644 index 00000000000..f5f62896f78 --- /dev/null +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -0,0 +1,227 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { DefaultMergeStrategy, GridCellMergeMode, GridColumnDataType, GridType, IgxColumnComponent, IgxGridComponent, SortingDirection } from 'igniteui-angular'; +import { DataParent } from '../../test-utils/sample-test-data.spec'; +import { GridFunctions } from '../../test-utils/grid-functions.spec'; + +describe('IgxGrid - Cell merging #grid', () => { + let fix; + let grid: IgxGridComponent; + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, DefaultCellMergeGridComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(DefaultCellMergeGridComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + describe('Basic', () => { + it('should allow enabling/disabling merging per column.', () => { + + const col = grid.getColumnByName('ProductName'); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 2 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null , span: 1 }, + { value: 'NetAdvantage' , span: 2 } + ]); + + // disable merge + col.merge = false; + fix.detectChanges(); + + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: null , span: 1 }, + { value: 'NetAdvantage' , span: 1 }, + { value: 'NetAdvantage' , span: 1 } + ]); + }); + + it('should always merge columns if mergeMode is always.', () => { + const col = grid.getColumnByName('Released'); + col.merge = true; + fix.detectChanges(); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: true, span: 9 } + ]); + }); + + it('should merge only sorted columns if mergeMode is onSort.', () => { + grid.cellMergeMode = 'onSort'; + fix.detectChanges(); + const col = grid.getColumnByName('ProductName'); + //nothing is merged initially + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: null , span: 1 }, + { value: 'NetAdvantage' , span: 1 }, + { value: 'NetAdvantage' , span: 1 } + ]); + + grid.sort({ fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + // merge only after sorted + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'NetAdvantage' , span: 2 }, + { value: 'Ignite UI for JavaScript', span: 3 }, + { value: 'Ignite UI for Angular', span: 3 }, + { value: null , span: 1 } + ]); + }); + + it('should allow setting a custom merge strategy via mergeStrategy on grid.', () => { + grid.mergeStrategy = new NoopMergeStrategy(); + fix.detectChanges(); + const col = grid.getColumnByName('ProductName'); + // this strategy does no merging + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: null , span: 1 }, + { value: 'NetAdvantage' , span: 1 }, + { value: 'NetAdvantage' , span: 1 } + ]); + }); + + it('should allow setting a custom comparer for merging on particular column via mergingComparer.', () => { + const col = grid.getColumnByName('ProductName'); + // all are same and should merge + col.mergingComparer = (prev:any, rec: any, field: string) => { + return true; + }; + grid.pipeTrigger += 1; + fix.detectChanges(); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 9 } + ]); + }); + }); +}); + +@Component({ + template: ` + + @for(col of cols; track col) { + + } + + `, + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class DefaultCellMergeGridComponent extends DataParent { + public mergeMode: GridCellMergeMode = GridCellMergeMode.always; + @ViewChild('grid', { read: IgxGridComponent, static: true }) + public grid: IgxGridComponent; + public cols = [ + { field:'ID', merge: false }, + { field:'ProductName', dataType: GridColumnDataType.String, merge: true }, + { field:'Downloads', dataType: GridColumnDataType.Number, merge: false }, + { field:'Released', dataType: GridColumnDataType.Boolean, merge: false }, + { field:'ReleaseDate', dataType: GridColumnDataType.Date, merge: false } + ]; + + public override data = [ + { + Downloads: 254, + ID: 1, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: this.today, + Released: true + }, + { + Downloads: 1000, + ID: 2, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: this.nextDay, + Released: true + }, + { + Downloads: 20, + ID: 3, + ProductName: 'Ignite UI for Angular', + ReleaseDate: null, + Released: true + }, + { + Downloads: null, + ID: 4, + ProductName: 'Ignite UI for JavaScript', + ReleaseDate: this.prevDay, + Released: true + }, + { + Downloads: 100, + ID: 5, + ProductName: 'Ignite UI for Angular', + ReleaseDate: null, + Released: true + }, + { + Downloads: 1000, + ID: 6, + ProductName: 'Ignite UI for Angular', + ReleaseDate: this.nextDay, + Released: true + }, + { + Downloads: 0, + ID: 7, + ProductName: null, + ReleaseDate: this.prevDay, + Released: true + }, + { + Downloads: 1000, + ID: 8, + ProductName: 'NetAdvantage', + ReleaseDate: this.today, + Released: true + }, + { + Downloads: 1000, + ID: 9, + ProductName: 'NetAdvantage', + ReleaseDate: this.prevDay, + Released: true + } + ]; + +} + +class NoopMergeStrategy extends DefaultMergeStrategy { + public override merge( + data: any[], + field: string, + comparer: (prevRecord: any, record: any, field: string) => boolean = this.comparer, + result: any[], + activeRowIndexes : number[], + grid?: GridType + ) { + return data; + } +} diff --git a/projects/igniteui-angular/src/lib/test-utils/grid-functions.spec.ts b/projects/igniteui-angular/src/lib/test-utils/grid-functions.spec.ts index e6689a977de..53058f1328a 100644 --- a/projects/igniteui-angular/src/lib/test-utils/grid-functions.spec.ts +++ b/projects/igniteui-angular/src/lib/test-utils/grid-functions.spec.ts @@ -19,7 +19,7 @@ import { IgxGridCellComponent } from '../grids/cell.component'; import { IgxPivotRowComponent } from '../grids/pivot-grid/pivot-row.component'; import { SortingDirection } from '../data-operations/sorting-strategy'; import { IgxRowDirective } from '../grids/row.directive'; -import { CellType, GridType, RowType } from '../grids/common/grid.interface'; +import { CellType, ColumnType, GridType, RowType } from '../grids/common/grid.interface'; import { IgxTreeNodeComponent } from '../tree/tree-node/tree-node.component'; import { IgxColumnComponent } from '../grids/columns/column.component'; import { IgxPivotGridComponent } from '../grids/pivot-grid/pivot-grid.component'; @@ -104,6 +104,21 @@ export const SAFE_DISPOSE_COMP_ID = 'root'; export class GridFunctions { + public static verifyColumnMergedState(grid: GridType, col: ColumnType, state: any[]) { + const dataRows = grid.dataRowList.toArray(); + let totalSpan = 0; + for (let index = 0; index < dataRows.length - 1; index++) { + const row = dataRows[index]; + const cellValue = row.cells.toArray().find(x => x.column === col).value; + const rowSpan = row.metaData?.cellMergeMeta.get(col.field)?.rowSpan || 1; + const currState = state[index - totalSpan]; + expect(cellValue).toBe(currState.value); + expect(rowSpan).toBe(currState.span); + totalSpan += (rowSpan - 1); + index += (rowSpan - 1); + } + } + public static getRows(fix): DebugElement[] { const rows: DebugElement[] = fix.debugElement.queryAll(By.css(ROW_CSS_CLASS)); rows.shift(); From 91c457b27f9e261cf15d8aa1cb69fc65cb51764a Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 21 Jul 2025 18:26:49 +0300 Subject: [PATCH 45/99] chore(*): Add some UI tests for merging. --- .../src/lib/grids/grid/cell-merge.spec.ts | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index f5f62896f78..b52e0c929e6 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -1,13 +1,19 @@ -import { Component, ViewChild } from '@angular/core'; -import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { DefaultMergeStrategy, GridCellMergeMode, GridColumnDataType, GridType, IgxColumnComponent, IgxGridComponent, SortingDirection } from 'igniteui-angular'; import { DataParent } from '../../test-utils/sample-test-data.spec'; import { GridFunctions } from '../../test-utils/grid-functions.spec'; +import { By } from '@angular/platform-browser'; +import { UIInteractions, wait } from '../../test-utils/ui-interactions.spec'; +import { hasClass } from '../../test-utils/helper-utils.spec'; describe('IgxGrid - Cell merging #grid', () => { let fix; let grid: IgxGridComponent; + const MERGE_CELL_CSS_CLASS = '.igx-grid__td--merged'; + const CELL_CSS_CLASS = '.igx-grid__td'; + const CSS_CLASS_GRID_ROW = '.igx-grid__tr'; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -121,6 +127,38 @@ describe('IgxGrid - Cell merging #grid', () => { ]); }); }); + + + describe('UI', () => { + it ('should properly align merged cells with their spanned rows.', () => { + const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; + const endRow = fix.debugElement.queryAll(By.css(CSS_CLASS_GRID_ROW))[2].nativeNode; + expect(mergedCell.getBoundingClientRect().bottom).toBe(endRow.getBoundingClientRect().bottom); + }); + + it('should mark merged cell as hovered when hovering any row that intersects that cell.', () => { + const secondRow = fix.debugElement.queryAll(By.css(CSS_CLASS_GRID_ROW))[2]; + UIInteractions.hoverElement(secondRow.nativeNode); + fix.detectChanges(); + // hover 2nd row that intersects the merged cell in row 1 + const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; + // merged cell should be marked as hovered + hasClass(mergedCell, 'igx-grid__td--merged-hovered', true); + }); + + it('should set correct size to merged cell that spans multiple rows that have different sizes.', () => { + const col = grid.getColumnByName('ID'); + col.bodyTemplate = fix.componentInstance.customTemplate; + fix.detectChanges(); + grid.verticalScrollContainer.recalcUpdateSizes(); + grid.dataRowList.toArray().forEach(x => x.cdr.detectChanges()); + const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; + // one row is 100px, other is 200, 4px border + expect(mergedCell.getBoundingClientRect().height).toBe(100 + 200 + 4); + + + }); + }); }); @Component({ @@ -130,6 +168,9 @@ describe('IgxGrid - Cell merging #grid', () => { } + + + `, imports: [IgxGridComponent, IgxColumnComponent] }) @@ -137,6 +178,10 @@ export class DefaultCellMergeGridComponent extends DataParent { public mergeMode: GridCellMergeMode = GridCellMergeMode.always; @ViewChild('grid', { read: IgxGridComponent, static: true }) public grid: IgxGridComponent; + + @ViewChild('customTemplate', { read: TemplateRef, static: true }) + public customTemplate: TemplateRef; + public cols = [ { field:'ID', merge: false }, { field:'ProductName', dataType: GridColumnDataType.String, merge: true }, From a5c5566911789aa96ae481dea7fa2c3951d99529 Mon Sep 17 00:00:00 2001 From: MKirova Date: Tue, 22 Jul 2025 17:40:07 +0300 Subject: [PATCH 46/99] chore(*): Add some integration tests. --- .../src/lib/grids/grid-base.directive.ts | 2 +- .../src/lib/grids/grid/cell-merge.spec.ts | 460 +++++++++++++----- 2 files changed, 341 insertions(+), 121 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index e5bcaa27c3e..e47d720920b 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3891,7 +3891,7 @@ export abstract class IgxGridBaseDirective implements GridType, const rec = prevDataView[index]; if (rec.cellMergeMeta && // index + maxRowSpan is within view - startIndex <= (index + Math.max(...rec.cellMergeMeta.values().toArray().map(x => x.rowSpan)))) { + startIndex < (index + Math.max(...rec.cellMergeMeta.values().toArray().map(x => x.rowSpan)))) { data.push({record: rec, index: index }); } } diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index b52e0c929e6..87710439b54 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -1,7 +1,7 @@ import { Component, TemplateRef, ViewChild } from '@angular/core'; import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { DefaultMergeStrategy, GridCellMergeMode, GridColumnDataType, GridType, IgxColumnComponent, IgxGridComponent, SortingDirection } from 'igniteui-angular'; +import { DefaultMergeStrategy, DefaultSortingStrategy, GridCellMergeMode, GridColumnDataType, GridType, IgxColumnComponent, IgxGridComponent, IgxPaginatorComponent, SortingDirection } from 'igniteui-angular'; import { DataParent } from '../../test-utils/sample-test-data.spec'; import { GridFunctions } from '../../test-utils/grid-functions.spec'; import { By } from '@angular/platform-browser'; @@ -22,142 +22,337 @@ describe('IgxGrid - Cell merging #grid', () => { }).compileComponents(); })); - beforeEach(() => { - fix = TestBed.createComponent(DefaultCellMergeGridComponent); - fix.detectChanges(); - grid = fix.componentInstance.grid; - }); + describe('Basic', () => { - it('should allow enabling/disabling merging per column.', () => { - - const col = grid.getColumnByName('ProductName'); - GridFunctions.verifyColumnMergedState(grid, col, [ - { value: 'Ignite UI for JavaScript', span: 2 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for Angular', span: 2 }, - { value: null , span: 1 }, - { value: 'NetAdvantage' , span: 2 } - ]); - - // disable merge - col.merge = false; - fix.detectChanges(); - GridFunctions.verifyColumnMergedState(grid, col, [ - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: null , span: 1 }, - { value: 'NetAdvantage' , span: 1 }, - { value: 'NetAdvantage' , span: 1 } - ]); + beforeEach(() => { + fix = TestBed.createComponent(DefaultCellMergeGridComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; }); - it('should always merge columns if mergeMode is always.', () => { - const col = grid.getColumnByName('Released'); - col.merge = true; - fix.detectChanges(); - GridFunctions.verifyColumnMergedState(grid, col, [ - { value: true, span: 9 } - ]); + describe('Configuration', () => { + + it('should allow enabling/disabling merging per column.', () => { + + const col = grid.getColumnByName('ProductName'); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 2 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + + // disable merge + col.merge = false; + fix.detectChanges(); + + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 1 }, + { value: 'NetAdvantage', span: 1 } + ]); + }); + + it('should always merge columns if mergeMode is always.', () => { + const col = grid.getColumnByName('Released'); + col.merge = true; + fix.detectChanges(); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: true, span: 9 } + ]); + }); + + it('should merge only sorted columns if mergeMode is onSort.', () => { + grid.cellMergeMode = 'onSort'; + fix.detectChanges(); + const col = grid.getColumnByName('ProductName'); + //nothing is merged initially + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 1 }, + { value: 'NetAdvantage', span: 1 } + ]); + + grid.sort({ fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + // merge only after sorted + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'NetAdvantage', span: 2 }, + { value: 'Ignite UI for JavaScript', span: 3 }, + { value: 'Ignite UI for Angular', span: 3 }, + { value: null, span: 1 } + ]); + }); + + it('should allow setting a custom merge strategy via mergeStrategy on grid.', () => { + grid.mergeStrategy = new NoopMergeStrategy(); + fix.detectChanges(); + const col = grid.getColumnByName('ProductName'); + // this strategy does no merging + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 1 }, + { value: 'NetAdvantage', span: 1 } + ]); + }); + + it('should allow setting a custom comparer for merging on particular column via mergingComparer.', () => { + const col = grid.getColumnByName('ProductName'); + // all are same and should merge + col.mergingComparer = (prev: any, rec: any, field: string) => { + return true; + }; + grid.pipeTrigger += 1; + fix.detectChanges(); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 9 } + ]); + }); }); - it('should merge only sorted columns if mergeMode is onSort.', () => { - grid.cellMergeMode = 'onSort'; - fix.detectChanges(); - const col = grid.getColumnByName('ProductName'); - //nothing is merged initially - GridFunctions.verifyColumnMergedState(grid, col, [ - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: null , span: 1 }, - { value: 'NetAdvantage' , span: 1 }, - { value: 'NetAdvantage' , span: 1 } - ]); - - grid.sort({ fieldName: 'ProductName', dir: SortingDirection.Desc, ignoreCase: false }); - fix.detectChanges(); + describe('UI', () => { + it('should properly align merged cells with their spanned rows.', () => { + const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; + const endRow = fix.debugElement.queryAll(By.css(CSS_CLASS_GRID_ROW))[2].nativeNode; + expect(mergedCell.getBoundingClientRect().bottom).toBe(endRow.getBoundingClientRect().bottom); + }); + + it('should mark merged cell as hovered when hovering any row that intersects that cell.', () => { + const secondRow = fix.debugElement.queryAll(By.css(CSS_CLASS_GRID_ROW))[2]; + UIInteractions.hoverElement(secondRow.nativeNode); + fix.detectChanges(); + // hover 2nd row that intersects the merged cell in row 1 + const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; + // merged cell should be marked as hovered + hasClass(mergedCell, 'igx-grid__td--merged-hovered', true); + }); - // merge only after sorted - GridFunctions.verifyColumnMergedState(grid, col, [ - { value: 'NetAdvantage' , span: 2 }, - { value: 'Ignite UI for JavaScript', span: 3 }, - { value: 'Ignite UI for Angular', span: 3 }, - { value: null , span: 1 } - ]); + it('should set correct size to merged cell that spans multiple rows that have different sizes.', () => { + const col = grid.getColumnByName('ID'); + col.bodyTemplate = fix.componentInstance.customTemplate; + fix.detectChanges(); + grid.verticalScrollContainer.recalcUpdateSizes(); + grid.dataRowList.toArray().forEach(x => x.cdr.detectChanges()); + const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; + // one row is 100px, other is 200, 4px border + expect(mergedCell.getBoundingClientRect().height).toBe(100 + 200 + 4); + }); }); + }); - it('should allow setting a custom merge strategy via mergeStrategy on grid.', () => { - grid.mergeStrategy = new NoopMergeStrategy(); + describe('Integration', () => { + beforeEach(() => { + fix = TestBed.createComponent(IntegrationCellMergeGridComponent); fix.detectChanges(); - const col = grid.getColumnByName('ProductName'); - // this strategy does no merging - GridFunctions.verifyColumnMergedState(grid, col, [ - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: 'Ignite UI for JavaScript', span: 1 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: 'Ignite UI for Angular', span: 1 }, - { value: null , span: 1 }, - { value: 'NetAdvantage' , span: 1 }, - { value: 'NetAdvantage' , span: 1 } - ]); + grid = fix.componentInstance.grid; }); - it('should allow setting a custom comparer for merging on particular column via mergingComparer.', () => { - const col = grid.getColumnByName('ProductName'); - // all are same and should merge - col.mergingComparer = (prev:any, rec: any, field: string) => { - return true; - }; - grid.pipeTrigger += 1; - fix.detectChanges(); - GridFunctions.verifyColumnMergedState(grid, col, [ - { value: 'Ignite UI for JavaScript', span: 9 } - ]); + describe('Virtualization', () => { + beforeEach(() => { + fix.componentInstance.width = '400px'; + fix.componentInstance.height = '300px'; + fix.detectChanges(); + }); + it('should retain rows with merged cells that span multiple rows in DOM as long as merged cell is still in view.', async() => { + // initial row list is same as the virtualization chunk + expect(grid.rowList.length).toBe(grid.virtualizationState.chunkSize); + + grid.navigateTo(grid.virtualizationState.chunkSize - 1, 0); + await wait(100); + fix.detectChanges(); + + //virtualization starts from 1 + expect(grid.virtualizationState.startIndex).toBe(1); + + // check row is chunkSize + 1 extra row at the top + expect(grid.rowList.length).toBe(grid.virtualizationState.chunkSize + 1); + // first row at top is index 0 + expect(grid.rowList.first.index).toBe(0); + // and has offset to position correctly the merged cell + expect(grid.rowList.first.nativeElement.offsetTop).toBeLessThan(-50); + }); + + it('should remove row from DOM when merged cell is no longer in view.', async() => { + // scroll so that first row with merged cell is not in view + grid.navigateTo(grid.virtualizationState.chunkSize, 0); + await wait(100); + fix.detectChanges(); + + //virtualization starts from 2 + expect(grid.virtualizationState.startIndex).toBe(2); + + // no merge cells from previous chunks + expect(grid.rowList.length).toBe(grid.virtualizationState.chunkSize); + // first row is from the virtualization + expect(grid.rowList.first.index).toBe(grid.virtualizationState.startIndex); + }); + + it('horizontal virtualization should not be affected by vertically merged cells.', async() => { + let mergedCell = grid.rowList.first.cells.find(x => x.column.field === 'ProductName'); + expect(mergedCell.value).toBe('Ignite UI for JavaScript'); + expect(mergedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + + // scroll horizontally + grid.navigateTo(0, 4); + await wait(100); + fix.detectChanges(); + + // not in DOM + mergedCell = grid.rowList.first.cells.find(x => x.column.field === 'ProductName'); + expect(mergedCell).toBeUndefined(); + + // scroll back + grid.navigateTo(0, 0); + await wait(100); + fix.detectChanges(); + + mergedCell = grid.rowList.first.cells.find(x => x.column.field === 'ProductName'); + expect(mergedCell.value).toBe('Ignite UI for JavaScript'); + expect(mergedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + }); }); - }); + describe('Group By', () => { + it('cells should merge only within their respective groups.', () => { + grid.groupBy({ + fieldName: 'ProductName', dir: SortingDirection.Desc, + ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + const col = grid.getColumnByName('ProductName'); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'NetAdvantage', span: 2 }, + { value: 'Ignite UI for JavaScript', span: 3 }, + { value: 'Ignite UI for Angular', span: 3 }, + { value: null, span: 1 } + ]); + + grid.groupBy({ + fieldName: 'ReleaseDate', dir: SortingDirection.Desc, + ignoreCase: false, strategy: DefaultSortingStrategy.instance() + }); + fix.detectChanges(); + + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'NetAdvantage', span: 1 }, + { value: 'NetAdvantage', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 } + ]); + + }); - describe('UI', () => { - it ('should properly align merged cells with their spanned rows.', () => { - const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; - const endRow = fix.debugElement.queryAll(By.css(CSS_CLASS_GRID_ROW))[2].nativeNode; - expect(mergedCell.getBoundingClientRect().bottom).toBe(endRow.getBoundingClientRect().bottom); }); - it('should mark merged cell as hovered when hovering any row that intersects that cell.', () => { - const secondRow = fix.debugElement.queryAll(By.css(CSS_CLASS_GRID_ROW))[2]; - UIInteractions.hoverElement(secondRow.nativeNode); - fix.detectChanges(); - // hover 2nd row that intersects the merged cell in row 1 - const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; - // merged cell should be marked as hovered - hasClass(mergedCell, 'igx-grid__td--merged-hovered', true); + describe('Master-Detail', () => { + + it('should interrupt merge sequence if a master-detail row is expanded.', () => { + grid.detailTemplate = fix.componentInstance.detailTemplate; + fix.detectChanges(); + + const col = grid.getColumnByName('ProductName'); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 2 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + + GridFunctions.toggleMasterRow(fix, grid.rowList.first); + fix.detectChanges(); + + // should slit first merge group in 2 + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + }); + + }); - it('should set correct size to merged cell that spans multiple rows that have different sizes.', () => { - const col = grid.getColumnByName('ID'); - col.bodyTemplate = fix.componentInstance.customTemplate; - fix.detectChanges(); - grid.verticalScrollContainer.recalcUpdateSizes(); - grid.dataRowList.toArray().forEach(x => x.cdr.detectChanges()); - const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; - // one row is 100px, other is 200, 4px border - expect(mergedCell.getBoundingClientRect().height).toBe(100 + 200 + 4); + describe('Paging', () => { + it('should merge cells only on current page of data.', () => { + fix.componentInstance.paging = true; + fix.detectChanges(); + grid.triggerPipes(); + fix.detectChanges(); + + const col = grid.getColumnByName('ProductName'); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 2 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 } + ]); + grid.page = 2; + fix.detectChanges(); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for Angular', span: 1 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + }); }); + + describe('Column Pinning', () => { + it('should merge cells in pinned columns.', () => { + const col = grid.getColumnByName('ProductName'); + col.pinned = true; + fix.detectChanges(); + + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 2 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + + const mergedCell = grid.rowList.first.cells.find(x => x.column.field === 'ProductName'); + expect(mergedCell.value).toBe('Ignite UI for JavaScript'); + expect(mergedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + }); + }); + }); }); @@ -183,11 +378,11 @@ export class DefaultCellMergeGridComponent extends DataParent { public customTemplate: TemplateRef; public cols = [ - { field:'ID', merge: false }, - { field:'ProductName', dataType: GridColumnDataType.String, merge: true }, - { field:'Downloads', dataType: GridColumnDataType.Number, merge: false }, - { field:'Released', dataType: GridColumnDataType.Boolean, merge: false }, - { field:'ReleaseDate', dataType: GridColumnDataType.Date, merge: false } + { field: 'ID', merge: false }, + { field: 'ProductName', dataType: GridColumnDataType.String, merge: true }, + { field: 'Downloads', dataType: GridColumnDataType.Number, merge: false }, + { field: 'Released', dataType: GridColumnDataType.Boolean, merge: false }, + { field: 'ReleaseDate', dataType: GridColumnDataType.Date, merge: false } ]; public override data = [ @@ -258,13 +453,38 @@ export class DefaultCellMergeGridComponent extends DataParent { } +@Component({ + template: ` + + @for(col of cols; track col) { + + } + @if (paging) { + + } + + + + + `, + imports: [IgxGridComponent, IgxColumnComponent, IgxPaginatorComponent] +}) +export class IntegrationCellMergeGridComponent extends DefaultCellMergeGridComponent { + public height = '100%'; + public width = '100%'; + public paging = false; + + @ViewChild('detailTemplate', { read: TemplateRef, static: true }) + public detailTemplate: TemplateRef; +} + class NoopMergeStrategy extends DefaultMergeStrategy { public override merge( data: any[], field: string, comparer: (prevRecord: any, record: any, field: string) => boolean = this.comparer, result: any[], - activeRowIndexes : number[], + activeRowIndexes: number[], grid?: GridType ) { return data; From 46ea7ae3cf27f111415fccde2151fa52556cbdae Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 23 Jul 2025 14:09:14 +0300 Subject: [PATCH 47/99] chore(*): Add more integration tests. --- .../src/lib/grids/grid/cell-merge.spec.ts | 186 +++++++++++++++++- .../src/lib/grids/row.directive.ts | 2 +- 2 files changed, 186 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index 87710439b54..0229ef9af01 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -3,7 +3,7 @@ import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { DefaultMergeStrategy, DefaultSortingStrategy, GridCellMergeMode, GridColumnDataType, GridType, IgxColumnComponent, IgxGridComponent, IgxPaginatorComponent, SortingDirection } from 'igniteui-angular'; import { DataParent } from '../../test-utils/sample-test-data.spec'; -import { GridFunctions } from '../../test-utils/grid-functions.spec'; +import { GridFunctions, GridSelectionFunctions } from '../../test-utils/grid-functions.spec'; import { By } from '@angular/platform-browser'; import { UIInteractions, wait } from '../../test-utils/ui-interactions.spec'; import { hasClass } from '../../test-utils/helper-utils.spec'; @@ -353,6 +353,190 @@ describe('IgxGrid - Cell merging #grid', () => { }); }); + describe('Row Pinning', () => { + it('should merge adjacent pinned rows in pinned row area.', () => { + const row1 = grid.rowList.toArray()[0]; + const row2 = grid.rowList.toArray()[1]; + const col = grid.getColumnByName('ProductName'); + row1.pin(); + row2.pin(); + fix.detectChanges(); + + expect(grid.pinnedRows.length).toBe(2); + const pinnedRow = grid.pinnedRows[0]; + expect(pinnedRow.metaData.cellMergeMeta.get(col.field)?.rowSpan).toBe(2); + const mergedPinnedCell = pinnedRow.cells.find(x => x.column.field === 'ProductName'); + expect(mergedPinnedCell.value).toBe('Ignite UI for JavaScript'); + expect(mergedPinnedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + }); + + it('should merge adjacent ghost rows in unpinned area.', () => { + const row1 = grid.rowList.toArray()[0]; + const row2 = grid.rowList.toArray()[1]; + const col = grid.getColumnByName('ProductName'); + row1.pin(); + row2.pin(); + fix.detectChanges(); + + const ghostRows = grid.rowList.filter(x => x.disabled); + expect(ghostRows.length).toBe(2); + const ghostRow = ghostRows[0]; + expect(ghostRow.metaData.cellMergeMeta.get(col.field)?.rowSpan).toBe(2); + const mergedPinnedCell = ghostRow.cells.find(x => x.column.field === 'ProductName'); + expect(mergedPinnedCell.value).toBe('Ignite UI for JavaScript'); + expect(mergedPinnedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + }); + + it('should not merge ghost and data rows together.', () => { + const col = grid.getColumnByName('ProductName'); + const row1 = grid.rowList.toArray()[0]; + row1.pin(); + fix.detectChanges(); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + }); + }); + + describe('Activation', () => { + + it('should interrupt merge sequence so that active row has no merging.', () => { + const col = grid.getColumnByName('ProductName'); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 2 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + + const row1 = grid.rowList.toArray()[0]; + + UIInteractions.simulateClickAndSelectEvent(row1.cells.toArray()[1].nativeElement); + fix.detectChanges(); + + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + }); + + }); + + describe('Updating', () => { + + beforeEach(() => { + grid.primaryKey = 'ID'; + grid.columns.forEach(x => x.editable = true); + fix.detectChanges(); + }); + + it('should edit the individual row values for the active row.', () => { + const col = grid.getColumnByName('ProductName'); + grid.rowEditable = true; + fix.detectChanges(); + + const row = grid.gridAPI.get_row_by_index(0); + const cell = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + UIInteractions.simulateDoubleClickAndSelectEvent(cell.nativeElement); + fix.detectChanges(); + expect(row.inEditMode).toBe(true); + + // row in edit is not merged anymore + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + + // enter new val + const cellInput = grid.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, "NewValue"); + fix.detectChanges(); + + // Done button click + const doneButtonElement = GridFunctions.getRowEditingDoneButton(fix); + doneButtonElement.click(); + fix.detectChanges(); + + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'NewValue', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + }); + + it('should edit the individual cell value for the active row.', () => { + const col = grid.getColumnByName('ProductName'); + let cell = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + + UIInteractions.simulateDoubleClickAndSelectEvent(cell.nativeElement); + fix.detectChanges(); + + cell = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + expect(cell.editMode).toBe(true); + + // enter new val + const cellInput = grid.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement.querySelector('[igxinput]'); + UIInteractions.setInputElementValue(cellInput, "NewValue"); + fix.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('enter', GridFunctions.getGridContent(fix)); + fix.detectChanges(); + + // row with edit cell is not merged anymore + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'NewValue', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + }); + }); + describe('Row Selection', () => { + + it('should mark all merged cells that intersect with a selected row as selected.', () => { + grid.rowSelection = 'multiple'; + fix.detectChanges(); + + const secondRow = grid.rowList.toArray()[1]; + GridSelectionFunctions.clickRowCheckbox(secondRow); + fix.detectChanges(); + + expect(secondRow.selected).toBe(true); + grid.markForCheck(); + + const mergedIntersectedCell = grid.gridAPI.get_cell_by_index(0, 'ProductName'); + // check cell has selected style + hasClass(mergedIntersectedCell.nativeElement,'igx-grid__td--merged-selected', true); + }); + + }); + }); }); diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 0e6c12ef6b7..a7bf2a10cb7 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -628,7 +628,7 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { if (mergeMeta && rowCount > 1) { const indexInData = this.pinned && this.grid.isRowPinningToTop ? this.index - this.grid.pinnedRecordsCount : this.index; const range = this.grid.verticalScrollContainer.igxForOf.slice(indexInData, indexInData + rowCount); - const inRange = range.filter(x => this.selectionService.isRowSelected(this.grid.primaryKey ? (x.recordRef || x)[this.grid.primaryKey] : (x.recordRef || x).recordRef)).length > 0; + const inRange = range.filter(x => this.selectionService.isRowSelected(this.grid.primaryKey ? (x.recordRef || x)[this.grid.primaryKey] : (x.recordRef || x))).length > 0; return inRange; } return false; From f8624690fd01254b48b0ca8a33729e0f856b283f Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 23 Jul 2025 16:02:36 +0300 Subject: [PATCH 48/99] chore(*): Add more integration tests. --- .../src/lib/grids/grid/cell-merge.spec.ts | 146 +++++++++++++++++- 1 file changed, 143 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index 0229ef9af01..5b0df570546 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -1,12 +1,13 @@ import { Component, TemplateRef, ViewChild } from '@angular/core'; import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { DefaultMergeStrategy, DefaultSortingStrategy, GridCellMergeMode, GridColumnDataType, GridType, IgxColumnComponent, IgxGridComponent, IgxPaginatorComponent, SortingDirection } from 'igniteui-angular'; +import { DefaultMergeStrategy, DefaultSortingStrategy, GridCellMergeMode, GridColumnDataType, GridType, IgxColumnComponent, IgxGridComponent, IgxPaginatorComponent, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular'; import { DataParent } from '../../test-utils/sample-test-data.spec'; import { GridFunctions, GridSelectionFunctions } from '../../test-utils/grid-functions.spec'; import { By } from '@angular/platform-browser'; import { UIInteractions, wait } from '../../test-utils/ui-interactions.spec'; import { hasClass } from '../../test-utils/helper-utils.spec'; +import { ColumnLayoutTestComponent } from './grid.multi-row-layout.spec'; describe('IgxGrid - Cell merging #grid', () => { let fix; @@ -14,10 +15,12 @@ describe('IgxGrid - Cell merging #grid', () => { const MERGE_CELL_CSS_CLASS = '.igx-grid__td--merged'; const CELL_CSS_CLASS = '.igx-grid__td'; const CSS_CLASS_GRID_ROW = '.igx-grid__tr'; + const HIGHLIGHT_ACTIVE_CSS_CLASS = '.igx-highlight__active'; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - NoopAnimationsModule, DefaultCellMergeGridComponent + NoopAnimationsModule, DefaultCellMergeGridComponent, ColumnLayoutTestComponent ] }).compileComponents(); })); @@ -517,6 +520,7 @@ describe('IgxGrid - Cell merging #grid', () => { ]); }); }); + describe('Row Selection', () => { it('should mark all merged cells that intersect with a selected row as selected.', () => { @@ -528,7 +532,6 @@ describe('IgxGrid - Cell merging #grid', () => { fix.detectChanges(); expect(secondRow.selected).toBe(true); - grid.markForCheck(); const mergedIntersectedCell = grid.gridAPI.get_cell_by_index(0, 'ProductName'); // check cell has selected style @@ -537,6 +540,143 @@ describe('IgxGrid - Cell merging #grid', () => { }); + describe('Cell Selection', () => { + it('should interrupt merge sequence so that selected cell has no merging.', () => { + const col = grid.getColumnByName('ProductName'); + grid.cellSelection = 'multiple'; + fix.detectChanges(); + + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 2 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 2 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + + const startCell = grid.gridAPI.get_cell_by_index(4, 'ProductName'); + const endCell = grid.gridAPI.get_cell_by_index(0, 'ID'); + + GridSelectionFunctions.selectCellsRangeNoWait(fix, startCell, endCell); + fix.detectChanges(); + + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for JavaScript', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: 'Ignite UI for Angular', span: 1 }, + { value: null, span: 1 }, + { value: 'NetAdvantage', span: 2 } + ]); + + // check api + expect(grid.getSelectedData().length).toBe(5); + expect(grid.getSelectedData()).toEqual(grid.data.slice(0, 5).map(x => { return { 'ID': x.ID, 'ProductName': x. ProductName}})); + }); + }); + + describe('Column selection', () => { + it('should mark merged cells in selected column as selected.', () => { + grid.columnSelection = 'multiple'; + fix.detectChanges(); + const col = grid.getColumnByName('ProductName'); + col.selected = true; + fix.detectChanges(); + + const mergedCells = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS)); + mergedCells.forEach(element => { + hasClass(element.nativeNode, 'igx-grid__td--column-selected', true); + }); + }); + + it('selected data API should return all associated data fields as selected.', () => { + grid.columnSelection = 'multiple'; + fix.detectChanges(); + const col = grid.getColumnByName('ProductName'); + col.selected = true; + fix.detectChanges(); + + expect(grid.getSelectedColumnsData()).toEqual(grid.data.map(x => { return {'ProductName': x. ProductName}})); + }); + }); + + describe('Filtering', () => { + + it('should merge cells in filtered data.', () => { + grid.filter('ProductName', 'Net', IgxStringFilteringOperand.instance().condition('startsWith'), true); + fix.detectChanges(); + const col = grid.getColumnByName('ProductName'); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'NetAdvantage', span: 2 } + ]); + }); + + }); + + describe('Searching', () => { + + it('findNext \ findPrev should count merged cells as 1 result and navigate once through them.', () => { + const cell0 = grid.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement; + const cell3 = grid.gridAPI.get_cell_by_index(3, 'ProductName').nativeElement; + const fixNativeElem = fix.debugElement.nativeElement; + + let matches = grid.findNext('JavaScript'); + fix.detectChanges(); + + expect(matches).toBe(2); + + let activeHighlight = fixNativeElem.querySelectorAll(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight[0].closest("igx-grid-cell")).toBe(cell0); + + matches = grid.findNext('JavaScript'); + fix.detectChanges(); + + activeHighlight = fixNativeElem.querySelectorAll(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight[0].closest("igx-grid-cell")).toBe(cell3); + + matches = grid.findPrev('JavaScript'); + fix.detectChanges(); + + activeHighlight = fixNativeElem.querySelectorAll(HIGHLIGHT_ACTIVE_CSS_CLASS); + expect(activeHighlight[0].closest("igx-grid-cell")).toBe(cell0); + }); + + it('should update matches if a cell becomes unmerged.', () => { + let matches = grid.findNext('JavaScript'); + fix.detectChanges(); + + expect(matches).toBe(2); + + UIInteractions.simulateClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement); + fix.detectChanges(); + + matches = grid.findNext('JavaScript'); + fix.detectChanges(); + expect(matches).toBe(3); + }); + + }); + + describe('Multi-row layout', () => { + it('should throw warning and disallow merging with mrl.', () => { + jasmine.getEnv().allowRespy(true); + fix = TestBed.createComponent(ColumnLayoutTestComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + spyOn(console, 'warn'); + grid.columns[1].merge = true; + fix.detectChanges(); + + expect(console.warn).toHaveBeenCalledWith('Merging is not supported with multi-row layouts.'); + expect(console.warn).toHaveBeenCalledTimes(1); + jasmine.getEnv().allowRespy(false); + }); + + }); + }); }); From fc467396f89401c67edb5578b700d4ff47862737 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 24 Jul 2025 14:40:33 +0300 Subject: [PATCH 49/99] chore(*): Add Hgrid and TreeGrid integration tests. --- .../src/lib/grids/grid/cell-merge.spec.ts | 209 +++++++++++++++++- 1 file changed, 207 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index 5b0df570546..8e8bb8e58fb 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -1,13 +1,16 @@ import { Component, TemplateRef, ViewChild } from '@angular/core'; import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { DefaultMergeStrategy, DefaultSortingStrategy, GridCellMergeMode, GridColumnDataType, GridType, IgxColumnComponent, IgxGridComponent, IgxPaginatorComponent, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular'; +import { ByLevelTreeGridMergeStrategy, DefaultMergeStrategy, DefaultSortingStrategy, GridCellMergeMode, GridColumnDataType, GridType, IgxColumnComponent, IgxGridComponent, IgxHierarchicalGridComponent, IgxPaginatorComponent, IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular'; import { DataParent } from '../../test-utils/sample-test-data.spec'; import { GridFunctions, GridSelectionFunctions } from '../../test-utils/grid-functions.spec'; import { By } from '@angular/platform-browser'; import { UIInteractions, wait } from '../../test-utils/ui-interactions.spec'; import { hasClass } from '../../test-utils/helper-utils.spec'; import { ColumnLayoutTestComponent } from './grid.multi-row-layout.spec'; +import { IgxHierarchicalGridTestBaseComponent } from '../hierarchical-grid/hierarchical-grid.spec'; +import { IgxHierarchicalRowComponent } from '../hierarchical-grid/hierarchical-row.component'; +import { IgxTreeGridSelectionComponent } from '../../test-utils/tree-grid-components.spec'; describe('IgxGrid - Cell merging #grid', () => { let fix; @@ -20,7 +23,8 @@ describe('IgxGrid - Cell merging #grid', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ - NoopAnimationsModule, DefaultCellMergeGridComponent, ColumnLayoutTestComponent + NoopAnimationsModule, DefaultCellMergeGridComponent, ColumnLayoutTestComponent, + IgxHierarchicalGridTestBaseComponent, IgxTreeGridSelectionComponent ] }).compileComponents(); })); @@ -677,6 +681,207 @@ describe('IgxGrid - Cell merging #grid', () => { }); + describe('HierarchicalGrid', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxHierarchicalGridTestBaseComponent); + fix.componentInstance.data = [ + { + ID: 1, ChildLevels: 1, ProductName: 'Product A' , Col1: 1, + childData: [ + { + ID: 1, ChildLevels: 2, ProductName: 'Product A' , Col1: 1, + }, + { + ID: 2, ChildLevels: 2, ProductName: 'Product A' , Col1: 1, + }, + { + ID: 3, ChildLevels: 2, ProductName: 'Product B' , Col1: 1, + }, + { + ID: 4, ChildLevels: 2, ProductName: 'Product A' , Col1: 1, + } + ] + }, + { + ID: 2, ChildLevels: 1, ProductName: 'Product A' , Col1: 1, childData: [ + { + ID: 1, ChildLevels: 2, ProductName: 'Product A' , Col1: 1, + }, + { + ID: 2, ChildLevels: 2, ProductName: 'Product A' , Col1: 1, + }, + { + ID: 3, ChildLevels: 2, ProductName: 'Product A' , Col1: 1, + }, + { + ID: 4, ChildLevels: 2, ProductName: 'Product A' , Col1: 1, + } + ] + }, + { + ID: 3, ChildLevels: 1, ProductName: 'Product B' , Col1: 1 + }, + { + ID: 4, ChildLevels: 1, ProductName: 'Product B' , Col1: 1 + }, + { + ID: 5, ChildLevels: 1, ProductName: 'Product C' , Col1: 1 + }, + { + ID: 6, ChildLevels: 1, ProductName: 'Product B' , Col1: 1 + } + ]; + fix.detectChanges(); + grid = fix.componentInstance.hgrid; + // enable merging + grid.cellMergeMode = 'always'; + const col = grid.getColumnByName('ProductName'); + col.merge = true; + fix.detectChanges(); + }); + + it('should allow configuring and merging cells on each level of hierarchy.', () => { + + const col = grid.getColumnByName('ProductName'); + // root grid should be merged + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Product A', span: 2 }, + { value: 'Product B', span: 2 }, + { value: 'Product C', span: 1 }, + { value: 'Product B', span: 1 } + ]); + + const ri = fix.componentInstance.rowIsland; + ri.cellMergeMode = 'always'; + ri.getColumnByName('ProductName').merge = true; + fix.detectChanges(); + + // toggle row + const firstRow = grid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + const childGrid = grid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + expect(childGrid).toBeDefined(); + + // merging enabled + GridFunctions.verifyColumnMergedState(childGrid, childGrid.getColumnByName('ProductName'), [ + { value: 'Product A', span: 2 }, + { value: 'Product B', span: 1 }, + { value: 'Product A', span: 1 } + ]); + }); + + it('should merge cells within their respective grids only.', () => { + const ri = fix.componentInstance.rowIsland; + ri.cellMergeMode = 'always'; + ri.getColumnByName('ProductName').merge = true; + fix.detectChanges(); + + // toggle row 1 + const firstRow = grid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + // toggle row 2 + const secondRow = grid.gridAPI.get_row_by_index(2) as IgxHierarchicalRowComponent; + secondRow.toggle(); + fix.detectChanges(); + + const childGrid1 = grid.gridAPI.getChildGrids(false)[0] as IgxHierarchicalGridComponent; + expect(childGrid1).toBeDefined(); + + GridFunctions.verifyColumnMergedState(childGrid1, childGrid1.getColumnByName('ProductName'), [ + { value: 'Product A', span: 2 }, + { value: 'Product B', span: 1 }, + { value: 'Product A', span: 1 } + ]); + + const childGrid2 = grid.gridAPI.getChildGrids(false)[1] as IgxHierarchicalGridComponent; + expect(childGrid2).toBeDefined(); + + GridFunctions.verifyColumnMergedState(childGrid2, childGrid2.getColumnByName('ProductName'), [ + { value: 'Product A', span: 4 } + ]); + }); + + it('should interrupt merge sequence if row is expanded and a child grid is shown between same value cells.', () => { + const col = grid.getColumnByName('ProductName'); + // root grid should be merged + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Product A', span: 2 }, + { value: 'Product B', span: 2 }, + { value: 'Product C', span: 1 }, + { value: 'Product B', span: 1 } + ]); + + // toggle row 1 + const firstRow = grid.gridAPI.get_row_by_index(0) as IgxHierarchicalRowComponent; + firstRow.toggle(); + fix.detectChanges(); + + // first merge sequence interrupted due to expanded row + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: 'Product A', span: 1 }, + { value: 'Product A', span: 1 }, + { value: 'Product B', span: 2 }, + { value: 'Product C', span: 1 }, + { value: 'Product B', span: 1 } + ]); + }); + + }); + + describe('TreeGrid', () => { + + beforeEach(() => { + fix = TestBed.createComponent(IgxTreeGridSelectionComponent); + fix.detectChanges(); + grid = fix.componentInstance.treeGrid; + // enable merging + grid.cellMergeMode = 'always'; + const col = grid.getColumnByName('OnPTO'); + col.merge = true; + fix.detectChanges(); + }); + + it('should merge all cells with same values, even if on different levels by default.', () => { + const col = grid.getColumnByName('OnPTO'); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: false, span: 2 }, + { value: true, span: 1 }, + { value: false, span: 1 }, + { value: true, span: 1 }, + { value: false, span: 2 }, + { value: true, span: 1 }, + { value: false, span: 3 }, + { value: true, span: 1 } + ]); + }); + + it('should allow setting the ByLevelTreeGridMergeStrategy as the mergeStrategy to merge only data on the same hierarchy level.', () => { + grid.mergeStrategy = new ByLevelTreeGridMergeStrategy(); + fix.detectChanges(); + grid.triggerPipes(); + fix.detectChanges(); + const col = grid.getColumnByName('OnPTO'); + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: false, span: 1 }, + { value: false, span: 1 }, + { value: true, span: 1 }, + { value: false, span: 1 }, + { value: true, span: 1 }, + { value: false, span: 1 }, + { value: false, span: 1 }, + { value: true, span: 1 }, + { value: false, span: 1 }, + { value: false, span: 1 }, + { value: false, span: 1 }, + { value: true, span: 1 } + ]); + }); + }); }); }); From ea9baa684dfa247b46b8d2c76d43dd98e5460b9b Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 24 Jul 2025 14:43:14 +0300 Subject: [PATCH 50/99] chore(*): Fix lint i tests. --- .../src/lib/grids/grid/cell-merge.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index 8e8bb8e58fb..0493bdef316 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -578,7 +578,9 @@ describe('IgxGrid - Cell merging #grid', () => { // check api expect(grid.getSelectedData().length).toBe(5); - expect(grid.getSelectedData()).toEqual(grid.data.slice(0, 5).map(x => { return { 'ID': x.ID, 'ProductName': x. ProductName}})); + expect(grid.getSelectedData()).toEqual(grid.data.slice(0, 5).map(x => { + return { 'ID': x.ID, 'ProductName': x. ProductName}; + })); }); }); @@ -603,7 +605,9 @@ describe('IgxGrid - Cell merging #grid', () => { col.selected = true; fix.detectChanges(); - expect(grid.getSelectedColumnsData()).toEqual(grid.data.map(x => { return {'ProductName': x. ProductName}})); + expect(grid.getSelectedColumnsData()).toEqual(grid.data.map(x => { + return {'ProductName': x. ProductName}; + })); }); }); From 1f4c5543152a7365aee0a474aff59ca7e0451710 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 24 Jul 2025 15:07:16 +0300 Subject: [PATCH 51/99] chore(*): Update Changelog. --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5d8b720cf..1ea27757245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes for each version of this project will be documented in this file. +## 20.1.0 + +### New Features + +- `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid` + - Introduced a new cell merging feature that allows you to configure and merge cells in a column based on same data or other custom condition, into a single cell. + + It can be enabled on the individual columns: + + ```html + + ``` + The merging can be configured on the grid level to apply either: + - `onSort` - only when the column is sorted. + - `always` - always, regardless of data operations. + + ```html + + + ``` + + The default `cellMergeMode` is `onSort`. + + The functionality can be modified by setting a custom `mergeStrategy` on the grid, in case some other merge conditions or logic is needed for a custom scenario. + + It's possible also to set a `mergeComparer` on the individual columns, in case some custom handling is needed for a particular data field. + + ## 20.0.0 ### General From cc478207714eacca4dde0359dc7f89c2e99960af Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 24 Jul 2025 16:11:36 +0300 Subject: [PATCH 52/99] chore(*): Fix unrelated respy issue in combo tests. --- .../src/lib/simple-combo/simple-combo.component.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts index d14691194bb..f72b08c7292 100644 --- a/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts @@ -532,6 +532,7 @@ describe('IgxSimpleCombo', () => { }); it('should delete the selection on destroy', () => { + jasmine.getEnv().allowRespy(true); const selectionService = new IgxSelectionAPIService(); const comboClearSpy = spyOn(mockComboService, 'clear'); const selectionDeleteSpy = spyOn(selectionService, 'delete'); @@ -548,6 +549,7 @@ describe('IgxSimpleCombo', () => { combo.ngOnDestroy(); expect(comboClearSpy).toHaveBeenCalled(); expect(selectionDeleteSpy).toHaveBeenCalled(); + jasmine.getEnv().allowRespy(false); }); }); From 35a1bde653e04b8f282497ce8198479d2a853525 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 24 Jul 2025 16:57:51 +0300 Subject: [PATCH 53/99] chore(*): Fix hardcoded value in unrelated test. --- .../src/lib/query-builder/query-builder.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/query-builder/query-builder.component.spec.ts b/projects/igniteui-angular/src/lib/query-builder/query-builder.component.spec.ts index 8a1e2af8635..38f0578a17e 100644 --- a/projects/igniteui-angular/src/lib/query-builder/query-builder.component.spec.ts +++ b/projects/igniteui-angular/src/lib/query-builder/query-builder.component.spec.ts @@ -773,7 +773,7 @@ describe('IgxQueryBuilder', () => { // Verify value input placeholder const input = QueryBuilderFunctions.getQueryBuilderValueInput(fix).querySelector('input'); // Verify value input placeholder - expect(input.placeholder).toEqual('Select date'); + expect(input.placeholder).toEqual(queryBuilder.resourceStrings.igx_query_builder_date_placeholder); QueryBuilderFunctions.verifyEditModeExpressionInputStates(fix, true, true, false, true); // Third input should be disabled for unary operators. // Commit the populated expression. From a7970d126246a35165c2cbdcf61c48ffbd2485fb Mon Sep 17 00:00:00 2001 From: MKirova Date: Fri, 25 Jul 2025 11:55:06 +0300 Subject: [PATCH 54/99] chore(*): Improve samples a bit. --- .../styles/components/grid/_grid-theme.scss | 2 + .../grid-cellMerging.component.html | 45 +- .../grid-cellMerging.component.scss | 3 + .../grid-cellMerging.component.ts | 343 +- src/app/shared/invoiceData.ts | 8081 +++++++++++++++++ 5 files changed, 8113 insertions(+), 361 deletions(-) create mode 100644 src/app/shared/invoiceData.ts diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index 5f5df28fc9c..4537aa86c68 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1178,6 +1178,7 @@ %grid-row--mrl { %igx-grid__hierarchical-expander--header, + %igx-grid__hierarchical-expander, %igx-grid__header-indentation, %igx-grid__row-indentation, %grid__cbx-selection { @@ -1310,6 +1311,7 @@ } %grid__cbx-selection, + %igx-grid__hierarchical-expander, %igx-grid__row-indentation, %igx-grid__drag-indicator { border-bottom: rem(1px) solid var-get($theme, 'row-border-color'); diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index b522b1af332..bc53e1cb9fa 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -1,7 +1,7 @@

Grid with cell merge

- + @if (searchText.length === 0) { search @@ -45,46 +45,47 @@

Grid with cell merge

- - -
- - - -
-
+ + + + + + + + + + + + + - + - + - + - + - + -

Hierarchical grid with cell merge

- - + @@ -108,7 +109,7 @@

Hierarchical grid with cell merge

Tree grid with cell merge

- diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.scss b/src/app/grid-cellMerging/grid-cellMerging.component.scss index 3d4037836d0..9f4f5d20867 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.scss +++ b/src/app/grid-cellMerging/grid-cellMerging.component.scss @@ -17,3 +17,6 @@ .grid-size { --ig-size: var(--ig-size-small); } +.searchInput{ + width: 800px; +} diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index 89b86f37a5e..d8cb3919a15 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -27,6 +27,7 @@ import { HIERARCHICAL_DATA } from '../shared/hierarchicalData'; import { data, dataWithoutPK } from '../shared/data'; import { HIERARCHICAL_SAMPLE_DATA } from '../shared/sample-data'; import { ByLevelTreeGridMergeStrategy } from 'igniteui-angular'; +import { INVOICE_DATA } from '../shared/invoiceData'; @Component({ selector: 'app-grid-cellMerging', @@ -36,16 +37,13 @@ import { ByLevelTreeGridMergeStrategy } from 'igniteui-angular'; FormsModule, IgxColumnComponent, IgxGridComponent, - IgxCellTemplateDirective, - IgxButtonDirective, IgxPaginatorComponent, - IgxActionStripComponent, - IgxGridPinningActionsComponent, + // IgxActionStripComponent, + // IgxGridPinningActionsComponent, IgxGridToolbarComponent, IgxGridToolbarActionsComponent, IgxGridToolbarPinningComponent, IgxGridToolbarHidingComponent, - IgxGridToolbarExporterComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent, IgxTreeGridComponent, @@ -64,340 +62,7 @@ export class GridCellMergingComponent { public alignTop= { alignItems: "flex-start", paddingTop: "12px" }; public searchText: string =''; @ViewChild('grid1', { static: true }) public grid: IgxGridComponent; - public data = [{ - ProductID: 1, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '10 boxes x 20 bags', - UnitPrice: '18.0000', - UnitsInStock: 39, - UnitsOnOrder: 0, - ReorderLevel: 10.567, - Discontinued: false, - OrderDate: null, - OrderDate2: new Date(1991, 2, 12, 18, 40, 50).toISOString() - }, { - ProductID: 2, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 3, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 4, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '20.0000', - UnitsInStock: 20, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 5, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 6, - ProductName: 'Chang', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 7, - ProductName: 'Chang', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 8, - ProductName: 'Chang', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 30, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 9, - ProductName: 'Aniseed Syrup', - SupplierID: 1, - CategoryID: 2, - QuantityPerUnit: '12 - 550 ml bottles', - UnitPrice: '10.0000', - UnitsInStock: 13, - UnitsOnOrder: 70, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2006-03-17').toISOString(), - OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() - }, - { - ProductID: 10, - ProductName: 'Chang', - SupplierID: 1, - CategoryID: 2, - QuantityPerUnit: '12 - 550 ml bottles', - UnitPrice: '10.0000', - UnitsInStock: 13, - UnitsOnOrder: 70, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2006-03-17').toISOString(), - OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() - }, - { - ProductID: 11, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 2, - QuantityPerUnit: '12 - 550 ml bottles', - UnitPrice: '10.0000', - UnitsInStock: 13, - UnitsOnOrder: 70, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2006-03-17').toISOString(), - OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() - }, - { - ProductID: 12, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 2, - QuantityPerUnit: '12 - 550 ml bottles', - UnitPrice: '10.0000', - UnitsInStock: 12, - UnitsOnOrder: 70, - ReorderLevel: 30, - Discontinued: false, - OrderDate: new Date('2006-03-17').toISOString(), - OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() - }, - { - ProductID: 13, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '10 boxes x 20 bags', - UnitPrice: '18.0000', - UnitsInStock: 39, - UnitsOnOrder: 0, - ReorderLevel: 10.567, - Discontinued: false, - OrderDate: null, - OrderDate2: new Date(1991, 2, 12, 18, 40, 50).toISOString() - }, { - ProductID: 14, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 15, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 16, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '20.0000', - UnitsInStock: 20, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 17, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 18, - ProductName: 'Chang', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 19, - ProductName: 'Chang', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 20, - ProductName: 'Chang', - SupplierID: 1, - CategoryID: 1, - QuantityPerUnit: '24 - 12 oz bottles', - UnitPrice: '19.0000', - UnitsInStock: 17, - UnitsOnOrder: 40, - ReorderLevel: 30, - Discontinued: false, - OrderDate: new Date('2003-03-17').toISOString(), - OrderDate2: new Date('2003-03-17').toISOString() - }, - { - ProductID: 21, - ProductName: 'Aniseed Syrup', - SupplierID: 1, - CategoryID: 2, - QuantityPerUnit: '12 - 550 ml bottles', - UnitPrice: '10.0000', - UnitsInStock: 13, - UnitsOnOrder: 70, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2006-03-17').toISOString(), - OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() - }, - { - ProductID: 22, - ProductName: 'Chang', - SupplierID: 1, - CategoryID: 2, - QuantityPerUnit: '12 - 550 ml bottles', - UnitPrice: '10.0000', - UnitsInStock: 13, - UnitsOnOrder: 70, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2006-03-17').toISOString(), - OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() - }, - { - ProductID: 23, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 2, - QuantityPerUnit: '12 - 550 ml bottles', - UnitPrice: '10.0000', - UnitsInStock: 13, - UnitsOnOrder: 70, - ReorderLevel: 25, - Discontinued: false, - OrderDate: new Date('2006-03-17').toISOString(), - OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() - }, - { - ProductID: 24, - ProductName: 'Chai', - SupplierID: 1, - CategoryID: 2, - QuantityPerUnit: '12 - 550 ml bottles', - UnitPrice: '10.0000', - UnitsInStock: 12, - UnitsOnOrder: 70, - ReorderLevel: 30, - Discontinued: false, - OrderDate: new Date('2006-03-17').toISOString(), - OrderDate2: new Date(1991, 2, 12, 15, 40, 50).toISOString() - }]; + public data = INVOICE_DATA; public searchKeyDown(ev) { if (ev.key === 'Enter' || ev.key === 'ArrowDown' || ev.key === 'ArrowRight') { diff --git a/src/app/shared/invoiceData.ts b/src/app/shared/invoiceData.ts new file mode 100644 index 00000000000..051444c0009 --- /dev/null +++ b/src/app/shared/invoiceData.ts @@ -0,0 +1,8081 @@ +/* eslint-disable */ + +export interface Invoice { + ID: number; + ShipAddress: string; + ShipCity: string; + ShipCountry: string; + ShipName: string; + ShipRegion: string; + ShipPostalCode: string; + CustomerID: string; + CustomerName: string; + Address: string; + City: string; + Region: string; + PostalCode: string; + Country: string; + Salesperson: string; + OrderID: number; + OrderDate: Date; + ShipperName: string; + ProductID: number; + ProductName: string; + UnitPrice: number; + Quantity: number; + Discontinued: boolean; + ExtendedPrice: number; + Freight: number; +} + +export const INVOICE_DATA = [{ + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipCountry: "Germany", + ShipName: "Alfred's Futterkiste", + ShipRegion: null, + ShipPostalCode: "12209", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10692, + OrderDate: new Date("11/23/2016"), + ShipperName: "United Package", + ProductID: 63, + ProductName: "Vegie-spread", + UnitPrice: 43.9000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 878.0000, + Freight: 61.0200 +}, { + ShipName: "Alfred's Futterkiste", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10702, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 3, + ProductName: "Aniseed Syrup", + UnitPrice: 10.0000, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 60.0000, + Freight: 23.9400 +}, { + ShipName: "Alfred's Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10702, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 76, + ProductName: "Lakkalik\u00f6\u00f6ri", + UnitPrice: 18.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 270.0000, + Freight: 23.9400 +}, { + ShipName: "Alfred's Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Nancy Davolio", + OrderID: 10835, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 59, + ProductName: "Raclette Courdavault", + UnitPrice: 55.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 825.0000, + Freight: 69.5300 +}, { + ShipName: "Alfred's Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Nancy Davolio", + OrderID: 10952, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 28, + ProductName: "R\u00f6ssle Sauerkraut", + UnitPrice: 45.6000, + Quantity: 2, + Discontinued: true, + ExtendedPrice: 91.2000, + Freight: 40.4200 +}, { + ShipName: "Alfred's Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Janet Leverling", + OrderID: 11011, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 71, + ProductName: "Flotemysost", + UnitPrice: 21.5000, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 430.0000, + Freight: 1.2100 +}, { + ShipName: "Alfred's Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Nancy Davolio", + OrderID: 10952, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 6, + ProductName: "Grandma's Boysenberry Spread", + UnitPrice: 25.0000, + Quantity: 16, + Discontinued: true, + ExtendedPrice: 380.0000, + Freight: 40.4200 +}, { + ShipName: "Alfred's Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Janet Leverling", + OrderID: 11011, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 58, + ProductName: "Escargots de Bourgogne", + UnitPrice: 13.2500, + Quantity: 40, + Discontinued: true, + ExtendedPrice: 503.5000, + Freight: 1.2100 +}, { + ShipName: "Alfred's Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Nancy Davolio", + OrderID: 10835, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 77, + ProductName: "Original Frankfurter gr\u00fcne So\u00dfe", + UnitPrice: 13.0000, + Quantity: 2, + Discontinued: true, + ExtendedPrice: 20.8000, + Freight: 69.5300 +}, { + ShipName: "Alfreds Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Michael Suyama", + OrderID: 10643, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 28, + ProductName: "R\u00f6ssle Sauerkraut", + UnitPrice: 45.6000, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 513.0000, + Freight: 29.4600 +}, { + ShipName: "Alfreds Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Michael Suyama", + OrderID: 10643, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 39, + ProductName: "Chartreuse verte", + UnitPrice: 18.0000, + Quantity: 21, + Discontinued: true, + ExtendedPrice: 283.5000, + Freight: 29.4600 +}, { + ShipName: "Alfreds Futterkiste", + ShipAddress: "Obere Str. 57", + ShipCity: "Berlin", + ShipRegion: null, + ShipPostalCode: "12209", + ShipCountry: "Germany", + CustomerID: "ALFKI", + CustomerName: "Alfreds Futterkiste", + Address: "Obere Str. 57", + City: "Berlin", + Region: null, + PostalCode: "12209", + Country: "Germany", + Salesperson: "Michael Suyama", + OrderID: 10643, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 46, + ProductName: "Spegesild", + UnitPrice: 12.0000, + Quantity: 2, + Discontinued: true, + ExtendedPrice: 18.0000, + Freight: 29.4600 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Robert King", + OrderID: 10308, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 69, + ProductName: "Gudbrandsdalsost", + UnitPrice: 28.8000, + Quantity: 1, + Discontinued: false, + ExtendedPrice: 28.8000, + Freight: 1.6100 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Robert King", + OrderID: 10308, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 70, + ProductName: "Outback Lager", + UnitPrice: 12.0000, + Quantity: 5, + Discontinued: false, + ExtendedPrice: 60.0000, + Freight: 1.6100 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10625, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 14, + ProductName: "Tofu", + UnitPrice: 23.2500, + Quantity: 3, + Discontinued: false, + ExtendedPrice: 69.7500, + Freight: 43.9000 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10625, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 42, + ProductName: "Singaporean Hokkien Fried Mee", + UnitPrice: 14.0000, + Quantity: 5, + Discontinued: false, + ExtendedPrice: 70.0000, + Freight: 43.9000 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10625, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 60, + ProductName: "Camembert Pierrot", + UnitPrice: 34.0000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 340.0000, + Freight: 43.9000 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10759, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 32, + ProductName: "Mascarpone Fabioli", + UnitPrice: 32.0000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 320.0000, + Freight: 11.9900 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10926, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 11, + ProductName: "Queso Cabrales", + UnitPrice: 21.0000, + Quantity: 2, + Discontinued: false, + ExtendedPrice: 42.0000, + Freight: 39.9200 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10926, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 13, + ProductName: "Konbu", + UnitPrice: 6.0000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 60.0000, + Freight: 39.9200 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10926, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 19, + ProductName: "Teatime Chocolate Biscuits", + UnitPrice: 9.2000, + Quantity: 7, + Discontinued: false, + ExtendedPrice: 64.4000, + Freight: 39.9200 +}, { + ShipName: "Ana Trujillo Emparedados y helados", + ShipAddress: "Avda. de la Constituci\u00f3n 2222", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05021", + ShipCountry: "Mexico", + CustomerID: "ANATR", + CustomerName: "Ana Trujillo Emparedados y helados", + Address: "Avda. de la Constituci\u00f3n 2222", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05021", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10926, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 72, + ProductName: "Mozzarella di Giovanni", + UnitPrice: 34.8000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 348.0000, + Freight: 39.9200 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10365, + OrderDate: new Date("11/23/2016"), + ShipperName: "United Package", + ProductID: 11, + ProductName: "Queso Cabrales", + UnitPrice: 16.8000, + Quantity: 24, + Discontinued: false, + ExtendedPrice: 403.2000, + Freight: 22.0000 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Robert King", + OrderID: 10573, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 17, + ProductName: "Alice Mutton", + UnitPrice: 39.0000, + Quantity: 18, + Discontinued: false, + ExtendedPrice: 702.0000, + Freight: 84.8400 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Robert King", + OrderID: 10573, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 34, + ProductName: "Sasquatch Ale", + UnitPrice: 14.0000, + Quantity: 40, + Discontinued: false, + ExtendedPrice: 560.0000, + Freight: 84.8400 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Robert King", + OrderID: 10573, + OrderDate: new Date("11/23/2016"), + ShipperName: "Federal Shipping", + ProductID: 53, + ProductName: "Perth Pasties", + UnitPrice: 32.8000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 820.0000, + Freight: 84.8400 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10682, + OrderDate: new Date("11/23/2016"), + ShipperName: "United Package", + ProductID: 33, + ProductName: "Geitost", + UnitPrice: 2.5000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 75.0000, + Freight: 36.1300 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10682, + OrderDate: new Date("11/23/2016"), + ShipperName: "United Package", + ProductID: 66, + ProductName: "Louisiana Hot Spiced Okra", + UnitPrice: 17.0000, + Quantity: 4, + Discontinued: false, + ExtendedPrice: 68.0000, + Freight: 36.1300 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10682, + OrderDate: new Date("11/23/2016"), + ShipperName: "United Package", + ProductID: 75, + ProductName: "Rh\u00f6nbr\u00e4u Klosterbier", + UnitPrice: 7.7500, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 232.5000, + Freight: 36.1300 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10856, + OrderDate: new Date("11/23/2016"), + ShipperName: "United Package", + ProductID: 2, + ProductName: "Chang", + UnitPrice: 19.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 380.0000, + Freight: 58.4300 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Janet Leverling", + OrderID: 10856, + OrderDate: new Date("11/23/2016"), + ShipperName: "United Package", + ProductID: 42, + ProductName: "Singaporean Hokkien Fried Mee", + UnitPrice: 14.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 280.0000, + Freight: 58.4300 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10535, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 11, + ProductName: "Queso Cabrales", + UnitPrice: 21.0000, + Quantity: 50, + Discontinued: true, + ExtendedPrice: 945.0000, + Freight: 15.6400 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10535, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 40, + ProductName: "Boston Crab Meat", + UnitPrice: 18.4000, + Quantity: 10, + Discontinued: true, + ExtendedPrice: 165.6000, + Freight: 15.6400 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10535, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 57, + ProductName: "Ravioli Angelo", + UnitPrice: 19.5000, + Quantity: 5, + Discontinued: true, + ExtendedPrice: 87.7500, + Freight: 15.6400 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10535, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 59, + ProductName: "Raclette Courdavault", + UnitPrice: 55.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 742.5000, + Freight: 15.6400 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Robert King", + OrderID: 10507, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 43, + ProductName: "Ipoh Coffee", + UnitPrice: 46.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 586.5000, + Freight: 47.4500 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Robert King", + OrderID: 10507, + OrderDate: new Date("11/23/2016"), + ShipperName: "Speedy Express", + ProductID: 48, + ProductName: "Chocolade", + UnitPrice: 12.7500, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 162.5600, + Freight: 47.4500 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Nancy Davolio", + OrderID: 10677, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 26, + ProductName: "Gumb\u00e4r Gummib\u00e4rchen", + UnitPrice: 31.2300, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 796.3700, + Freight: 4.0300 +}, { + ShipName: "Antonio Moreno Taquer\u00eda", + ShipAddress: "Mataderos 2312", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05023", + ShipCountry: "Mexico", + CustomerID: "ANTON", + CustomerName: "Antonio Moreno Taquer\u00eda", + Address: "Mataderos 2312", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05023", + Country: "Mexico", + Salesperson: "Nancy Davolio", + OrderID: 10677, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 33, + ProductName: "Geitost", + UnitPrice: 2.5000, + Quantity: 8, + Discontinued: false, + ExtendedPrice: 17.0000, + Freight: 4.0300 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Michael Suyama", + OrderID: 10355, + OrderDate: new Date("10/4/2017"), + ShipperName: "Speedy Express", + ProductID: 24, + ProductName: "Guaran\u00e1 Fant\u00e1stica", + UnitPrice: 3.6000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 90.0000, + Freight: 41.9500 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Michael Suyama", + OrderID: 10355, + OrderDate: new Date("10/4/2017"), + ShipperName: "Speedy Express", + ProductID: 57, + ProductName: "Ravioli Angelo", + UnitPrice: 15.6000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 390.0000, + Freight: 41.9500 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 10383, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 13, + ProductName: "Konbu", + UnitPrice: 4.8000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 96.0000, + Freight: 34.2400 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 10383, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 50, + ProductName: "Valkoinen suklaa", + UnitPrice: 13.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 195.0000, + Freight: 34.2400 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 10383, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 56, + ProductName: "Gnocchi di nonna Alice", + UnitPrice: 30.4000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 608.0000, + Freight: 34.2400 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10558, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 47, + ProductName: "Zaanse koeken", + UnitPrice: 9.5000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 237.5000, + Freight: 72.9700 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10558, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 51, + ProductName: "Manjimup Dried Apples", + UnitPrice: 53.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 1060.0000, + Freight: 72.9700 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10558, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 52, + ProductName: "Filo Mix", + UnitPrice: 7.0000, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 210.0000, + Freight: 72.9700 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10558, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 53, + ProductName: "Perth Pasties", + UnitPrice: 32.8000, + Quantity: 18, + Discontinued: false, + ExtendedPrice: 590.4000, + Freight: 72.9700 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10558, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 73, + ProductName: "R\u00f6d Kaviar", + UnitPrice: 15.0000, + Quantity: 3, + Discontinued: false, + ExtendedPrice: 45.0000, + Freight: 72.9700 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 10707, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 55, + ProductName: "P\u00e2t\u00e9 chinois", + UnitPrice: 24.0000, + Quantity: 21, + Discontinued: false, + ExtendedPrice: 504.0000, + Freight: 21.7400 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 10707, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 57, + ProductName: "Ravioli Angelo", + UnitPrice: 19.5000, + Quantity: 40, + Discontinued: false, + ExtendedPrice: 780.0000, + Freight: 21.7400 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10768, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 22, + ProductName: "Gustaf's Kn\u00e4ckebr\u00f6d", + UnitPrice: 21.0000, + Quantity: 4, + Discontinued: false, + ExtendedPrice: 84.0000, + Freight: 146.3200 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10768, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 31, + ProductName: "Gorgonzola Telino", + UnitPrice: 12.5000, + Quantity: 50, + Discontinued: true, + ExtendedPrice: 625.0000, + Freight: 146.3200 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10768, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 60, + ProductName: "Camembert Pierrot", + UnitPrice: 34.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 510.0000, + Freight: 146.3200 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10768, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 71, + ProductName: "Flotemysost", + UnitPrice: 21.5000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 258.0000, + Freight: 146.3200 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10793, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 41, + ProductName: "Jack's New England Clam Chowder", + UnitPrice: 9.6500, + Quantity: 14, + Discontinued: false, + ExtendedPrice: 135.1000, + Freight: 4.5200 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10793, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 52, + ProductName: "Filo Mix", + UnitPrice: 7.0000, + Quantity: 8, + Discontinued: false, + ExtendedPrice: 56.0000, + Freight: 4.5200 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 10864, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 35, + ProductName: "Steeleye Stout", + UnitPrice: 18.0000, + Quantity: 4, + Discontinued: false, + ExtendedPrice: 72.0000, + Freight: 3.0400 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 10864, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 67, + ProductName: "Laughing Lumberjack Lager", + UnitPrice: 14.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 210.0000, + Freight: 3.0400 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 10920, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 50, + ProductName: "Valkoinen suklaa", + UnitPrice: 16.2500, + Quantity: 24, + Discontinued: false, + ExtendedPrice: 390.0000, + Freight: 29.6100 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Anne Dodsworth", + OrderID: 11016, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 31, + ProductName: "Gorgonzola Telino", + UnitPrice: 12.5000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 187.5000, + Freight: 33.8000 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Anne Dodsworth", + OrderID: 11016, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 36, + ProductName: "Inlagd Sill", + UnitPrice: 19.0000, + Quantity: 16, + Discontinued: false, + ExtendedPrice: 304.0000, + Freight: 33.8000 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10743, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 46, + ProductName: "Spegesild", + UnitPrice: 12.0000, + Quantity: 28, + Discontinued: false, + ExtendedPrice: 319.2000, + Freight: 23.7200 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Anne Dodsworth", + OrderID: 10953, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 20, + ProductName: "Sir Rodney's Marmalade", + UnitPrice: 81.0000, + Quantity: 50, + Discontinued: false, + ExtendedPrice: 3847.5000, + Freight: 23.7200 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Anne Dodsworth", + OrderID: 10953, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 31, + ProductName: "Gorgonzola Telino", + UnitPrice: 12.5000, + Quantity: 50, + Discontinued: false, + ExtendedPrice: 593.7500, + Freight: 23.7200 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10453, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 48, + ProductName: "Chocolade", + UnitPrice: 10.2000, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 137.7000, + Freight: 25.3600 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10453, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 70, + ProductName: "Outback Lager", + UnitPrice: 12.0000, + Quantity: 25, + Discontinued: true, + ExtendedPrice: 270.0000, + Freight: 25.3600 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 10707, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 70, + ProductName: "Outback Lager", + UnitPrice: 15.0000, + Quantity: 28, + Discontinued: false, + ExtendedPrice: 357.0000, + Freight: 21.7400 +}, { + ShipName: "Around the Horn", + ShipAddress: "Brook Farm Stratford St. Mary", + ShipCity: "Colchester", + ShipRegion: "Essex", + ShipPostalCode: "CO7 6JX", + ShipCountry: "UK", + CustomerID: "AROUT", + CustomerName: "Around the Horn", + Address: "120 Hanover Sq.", + City: "London", + Region: null, + PostalCode: "WA1 1DP", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 10741, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 2, + ProductName: "Chang", + UnitPrice: 19.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 228.0000, + Freight: 10.9600 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Laura Callahan", + OrderID: 10278, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 44, + ProductName: "Gula Malacca", + UnitPrice: 15.5000, + Quantity: 16, + Discontinued: false, + ExtendedPrice: 248.0000, + Freight: 92.6900 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Laura Callahan", + OrderID: 10278, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 59, + ProductName: "Raclette Courdavault", + UnitPrice: 44.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 660.0000, + Freight: 92.6900 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Laura Callahan", + OrderID: 10278, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 63, + ProductName: "Vegie-spread", + UnitPrice: 35.1000, + Quantity: 8, + Discontinued: false, + ExtendedPrice: 280.8000, + Freight: 92.6900 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Laura Callahan", + OrderID: 10278, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 73, + ProductName: "R\u00f6d Kaviar", + UnitPrice: 12.0000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 300.0000, + Freight: 92.6900 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Andrew Fuller", + OrderID: 10280, + OrderDate: new Date("10/4/2017"), + ShipperName: "Speedy Express", + ProductID: 24, + ProductName: "Guaran\u00e1 Fant\u00e1stica", + UnitPrice: 3.6000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 43.2000, + Freight: 8.9800 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Andrew Fuller", + OrderID: 10280, + OrderDate: new Date("10/4/2017"), + ShipperName: "Speedy Express", + ProductID: 55, + ProductName: "P\u00e2t\u00e9 chinois", + UnitPrice: 19.2000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 384.0000, + Freight: 8.9800 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Andrew Fuller", + OrderID: 10280, + OrderDate: new Date("10/4/2017"), + ShipperName: "Speedy Express", + ProductID: 75, + ProductName: "Rh\u00f6nbr\u00e4u Klosterbier", + UnitPrice: 6.2000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 186.0000, + Freight: 8.9800 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10384, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 20, + ProductName: "Sir Rodney's Marmalade", + UnitPrice: 64.8000, + Quantity: 28, + Discontinued: false, + ExtendedPrice: 1814.4000, + Freight: 168.6400 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10384, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 60, + ProductName: "Camembert Pierrot", + UnitPrice: 27.2000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 408.0000, + Freight: 168.6400 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10444, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 17, + ProductName: "Alice Mutton", + UnitPrice: 31.2000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 312.0000, + Freight: 3.5000 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10444, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 26, + ProductName: "Gumb\u00e4r Gummib\u00e4rchen", + UnitPrice: 24.9000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 373.5000, + Freight: 3.5000 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10444, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 35, + ProductName: "Steeleye Stout", + UnitPrice: 14.4000, + Quantity: 8, + Discontinued: false, + ExtendedPrice: 115.2000, + Freight: 3.5000 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10444, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 41, + ProductName: "Jack's New England Clam Chowder", + UnitPrice: 7.7000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 231.0000, + Freight: 3.5000 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Nancy Davolio", + OrderID: 10524, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 54, + ProductName: "Tourti\u00e8re", + UnitPrice: 7.4500, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 111.7500, + Freight: 244.7900 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10572, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 40, + ProductName: "Boston Crab Meat", + UnitPrice: 18.4000, + Quantity: 50, + Discontinued: false, + ExtendedPrice: 920.0000, + Freight: 116.4300 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Nancy Davolio", + OrderID: 10626, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 53, + ProductName: "Perth Pasties", + UnitPrice: 32.8000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 393.6000, + Freight: 138.6900 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Nancy Davolio", + OrderID: 10626, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 60, + ProductName: "Camembert Pierrot", + UnitPrice: 34.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 680.0000, + Freight: 138.6900 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Nancy Davolio", + OrderID: 10626, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 71, + ProductName: "Flotemysost", + UnitPrice: 21.5000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 430.0000, + Freight: 138.6900 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Anne Dodsworth", + OrderID: 10672, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 71, + ProductName: "Flotemysost", + UnitPrice: 21.5000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 258.0000, + Freight: 95.7500 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Nancy Davolio", + OrderID: 10733, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 14, + ProductName: "Tofu", + UnitPrice: 23.2500, + Quantity: 16, + Discontinued: false, + ExtendedPrice: 372.0000, + Freight: 110.1100 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Nancy Davolio", + OrderID: 10733, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 28, + ProductName: "R\u00f6ssle Sauerkraut", + UnitPrice: 45.6000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 912.0000, + Freight: 110.1100 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Nancy Davolio", + OrderID: 10733, + OrderDate: new Date("10/4/2017"), + ShipperName: "Federal Shipping", + ProductID: 52, + ProductName: "Filo Mix", + UnitPrice: 7.0000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 175.0000, + Freight: 110.1100 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10778, + OrderDate: new Date("10/4/2017"), + ShipperName: "Speedy Express", + ProductID: 41, + ProductName: "Jack's New England Clam Chowder", + UnitPrice: 9.6500, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 96.5000, + Freight: 6.7900 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Anne Dodsworth", + OrderID: 10837, + OrderDate: new Date("10/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 13, + ProductName: "Konbu", + UnitPrice: 6.0000, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 36.0000, + Freight: 13.3200 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Anne Dodsworth", + OrderID: 10837, + OrderDate: new Date("10/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 40, + ProductName: "Boston Crab Meat", + UnitPrice: 18.4000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 460.0000, + Freight: 13.3200 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Laura Callahan", + OrderID: 10857, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 3, + ProductName: "Aniseed Syrup", + UnitPrice: 10.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 300.0000, + Freight: 188.8500 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Margaret Peacock", + OrderID: 10875, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 19, + ProductName: "Teatime Chocolate Biscuits", + UnitPrice: 9.2000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 230.0000, + Freight: 32.3700 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Margaret Peacock", + OrderID: 10875, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 49, + ProductName: "Maxilaku", + UnitPrice: 20.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 300.0000, + Freight: 32.3700 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10924, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 75, + ProductName: "Rh\u00f6nbr\u00e4u Klosterbier", + UnitPrice: 7.7500, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 46.5000, + Freight: 151.5200 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10572, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 16, + ProductName: "Pavlova", + UnitPrice: 17.4500, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 188.4600, + Freight: 116.4300 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10572, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 32, + ProductName: "Mascarpone Fabioli", + UnitPrice: 32.0000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 288.0000, + Freight: 116.4300 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10572, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 75, + ProductName: "Rh\u00f6nbr\u00e4u Klosterbier", + UnitPrice: 7.7500, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 104.6200, + Freight: 116.4300 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Steven Buchanan", + OrderID: 10654, + OrderDate: new Date("10/4/2018"), + ShipperName: "Speedy Express", + ProductID: 4, + ProductName: "Chef Anton's Cajun Seasoning", + UnitPrice: 22.0000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 237.6000, + Freight: 55.2600 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Steven Buchanan", + OrderID: 10654, + OrderDate: new Date("10/4/2018"), + ShipperName: "Speedy Express", + ProductID: 39, + ProductName: "Chartreuse verte", + UnitPrice: 18.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 324.0000, + Freight: 55.2600 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Steven Buchanan", + OrderID: 10654, + OrderDate: new Date("10/4/2018"), + ShipperName: "Speedy Express", + ProductID: 54, + ProductName: "Tourti\u00e8re", + UnitPrice: 7.4500, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 40.2300, + Freight: 55.2600 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Anne Dodsworth", + OrderID: 10672, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 38, + ProductName: "C\u00f4te de Blaye", + UnitPrice: 263.5000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 3557.2500, + Freight: 95.7500 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Margaret Peacock", + OrderID: 10875, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 47, + ProductName: "Zaanse koeken", + UnitPrice: 9.5000, + Quantity: 21, + Discontinued: false, + ExtendedPrice: 179.5500, + Freight: 32.3700 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10924, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 10, + ProductName: "Ikura", + UnitPrice: 31.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 558.0000, + Freight: 151.5200 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Janet Leverling", + OrderID: 10924, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 28, + ProductName: "R\u00f6ssle Sauerkraut", + UnitPrice: 45.6000, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 1231.2000, + Freight: 151.5200 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Nancy Davolio", + OrderID: 10689, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 1, + ProductName: "Chai", + UnitPrice: 18.0000, + Quantity: 35, + Discontinued: true, + ExtendedPrice: 472.5000, + Freight: 13.4200 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Anne Dodsworth", + OrderID: 10837, + OrderDate: new Date("10/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 47, + ProductName: "Zaanse koeken", + UnitPrice: 9.5000, + Quantity: 40, + Discontinued: true, + ExtendedPrice: 285.0000, + Freight: 13.3200 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Anne Dodsworth", + OrderID: 10837, + OrderDate: new Date("10/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 76, + ProductName: "Lakkalik\u00f6\u00f6ri", + UnitPrice: 18.0000, + Quantity: 21, + Discontinued: true, + ExtendedPrice: 283.5000, + Freight: 13.3200 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Laura Callahan", + OrderID: 10857, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 26, + ProductName: "Gumb\u00e4r Gummib\u00e4rchen", + UnitPrice: 31.2300, + Quantity: 35, + Discontinued: true, + ExtendedPrice: 819.7900, + Freight: 188.8500 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Laura Callahan", + OrderID: 10857, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 29, + ProductName: "Th\u00fcringer Rostbratwurst", + UnitPrice: 123.7900, + Quantity: 10, + Discontinued: true, + ExtendedPrice: 928.4300, + Freight: 188.8500 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Steven Buchanan", + OrderID: 10866, + OrderDate: new Date("10/4/2018"), + ShipperName: "Speedy Express", + ProductID: 2, + ProductName: "Chang", + UnitPrice: 19.0000, + Quantity: 21, + Discontinued: true, + ExtendedPrice: 299.2500, + Freight: 109.1100 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Steven Buchanan", + OrderID: 10866, + OrderDate: new Date("10/4/2018"), + ShipperName: "Speedy Express", + ProductID: 24, + ProductName: "Guaran\u00e1 Fant\u00e1stica", + UnitPrice: 4.5000, + Quantity: 6, + Discontinued: true, + ExtendedPrice: 20.2500, + Freight: 109.1100 +}, { + ShipName: "Berglunds snabbk\u00f6p", + ShipAddress: "Berguvsv\u00e4gen 8", + ShipCity: "Lule\u00e5", + ShipRegion: null, + ShipPostalCode: "S-958 22", + ShipCountry: "Sweden", + CustomerID: "BERGS", + CustomerName: "Berglunds snabbk\u00f6p", + Address: "Berguvsv\u00e4gen 8", + City: "Lule\u00e5", + Region: null, + PostalCode: "S-958 22", + Country: "Sweden", + Salesperson: "Steven Buchanan", + OrderID: 10866, + OrderDate: new Date("10/4/2018"), + ShipperName: "Speedy Express", + ProductID: 30, + ProductName: "Nord-Ost Matjeshering", + UnitPrice: 25.8900, + Quantity: 40, + Discontinued: false, + ExtendedPrice: 776.7000, + Freight: 109.1100 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Anne Dodsworth", + OrderID: 10501, + OrderDate: new Date("10/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 54, + ProductName: "Tourti\u00e8re", + UnitPrice: 7.4500, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 149.0000, + Freight: 8.8500 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10509, + OrderDate: new Date("10/4/2018"), + ShipperName: "Speedy Express", + ProductID: 28, + ProductName: "R\u00f6ssle Sauerkraut", + UnitPrice: 45.6000, + Quantity: 3, + Discontinued: false, + ExtendedPrice: 136.8000, + Freight: 0.1500 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Janet Leverling", + OrderID: 10582, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 57, + ProductName: "Ravioli Angelo", + UnitPrice: 19.5000, + Quantity: 4, + Discontinued: false, + ExtendedPrice: 78.0000, + Freight: 27.7100 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Janet Leverling", + OrderID: 10582, + OrderDate: new Date("10/4/2018"), + ShipperName: "United Package", + ProductID: 76, + ProductName: "Lakkalik\u00f6\u00f6ri", + UnitPrice: 18.0000, + Quantity: 14, + Discontinued: false, + ExtendedPrice: 252.0000, + Freight: 27.7100 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 10614, + OrderDate: new Date("10/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 11, + ProductName: "Queso Cabrales", + UnitPrice: 21.0000, + Quantity: 14, + Discontinued: false, + ExtendedPrice: 294.0000, + Freight: 1.9300 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 10614, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 21, + ProductName: "Sir Rodney's Scones", + UnitPrice: 10.0000, + Quantity: 8, + Discontinued: false, + ExtendedPrice: 80.0000, + Freight: 1.9300 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 10614, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 39, + ProductName: "Chartreuse verte", + UnitPrice: 18.0000, + Quantity: 5, + Discontinued: false, + ExtendedPrice: 90.0000, + Freight: 1.9300 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Anne Dodsworth", + OrderID: 10853, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 18, + ProductName: "Carnarvon Tigers", + UnitPrice: 62.5000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 625.0000, + Freight: 53.8300 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Michael Suyama", + OrderID: 10956, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 21, + ProductName: "Sir Rodney's Scones", + UnitPrice: 10.0000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 120.0000, + Freight: 44.6500 +}, { + ShipName: "Blauer See Delikatessen", + ShipAddress: "Forsterstr. 57", + ShipCity: "Mannheim", + ShipRegion: null, + ShipPostalCode: "68306", + ShipCountry: "Germany", + CustomerID: "BLAUS", + CustomerName: "Blauer See Delikatessen", + Address: "Forsterstr. 57", + City: "Mannheim", + Region: null, + PostalCode: "68306", + Country: "Germany", + Salesperson: "Michael Suyama", + OrderID: 10956, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 47, + ProductName: "Zaanse koeken", + UnitPrice: 9.5000, + Quantity: 14, + Discontinued: false, + ExtendedPrice: 133.0000, + Freight: 44.6500 +}, { + ShipName: "B\u00f3lido Comidas preparadas", + ShipAddress: "C/ Araquil, 67", + ShipCity: "Madrid", + ShipRegion: null, + ShipPostalCode: "28023", + ShipCountry: "Spain", + CustomerID: "BOLID", + CustomerName: "B\u00f3lido Comidas preparadas", + Address: "C/ Araquil, 67", + City: "Madrid", + Region: null, + PostalCode: "28023", + Country: "Spain", + Salesperson: "Margaret Peacock", + OrderID: 10326, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 75, + ProductName: "Rh\u00f6nbr\u00e4u Klosterbier", + UnitPrice: 6.2000, + Quantity: 50, + Discontinued: false, + ExtendedPrice: 310.0000, + Freight: 77.9200 +}, { + ShipName: "B\u00f3lido Comidas preparadas", + ShipAddress: "C/ Araquil, 67", + ShipCity: "Madrid", + ShipRegion: null, + ShipPostalCode: "28023", + ShipCountry: "Spain", + CustomerID: "BOLID", + CustomerName: "B\u00f3lido Comidas preparadas", + Address: "C/ Araquil, 67", + City: "Madrid", + Region: null, + PostalCode: "28023", + Country: "Spain", + Salesperson: "Anne Dodsworth", + OrderID: 10970, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 52, + ProductName: "Filo Mix", + UnitPrice: 7.0000, + Quantity: 40, + Discontinued: false, + ExtendedPrice: 224.0000, + Freight: 16.1600 +}, { + ShipName: "B\u00f3lido Comidas preparadas", + ShipAddress: "C/ Araquil, 67", + ShipCity: "Madrid", + ShipRegion: null, + ShipPostalCode: "28023", + ShipCountry: "Spain", + CustomerID: "BOLID", + CustomerName: "B\u00f3lido Comidas preparadas", + Address: "C/ Araquil, 67", + City: "Madrid", + Region: null, + PostalCode: "28023", + Country: "Spain", + Salesperson: "Margaret Peacock", + OrderID: 10801, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 17, + ProductName: "Alice Mutton", + UnitPrice: 39.0000, + Quantity: 40, + Discontinued: false, + ExtendedPrice: 1170.0000, + Freight: 97.0900 +}, { + ShipName: "B\u00f3lido Comidas preparadas", + ShipAddress: "C/ Araquil, 67", + ShipCity: "Madrid", + ShipRegion: null, + ShipPostalCode: "28023", + ShipCountry: "Spain", + CustomerID: "BOLID", + CustomerName: "B\u00f3lido Comidas preparadas", + Address: "C/ Araquil, 67", + City: "Madrid", + Region: null, + PostalCode: "28023", + Country: "Spain", + Salesperson: "Margaret Peacock", + OrderID: 10801, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 29, + ProductName: "Th\u00fcringer Rostbratwurst", + UnitPrice: 123.7900, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 1856.8500, + Freight: 97.0900 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Anne Dodsworth", + OrderID: 10331, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 54, + ProductName: "Tourti\u00e8re", + UnitPrice: 5.9000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 88.5000, + Freight: 10.1900 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Janet Leverling", + OrderID: 10362, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 25, + ProductName: "NuNuCa Nu\u00df-Nougat-Creme", + UnitPrice: 11.2000, + Quantity: 50, + Discontinued: false, + ExtendedPrice: 560.0000, + Freight: 96.0400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Janet Leverling", + OrderID: 10362, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 51, + ProductName: "Manjimup Dried Apples", + UnitPrice: 42.4000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 848.0000, + Freight: 96.0400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Janet Leverling", + OrderID: 10362, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 54, + ProductName: "Tourti\u00e8re", + UnitPrice: 5.9000, + Quantity: 24, + Discontinued: false, + ExtendedPrice: 141.6000, + Freight: 96.0400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10470, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 18, + ProductName: "Carnarvon Tigers", + UnitPrice: 50.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 1500.0000, + Freight: 64.5600 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10470, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 23, + ProductName: "Tunnbr\u00f6d", + UnitPrice: 7.2000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 108.0000, + Freight: 64.5600 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10470, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 64, + ProductName: "Wimmers gute Semmelkn\u00f6del", + UnitPrice: 26.6000, + Quantity: 8, + Discontinued: false, + ExtendedPrice: 212.8000, + Freight: 64.5600 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Nancy Davolio", + OrderID: 10525, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 36, + ProductName: "Inlagd Sill", + UnitPrice: 19.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 570.0000, + Freight: 11.0600 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Janet Leverling", + OrderID: 10715, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 10, + ProductName: "Ikura", + UnitPrice: 31.0000, + Quantity: 21, + Discontinued: false, + ExtendedPrice: 651.0000, + Freight: 63.2000 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Janet Leverling", + OrderID: 10715, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 71, + ProductName: "Flotemysost", + UnitPrice: 21.5000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 645.0000, + Freight: 63.2000 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Janet Leverling", + OrderID: 10732, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 76, + ProductName: "Lakkalik\u00f6\u00f6ri", + UnitPrice: 18.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 360.0000, + Freight: 16.9700 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Nancy Davolio", + OrderID: 10827, + OrderDate: new Date("10/4/2017"), + ShipperName: "United Package", + ProductID: 10, + ProductName: "Ikura", + UnitPrice: 31.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 465.0000, + Freight: 63.5400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Nancy Davolio", + OrderID: 10827, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 39, + ProductName: "Chartreuse verte", + UnitPrice: 18.0000, + Quantity: 21, + Discontinued: false, + ExtendedPrice: 378.0000, + Freight: 63.5400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Robert King", + OrderID: 10876, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 46, + ProductName: "Spegesild", + UnitPrice: 12.0000, + Quantity: 21, + Discontinued: false, + ExtendedPrice: 252.0000, + Freight: 60.4200 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Robert King", + OrderID: 10876, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 64, + ProductName: "Wimmers gute Semmelkn\u00f6del", + UnitPrice: 33.2500, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 665.0000, + Freight: 60.4200 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Laura Callahan", + OrderID: 10932, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 72, + ProductName: "Mozzarella di Giovanni", + UnitPrice: 34.8000, + Quantity: 16, + Discontinued: false, + ExtendedPrice: 556.8000, + Freight: 134.6400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Laura Callahan", + OrderID: 10940, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 13, + ProductName: "Konbu", + UnitPrice: 6.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 120.0000, + Freight: 19.7700 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Nancy Davolio", + OrderID: 10340, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 18, + ProductName: "Carnarvon Tigers", + UnitPrice: 50.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 950.0000, + Freight: 166.3100 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Nancy Davolio", + OrderID: 10340, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 41, + ProductName: "Jack's New England Clam Chowder", + UnitPrice: 7.7000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 87.7800, + Freight: 166.3100 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Nancy Davolio", + OrderID: 10340, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 43, + ProductName: "Ipoh Coffee", + UnitPrice: 36.8000, + Quantity: 40, + Discontinued: false, + ExtendedPrice: 1398.4000, + Freight: 166.3100 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Andrew Fuller", + OrderID: 10663, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 40, + ProductName: "Boston Crab Meat", + UnitPrice: 18.4000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 524.4000, + Freight: 113.1500 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Andrew Fuller", + OrderID: 10663, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 42, + ProductName: "Singaporean Hokkien Fried Mee", + UnitPrice: 14.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 399.0000, + Freight: 113.1500 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Andrew Fuller", + OrderID: 10663, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 51, + ProductName: "Manjimup Dried Apples", + UnitPrice: 53.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 1007.0000, + Freight: 113.1500 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Steven Buchanan", + OrderID: 10730, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 16, + ProductName: "Pavlova", + UnitPrice: 17.4500, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 248.6600, + Freight: 20.1200 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Steven Buchanan", + OrderID: 10730, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 31, + ProductName: "Gorgonzola Telino", + UnitPrice: 12.5000, + Quantity: 3, + Discontinued: false, + ExtendedPrice: 35.6200, + Freight: 20.1200 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Steven Buchanan", + OrderID: 10730, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 65, + ProductName: "Louisiana Fiery Hot Pepper Sauce", + UnitPrice: 21.0500, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 199.9700, + Freight: 20.1200 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Anne Dodsworth", + OrderID: 10871, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 6, + ProductName: "Grandma's Boysenberry Spread", + UnitPrice: 25.0000, + Quantity: 50, + Discontinued: false, + ExtendedPrice: 1187.5000, + Freight: 112.2700 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Anne Dodsworth", + OrderID: 10871, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 16, + ProductName: "Pavlova", + UnitPrice: 17.4500, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 198.9300, + Freight: 112.2700 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Anne Dodsworth", + OrderID: 10871, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 17, + ProductName: "Alice Mutton", + UnitPrice: 39.0000, + Quantity: 16, + Discontinued: false, + ExtendedPrice: 592.8000, + Freight: 112.2700 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Nancy Davolio", + OrderID: 10525, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 40, + ProductName: "Boston Crab Meat", + UnitPrice: 18.4000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 248.4000, + Freight: 11.0600 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Laura Callahan", + OrderID: 10932, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 16, + ProductName: "Pavlova", + UnitPrice: 17.4500, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 471.1500, + Freight: 134.6400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Laura Callahan", + OrderID: 10932, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 62, + ProductName: "Tarte au sucre", + UnitPrice: 49.3000, + Quantity: 14, + Discontinued: true, + ExtendedPrice: 621.1800, + Freight: 134.6400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Laura Callahan", + OrderID: 10932, + OrderDate: new Date("10/4/2016"), + ShipperName: "Speedy Express", + ProductID: 75, + ProductName: "Rh\u00f6nbr\u00e4u Klosterbier", + UnitPrice: 7.7500, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 139.5000, + Freight: 134.6400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10511, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 4, + ProductName: "Chef Anton's Cajun Seasoning", + UnitPrice: 22.0000, + Quantity: 50, + Discontinued: true, + ExtendedPrice: 935.0000, + Freight: 350.6400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10511, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 7, + ProductName: "Uncle Bob's Organic Dried Pears", + UnitPrice: 30.0000, + Quantity: 50, + Discontinued: true, + ExtendedPrice: 1275.0000, + Freight: 350.6400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10511, + OrderDate: new Date("10/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 8, + ProductName: "Northwoods Cranberry Sauce", + UnitPrice: 40.0000, + Quantity: 10, + Discontinued: true, + ExtendedPrice: 340.0000, + Freight: 350.6400 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10755, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 47, + ProductName: "Zaanse koeken", + UnitPrice: 9.5000, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 213.7500, + Freight: 16.7100 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10755, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 56, + ProductName: "Gnocchi di nonna Alice", + UnitPrice: 38.0000, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 855.0000, + Freight: 16.7100 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10755, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 57, + ProductName: "Ravioli Angelo", + UnitPrice: 19.5000, + Quantity: 14, + Discontinued: true, + ExtendedPrice: 204.7500, + Freight: 16.7100 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 10755, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 69, + ProductName: "Gudbrandsdalsost", + UnitPrice: 36.0000, + Quantity: 25, + Discontinued: true, + ExtendedPrice: 675.0000, + Freight: 16.7100 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 11076, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 6, + ProductName: "Grandma's Boysenberry Spread", + UnitPrice: 25.0000, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 375.0000, + Freight: 38.2800 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 11076, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 14, + ProductName: "Tofu", + UnitPrice: 23.2500, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 348.7500, + Freight: 38.2800 +}, { + ShipName: "Bon app'", + ShipAddress: "12, rue des Bouchers", + ShipCity: "Marseille", + ShipRegion: null, + ShipPostalCode: "13008", + ShipCountry: "France", + CustomerID: "BONAP", + CustomerName: "Bon app'", + Address: "12, rue des Bouchers", + City: "Marseille", + Region: null, + PostalCode: "13008", + Country: "France", + Salesperson: "Margaret Peacock", + OrderID: 11076, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 19, + ProductName: "Teatime Chocolate Biscuits", + UnitPrice: 9.2000, + Quantity: 10, + Discontinued: true, + ExtendedPrice: 69.0000, + Freight: 38.2800 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Toronto Blvd.", + ShipCity: "Toronto", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Toronto Blvd.", + City: "Toronto", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Margaret Peacock", + OrderID: 10389, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 10, + ProductName: "Ikura", + UnitPrice: 24.8000, + Quantity: 16, + Discontinued: false, + ExtendedPrice: 396.8000, + Freight: 47.4200 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Toronto Blvd.", + ShipCity: "Toronto", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Toronto Blvd.", + City: "Toronto", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Margaret Peacock", + OrderID: 10389, + OrderDate: new Date("10/4/2016"), + ShipperName: "United Package", + ProductID: 55, + ProductName: "P\u00e2t\u00e9 chinois", + UnitPrice: 19.2000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 288.0000, + Freight: 47.4200 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Margaret Peacock", + OrderID: 10389, + OrderDate: new Date("9/4/2018"), + ShipperName: "United Package", + ProductID: 62, + ProductName: "Tarte au sucre", + UnitPrice: 39.4000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 788.0000, + Freight: 47.4200 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Margaret Peacock", + OrderID: 10389, + OrderDate: new Date("9/4/2018"), + ShipperName: "United Package", + ProductID: 70, + ProductName: "Outback Lager", + UnitPrice: 12.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 360.0000, + Freight: 47.4200 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Janet Leverling", + OrderID: 10410, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 33, + ProductName: "Geitost", + UnitPrice: 2.0000, + Quantity: 49, + Discontinued: false, + ExtendedPrice: 98.0000, + Freight: 2.4000 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Toronto Blvd.", + ShipCity: "Toronto", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Toronto Blvd.", + City: "Toronto", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Janet Leverling", + OrderID: 10410, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 59, + ProductName: "Raclette Courdavault", + UnitPrice: 44.0000, + Quantity: 16, + Discontinued: false, + ExtendedPrice: 704.0000, + Freight: 2.4000 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Janet Leverling", + OrderID: 10742, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 3, + ProductName: "Aniseed Syrup", + UnitPrice: 10.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 200.0000, + Freight: 243.7300 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Janet Leverling", + OrderID: 10742, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 60, + ProductName: "Camembert Pierrot", + UnitPrice: 34.0000, + Quantity: 50, + Discontinued: false, + ExtendedPrice: 1700.0000, + Freight: 243.7300 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Janet Leverling", + OrderID: 10742, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 72, + ProductName: "Mozzarella di Giovanni", + UnitPrice: 34.8000, + Quantity: 35, + Discontinued: false, + ExtendedPrice: 1218.0000, + Freight: 243.7300 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Toronto Blvd.", + ShipCity: "Toronto", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Toronto Blvd.", + City: "Toronto", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Michael Suyama", + OrderID: 10944, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 56, + ProductName: "Gnocchi di nonna Alice", + UnitPrice: 38.0000, + Quantity: 18, + Discontinued: false, + ExtendedPrice: 684.0000, + Freight: 52.9200 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Toronto Blvd.", + ShipCity: "Toronto", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Toronto Blvd.", + City: "Toronto", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Andrew Fuller", + OrderID: 10949, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 6, + ProductName: "Grandma's Boysenberry Spread", + UnitPrice: 25.0000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 300.0000, + Freight: 74.4400 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Andrew Fuller", + OrderID: 10949, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 10, + ProductName: "Ikura", + UnitPrice: 31.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 930.0000, + Freight: 74.4400 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Andrew Fuller", + OrderID: 10949, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 17, + ProductName: "Alice Mutton", + UnitPrice: 39.0000, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 234.0000, + Freight: 74.4400 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Andrew Fuller", + OrderID: 10949, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 62, + ProductName: "Tarte au sucre", + UnitPrice: 49.3000, + Quantity: 60, + Discontinued: false, + ExtendedPrice: 2958.0000, + Freight: 74.4400 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Nancy Davolio", + OrderID: 10975, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 8, + ProductName: "Northwoods Cranberry Sauce", + UnitPrice: 40.0000, + Quantity: 16, + Discontinued: false, + ExtendedPrice: 640.0000, + Freight: 32.2700 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Nancy Davolio", + OrderID: 10975, + OrderDate: new Date("9/4/2018"), + ShipperName: "Federal Shipping", + ProductID: 75, + ProductName: "Rh\u00f6nbr\u00e4u Klosterbier", + UnitPrice: 7.7500, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 77.5000, + Freight: 32.2700 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Toronto Blvd.", + ShipCity: "Toronto", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Toronto Blvd.", + City: "Toronto", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Anne Dodsworth", + OrderID: 10411, + OrderDate: new Date("5/12/2017"), + ShipperName: "Federal Shipping", + ProductID: 41, + ProductName: "Jack's New England Clam Chowder", + UnitPrice: 7.7000, + Quantity: 25, + Discontinued: true, + ExtendedPrice: 154.0000, + Freight: 23.6500 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Anne Dodsworth", + OrderID: 10411, + OrderDate: new Date("5/12/2017"), + ShipperName: "Federal Shipping", + ProductID: 44, + ProductName: "Gula Malacca", + UnitPrice: 15.5000, + Quantity: 40, + Discontinued: true, + ExtendedPrice: 496.0000, + Freight: 23.6500 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Anne Dodsworth", + OrderID: 10411, + OrderDate: new Date("5/12/2017"), + ShipperName: "Federal Shipping", + ProductID: 59, + ProductName: "Raclette Courdavault", + UnitPrice: 44.0000, + Quantity: 9, + Discontinued: true, + ExtendedPrice: 316.8000, + Freight: 23.6500 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Margaret Peacock", + OrderID: 10431, + OrderDate: new Date("5/12/2017"), + ShipperName: "United Package", + ProductID: 17, + ProductName: "Alice Mutton", + UnitPrice: 31.2000, + Quantity: 50, + Discontinued: true, + ExtendedPrice: 1170.0000, + Freight: 44.1700 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Margaret Peacock", + OrderID: 10431, + OrderDate: new Date("5/12/2017"), + ShipperName: "United Package", + ProductID: 40, + ProductName: "Boston Crab Meat", + UnitPrice: 14.7000, + Quantity: 50, + Discontinued: true, + ExtendedPrice: 551.2500, + Freight: 44.1700 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Margaret Peacock", + OrderID: 10431, + OrderDate: new Date("5/12/2017"), + ShipperName: "United Package", + ProductID: 47, + ProductName: "Zaanse koeken", + UnitPrice: 7.6000, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 171.0000, + Freight: 44.1700 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Janet Leverling", + OrderID: 10918, + OrderDate: new Date("5/12/2017"), + ShipperName: "Federal Shipping", + ProductID: 1, + ProductName: "Chai", + UnitPrice: 18.0000, + Quantity: 60, + Discontinued: true, + ExtendedPrice: 810.0000, + Freight: 48.8300 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Janet Leverling", + OrderID: 10918, + OrderDate: new Date("5/12/2017"), + ShipperName: "Federal Shipping", + ProductID: 60, + ProductName: "Camembert Pierrot", + UnitPrice: 34.0000, + Quantity: 25, + Discontinued: true, + ExtendedPrice: 637.5000, + Freight: 48.8300 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Michael Suyama", + OrderID: 10944, + OrderDate: new Date("5/12/2017"), + ShipperName: "Federal Shipping", + ProductID: 11, + ProductName: "Queso Cabrales", + UnitPrice: 21.0000, + Quantity: 5, + Discontinued: true, + ExtendedPrice: 78.7500, + Freight: 52.9200 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Michael Suyama", + OrderID: 10944, + OrderDate: new Date("5/12/2017"), + ShipperName: "Federal Shipping", + ProductID: 44, + ProductName: "Gula Malacca", + UnitPrice: 19.4500, + Quantity: 18, + Discontinued: true, + ExtendedPrice: 262.5800, + Freight: 52.9200 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Nancy Davolio", + OrderID: 11027, + OrderDate: new Date("5/12/2017"), + ShipperName: "Speedy Express", + ProductID: 24, + ProductName: "Guaran\u00e1 Fant\u00e1stica", + UnitPrice: 4.5000, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 101.2500, + Freight: 52.5200 +}, { + ShipName: "Bottom-Dollar Markets", + ShipAddress: "23 Tsawassen Blvd.", + ShipCity: "Tsawassen", + ShipRegion: "BC", + ShipPostalCode: "T2F 8M4", + ShipCountry: "Canada", + CustomerID: "BOTTM", + CustomerName: "Bottom-Dollar Markets", + Address: "23 Tsawassen Blvd.", + City: "Tsawassen", + Region: "BC", + PostalCode: "T2F 8M4", + Country: "Canada", + Salesperson: "Nancy Davolio", + OrderID: 11027, + OrderDate: new Date("5/12/2017"), + ShipperName: "Speedy Express", + ProductID: 62, + ProductName: "Tarte au sucre", + UnitPrice: 49.3000, + Quantity: 21, + Discontinued: true, + ExtendedPrice: 776.4800, + Freight: 52.5200 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Robert King", + OrderID: 10289, + OrderDate: new Date("5/12/2017"), + ShipperName: "Federal Shipping", + ProductID: 3, + ProductName: "Aniseed Syrup", + UnitPrice: 8.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 240.0000, + Freight: 22.7700 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Robert King", + OrderID: 10289, + OrderDate: new Date("5/12/2017"), + ShipperName: "Federal Shipping", + ProductID: 64, + ProductName: "Wimmers gute Semmelkn\u00f6del", + UnitPrice: 26.6000, + Quantity: 9, + Discontinued: false, + ExtendedPrice: 239.4000, + Freight: 22.7700 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Andrew Fuller", + OrderID: 10471, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 7, + ProductName: "Uncle Bob's Organic Dried Pears", + UnitPrice: 24.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 720.0000, + Freight: 45.5900 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Andrew Fuller", + OrderID: 10471, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 56, + ProductName: "Gnocchi di nonna Alice", + UnitPrice: 30.4000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 608.0000, + Freight: 45.5900 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10484, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 21, + ProductName: "Sir Rodney's Scones", + UnitPrice: 8.0000, + Quantity: 14, + Discontinued: false, + ExtendedPrice: 112.0000, + Freight: 6.8800 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10484, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 40, + ProductName: "Boston Crab Meat", + UnitPrice: 14.7000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 147.0000, + Freight: 6.8800 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10484, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 51, + ProductName: "Manjimup Dried Apples", + UnitPrice: 42.4000, + Quantity: 3, + Discontinued: false, + ExtendedPrice: 127.2000, + Freight: 6.8800 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Anne Dodsworth", + OrderID: 10538, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 70, + ProductName: "Outback Lager", + UnitPrice: 15.0000, + Quantity: 7, + Discontinued: false, + ExtendedPrice: 105.0000, + Freight: 4.8700 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Anne Dodsworth", + OrderID: 10538, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 72, + ProductName: "Mozzarella di Giovanni", + UnitPrice: 34.8000, + Quantity: 1, + Discontinued: false, + ExtendedPrice: 34.8000, + Freight: 4.8700 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Michael Suyama", + OrderID: 10539, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 13, + ProductName: "Konbu", + UnitPrice: 6.0000, + Quantity: 8, + Discontinued: false, + ExtendedPrice: 48.0000, + Freight: 12.3600 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Michael Suyama", + OrderID: 10539, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 21, + ProductName: "Sir Rodney's Scones", + UnitPrice: 10.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 150.0000, + Freight: 12.3600 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Michael Suyama", + OrderID: 10539, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 33, + ProductName: "Geitost", + UnitPrice: 2.5000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 37.5000, + Freight: 12.3600 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Michael Suyama", + OrderID: 10539, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 49, + ProductName: "Maxilaku", + UnitPrice: 20.0000, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 120.0000, + Freight: 12.3600 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Janet Leverling", + OrderID: 10947, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 59, + ProductName: "Raclette Courdavault", + UnitPrice: 55.0000, + Quantity: 4, + Discontinued: false, + ExtendedPrice: 220.0000, + Freight: 3.2600 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 11023, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 7, + ProductName: "Uncle Bob's Organic Dried Pears", + UnitPrice: 30.0000, + Quantity: 4, + Discontinued: false, + ExtendedPrice: 120.0000, + Freight: 123.8300 +}, { + ShipName: "B's Beverages", + ShipAddress: "Fauntleroy Circus", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "EC2 5NT", + ShipCountry: "UK", + CustomerID: "BSBEV", + CustomerName: "B's Beverages", + Address: "Fauntleroy Circus", + City: "London", + Region: null, + PostalCode: "EC2 5NT", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 11023, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 43, + ProductName: "Ipoh Coffee", + UnitPrice: 46.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 1380.0000, + Freight: 123.8300 +}, { + ShipName: "Cactus Comidas para llevar", + ShipAddress: "Cerrito 333", + ShipCity: "Buenos Aires", + ShipRegion: null, + ShipPostalCode: "1010", + ShipCountry: "Argentina", + CustomerID: "CACTU", + CustomerName: "Cactus Comidas para llevar", + Address: "Cerrito 333", + City: "Buenos Aires", + Region: null, + PostalCode: "1010", + Country: "Argentina", + Salesperson: "Laura Callahan", + OrderID: 10521, + OrderDate: new Date("4/14/2017"), + ShipperName: "United Package", + ProductID: 68, + ProductName: "Scottish Longbreads", + UnitPrice: 12.5000, + Quantity: 6, + Discontinued: true, + ExtendedPrice: 75.0000, + Freight: 17.2200 +}, { + ShipName: "Cactus Comidas para llevar", + ShipAddress: "Cerrito 333", + ShipCity: "Buenos Aires", + ShipRegion: null, + ShipPostalCode: "1010", + ShipCountry: "Argentina", + CustomerID: "CACTU", + CustomerName: "Cactus Comidas para llevar", + Address: "Cerrito 333", + City: "Buenos Aires", + Region: null, + PostalCode: "1010", + Country: "Argentina", + Salesperson: "Laura Callahan", + OrderID: 11054, + OrderDate: new Date("4/14/2017"), + ShipperName: "Speedy Express", + ProductID: 33, + ProductName: "Geitost", + UnitPrice: 2.5000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 25.0000, + Freight: 0.3300 +}, { + ShipName: "Cactus Comidas para llevar", + ShipAddress: "Cerrito 333", + ShipCity: "Buenos Aires", + ShipRegion: null, + ShipPostalCode: "1010", + ShipCountry: "Argentina", + CustomerID: "CACTU", + CustomerName: "Cactus Comidas para llevar", + Address: "Cerrito 333", + City: "Buenos Aires", + Region: null, + PostalCode: "1010", + Country: "Argentina", + Salesperson: "Laura Callahan", + OrderID: 11054, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 67, + ProductName: "Laughing Lumberjack Lager", + UnitPrice: 14.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 280.0000, + Freight: 0.3300 +}, { + ShipName: "Centro comercial Moctezuma", + ShipAddress: "Sierras de Granada 9993", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05022", + ShipCountry: "Mexico", + CustomerID: "CENTC", + CustomerName: "Centro comercial Moctezuma", + Address: "Sierras de Granada 9993", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05022", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10259, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 21, + ProductName: "Sir Rodney's Scones", + UnitPrice: 8.0000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 80.0000, + Freight: 3.2500 +}, { + ShipName: "Centro comercial Moctezuma", + ShipAddress: "Sierras de Granada 9993", + ShipCity: "M\u00e9xico D.F.", + ShipRegion: null, + ShipPostalCode: "05022", + ShipCountry: "Mexico", + CustomerID: "CENTC", + CustomerName: "Centro comercial Moctezuma", + Address: "Sierras de Granada 9993", + City: "M\u00e9xico D.F.", + Region: null, + PostalCode: "05022", + Country: "Mexico", + Salesperson: "Margaret Peacock", + OrderID: 10259, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 37, + ProductName: "Gravad lax", + UnitPrice: 20.8000, + Quantity: 1, + Discontinued: false, + ExtendedPrice: 20.8000, + Freight: 3.2500 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Steven Buchanan", + OrderID: 10254, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 74, + ProductName: "Longlife Tofu", + UnitPrice: 8.0000, + Quantity: 21, + Discontinued: false, + ExtendedPrice: 168.0000, + Freight: 22.9800 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Michael Suyama", + OrderID: 10370, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 64, + ProductName: "Wimmers gute Semmelkn\u00f6del", + UnitPrice: 26.6000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 798.0000, + Freight: 1.1700 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Michael Suyama", + OrderID: 10519, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 56, + ProductName: "Gnocchi di nonna Alice", + UnitPrice: 38.0000, + Quantity: 40, + Discontinued: false, + ExtendedPrice: 1520.0000, + Freight: 91.7600 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Nancy Davolio", + OrderID: 10746, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 13, + ProductName: "Konbu", + UnitPrice: 6.0000, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 36.0000, + Freight: 31.4300 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Nancy Davolio", + OrderID: 10746, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 42, + ProductName: "Singaporean Hokkien Fried Mee", + UnitPrice: 14.0000, + Quantity: 28, + Discontinued: false, + ExtendedPrice: 392.0000, + Freight: 31.4300 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Nancy Davolio", + OrderID: 10746, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 62, + ProductName: "Tarte au sucre", + UnitPrice: 49.3000, + Quantity: 9, + Discontinued: false, + ExtendedPrice: 443.7000, + Freight: 31.4300 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Nancy Davolio", + OrderID: 10746, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 69, + ProductName: "Gudbrandsdalsost", + UnitPrice: 36.0000, + Quantity: 40, + Discontinued: false, + ExtendedPrice: 1440.0000, + Freight: 31.4300 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Margaret Peacock", + OrderID: 10966, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 37, + ProductName: "Gravad lax", + UnitPrice: 26.0000, + Quantity: 8, + Discontinued: false, + ExtendedPrice: 208.0000, + Freight: 27.1900 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Margaret Peacock", + OrderID: 11029, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 56, + ProductName: "Gnocchi di nonna Alice", + UnitPrice: 38.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 760.0000, + Freight: 47.8400 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Margaret Peacock", + OrderID: 11029, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 63, + ProductName: "Vegie-spread", + UnitPrice: 43.9000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 526.8000, + Freight: 47.8400 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Janet Leverling", + OrderID: 11041, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 63, + ProductName: "Vegie-spread", + UnitPrice: 43.9000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 1317.0000, + Freight: 48.2200 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Michael Suyama", + OrderID: 10519, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 10, + ProductName: "Ikura", + UnitPrice: 31.0000, + Quantity: 16, + Discontinued: true, + ExtendedPrice: 471.2000, + Freight: 91.7600 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Michael Suyama", + OrderID: 10519, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 60, + ProductName: "Camembert Pierrot", + UnitPrice: 34.0000, + Quantity: 10, + Discontinued: true, + ExtendedPrice: 323.0000, + Freight: 91.7600 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Robert King", + OrderID: 10731, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 21, + ProductName: "Sir Rodney's Scones", + UnitPrice: 10.0000, + Quantity: 40, + Discontinued: true, + ExtendedPrice: 380.0000, + Freight: 96.6500 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Robert King", + OrderID: 10731, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 51, + ProductName: "Manjimup Dried Apples", + UnitPrice: 53.0000, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 1510.5000, + Freight: 96.6500 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Steven Buchanan", + OrderID: 10254, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 24, + ProductName: "Guaran\u00e1 Fant\u00e1stica", + UnitPrice: 3.6000, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 45.9000, + Freight: 22.9800 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Steven Buchanan", + OrderID: 10254, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 55, + ProductName: "P\u00e2t\u00e9 chinois", + UnitPrice: 19.2000, + Quantity: 21, + Discontinued: true, + ExtendedPrice: 342.7200, + Freight: 22.9800 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Michael Suyama", + OrderID: 10370, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 1, + ProductName: "Chai", + UnitPrice: 14.4000, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 183.6000, + Freight: 1.1700 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Michael Suyama", + OrderID: 10370, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 74, + ProductName: "Longlife Tofu", + UnitPrice: 8.0000, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 136.0000, + Freight: 1.1700 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Margaret Peacock", + OrderID: 10966, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 56, + ProductName: "Gnocchi di nonna Alice", + UnitPrice: 38.0000, + Quantity: 12, + Discontinued: true, + ExtendedPrice: 387.6000, + Freight: 27.1900 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Margaret Peacock", + OrderID: 10966, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 62, + ProductName: "Tarte au sucre", + UnitPrice: 49.3000, + Quantity: 12, + Discontinued: true, + ExtendedPrice: 502.8600, + Freight: 27.1900 +}, { + ShipName: "Chop-suey Chinese", + ShipAddress: "Hauptstr. 31", + ShipCity: "Bern", + ShipRegion: null, + ShipPostalCode: "3012", + ShipCountry: "Switzerland", + CustomerID: "CHOPS", + CustomerName: "Chop-suey Chinese", + Address: "Hauptstr. 29", + City: "Bern", + Region: null, + PostalCode: "3012", + Country: "Switzerland", + Salesperson: "Janet Leverling", + OrderID: 11041, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 2, + ProductName: "Chang", + UnitPrice: 19.0000, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 456.0000, + Freight: 48.2200 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Rio de Janeiro", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Rio de Janeiro", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Laura Callahan", + OrderID: 10290, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 5, + ProductName: "Chef Anton's Gumbo Mix", + UnitPrice: 17.0000, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 340.0000, + Freight: 79.7000 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Sao Paulo", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Sao Paulo", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Laura Callahan", + OrderID: 10290, + OrderDate: new Date("2/15/2017"), + ShipperName: "Speedy Express", + ProductID: 29, + ProductName: "Th\u00fcringer Rostbratwurst", + UnitPrice: 99.0000, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 1485.0000, + Freight: 79.7000 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Rio de Janeiro", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Rio de Janeiro", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Laura Callahan", + OrderID: 10290, + OrderDate: new Date("4/14/2017"), + ShipperName: "Speedy Express", + ProductID: 49, + ProductName: "Maxilaku", + UnitPrice: 16.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 240.0000, + Freight: 79.7000 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Sao Paulo", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Sao Paulo", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Laura Callahan", + OrderID: 10290, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 77, + ProductName: "Original Frankfurter gr\u00fcne So\u00dfe", + UnitPrice: 10.4000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 104.0000, + Freight: 79.7000 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Sao Paulo", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Sao Paulo", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Margaret Peacock", + OrderID: 10466, + OrderDate: new Date("5/4/2017"), + ShipperName: "Speedy Express", + ProductID: 11, + ProductName: "Queso Cabrales", + UnitPrice: 16.8000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 168.0000, + Freight: 11.9300 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Sao Paulo", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Sao Paulo", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Margaret Peacock", + OrderID: 10466, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 46, + ProductName: "Spegesild", + UnitPrice: 9.6000, + Quantity: 5, + Discontinued: false, + ExtendedPrice: 48.0000, + Freight: 11.9300 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Rio de Janeiro", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Rio de Janeiro", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Margaret Peacock", + OrderID: 10494, + OrderDate: new Date("4/1/2017"), + ShipperName: "United Package", + ProductID: 56, + ProductName: "Gnocchi di nonna Alice", + UnitPrice: 30.4000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 912.0000, + Freight: 65.9900 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Rio de Janeiro", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Rio de Janeiro", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Nancy Davolio", + OrderID: 10969, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 46, + ProductName: "Spegesild", + UnitPrice: 12.0000, + Quantity: 9, + Discontinued: false, + ExtendedPrice: 108.0000, + Freight: 0.2100 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Sao Paulo", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Sao Paulo", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Andrew Fuller", + OrderID: 11042, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 44, + ProductName: "Gula Malacca", + UnitPrice: 19.4500, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 291.7500, + Freight: 29.9900 +}, { + ShipName: "Com\u00e9rcio Mineiro", + ShipAddress: "Av. dos Lus\u00edadas, 23", + ShipCity: "Sao Paulo", + ShipRegion: "SP", + ShipPostalCode: "05432-043", + ShipCountry: "Brazil", + CustomerID: "COMMI", + CustomerName: "Com\u00e9rcio Mineiro", + Address: "Av. dos Lus\u00edadas, 23", + City: "Sao Paulo", + Region: "SP", + PostalCode: "05432-043", + Country: "Brazil", + Salesperson: "Andrew Fuller", + OrderID: 11042, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 61, + ProductName: "Sirop d'\u00e9rable", + UnitPrice: 28.5000, + Quantity: 4, + Discontinued: false, + ExtendedPrice: 114.0000, + Freight: 29.9900 +}, { + ShipName: "Consolidated Holdings", + ShipAddress: "Berkeley Gardens 12 Brewery", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX1 6LT", + ShipCountry: "UK", + CustomerID: "CONSH", + CustomerName: "Consolidated Holdings", + Address: "Berkeley Gardens 12 Brewery", + City: "London", + Region: null, + PostalCode: "WX1 6LT", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 10435, + OrderDate: new Date("4/16/2016"), + ShipperName: "United Package", + ProductID: 2, + ProductName: "Chang", + UnitPrice: 15.2000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 152.0000, + Freight: 9.2100 +}, { + ShipName: "Consolidated Holdings", + ShipAddress: "Berkeley Gardens 12 Brewery", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX1 6LT", + ShipCountry: "UK", + CustomerID: "CONSH", + CustomerName: "Consolidated Holdings", + Address: "Berkeley Gardens 12 Brewery", + City: "London", + Region: null, + PostalCode: "WX1 6LT", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 10435, + OrderDate: new Date("5/12/2016"), + ShipperName: "United Package", + ProductID: 22, + ProductName: "Gustaf's Kn\u00e4ckebr\u00f6d", + UnitPrice: 16.8000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 201.6000, + Freight: 9.2100 +}, { + ShipName: "Consolidated Holdings", + ShipAddress: "Berkeley Gardens 12 Brewery", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX1 6LT", + ShipCountry: "UK", + CustomerID: "CONSH", + CustomerName: "Consolidated Holdings", + Address: "Berkeley Gardens 12 Brewery", + City: "London", + Region: null, + PostalCode: "WX1 6LT", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 10435, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 72, + ProductName: "Mozzarella di Giovanni", + UnitPrice: 27.8000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 278.0000, + Freight: 9.2100 +}, { + ShipName: "Consolidated Holdings", + ShipAddress: "Berkeley Gardens 12 Brewery", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX1 6LT", + ShipCountry: "UK", + CustomerID: "CONSH", + CustomerName: "Consolidated Holdings", + Address: "Berkeley Gardens 12 Brewery", + City: "London", + Region: null, + PostalCode: "WX1 6LT", + Country: "UK", + Salesperson: "Andrew Fuller", + OrderID: 10462, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 13, + ProductName: "Konbu", + UnitPrice: 4.8000, + Quantity: 1, + Discontinued: false, + ExtendedPrice: 4.8000, + Freight: 6.1700 +}, { + ShipName: "Consolidated Holdings", + ShipAddress: "Berkeley Gardens 12 Brewery", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX1 6LT", + ShipCountry: "UK", + CustomerID: "CONSH", + CustomerName: "Consolidated Holdings", + Address: "Berkeley Gardens 12 Brewery", + City: "London", + Region: null, + PostalCode: "WX1 6LT", + Country: "UK", + Salesperson: "Andrew Fuller", + OrderID: 10462, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 23, + ProductName: "Tunnbr\u00f6d", + UnitPrice: 7.2000, + Quantity: 21, + Discontinued: false, + ExtendedPrice: 151.2000, + Freight: 6.1700 +}, { + ShipName: "Consolidated Holdings", + ShipAddress: "Berkeley Gardens 12 Brewery", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX1 6LT", + ShipCountry: "UK", + CustomerID: "CONSH", + CustomerName: "Consolidated Holdings", + Address: "Berkeley Gardens 12 Brewery", + City: "London", + Region: null, + PostalCode: "WX1 6LT", + Country: "UK", + Salesperson: "Robert King", + OrderID: 10848, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 5, + ProductName: "Chef Anton's Gumbo Mix", + UnitPrice: 21.3500, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 640.5000, + Freight: 38.2400 +}, { + ShipName: "Consolidated Holdings", + ShipAddress: "Berkeley Gardens 12 Brewery", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX1 6LT", + ShipCountry: "UK", + CustomerID: "CONSH", + CustomerName: "Consolidated Holdings", + Address: "Berkeley Gardens 12 Brewery", + City: "London", + Region: null, + PostalCode: "WX1 6LT", + Country: "UK", + Salesperson: "Robert King", + OrderID: 10848, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 9, + ProductName: "Mishi Kobe Niku", + UnitPrice: 97.0000, + Quantity: 3, + Discontinued: false, + ExtendedPrice: 291.0000, + Freight: 38.2400 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 10301, + OrderDate: new Date("4/14/2017"), + ShipperName: "United Package", + ProductID: 40, + ProductName: "Boston Crab Meat", + UnitPrice: 14.7000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 147.0000, + Freight: 45.0800 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 10301, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 56, + ProductName: "Gnocchi di nonna Alice", + UnitPrice: 30.4000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 608.0000, + Freight: 45.0800 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Andrew Fuller", + OrderID: 10312, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 28, + ProductName: "R\u00f6ssle Sauerkraut", + UnitPrice: 36.4000, + Quantity: 4, + Discontinued: false, + ExtendedPrice: 145.6000, + Freight: 40.2600 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Andrew Fuller", + OrderID: 10312, + OrderDate: new Date("4/14/2018"), + ShipperName: "United Package", + ProductID: 43, + ProductName: "Ipoh Coffee", + UnitPrice: 36.8000, + Quantity: 24, + Discontinued: false, + ExtendedPrice: 883.2000, + Freight: 40.2600 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Andrew Fuller", + OrderID: 10312, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 53, + ProductName: "Perth Pasties", + UnitPrice: 26.2000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 524.0000, + Freight: 40.2600 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Andrew Fuller", + OrderID: 10312, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 75, + ProductName: "Rh\u00f6nbr\u00e4u Klosterbier", + UnitPrice: 6.2000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 62.0000, + Freight: 40.2600 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10348, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 23, + ProductName: "Tunnbr\u00f6d", + UnitPrice: 7.2000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 180.0000, + Freight: 0.7800 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Michael Suyama", + OrderID: 10356, + OrderDate: new Date("4/14/2018"), + ShipperName: "United Package", + ProductID: 31, + ProductName: "Gorgonzola Telino", + UnitPrice: 10.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 300.0000, + Freight: 36.7100 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Michael Suyama", + OrderID: 10356, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 55, + ProductName: "P\u00e2t\u00e9 chinois", + UnitPrice: 19.2000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 230.4000, + Freight: 36.7100 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Michael Suyama", + OrderID: 10356, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 69, + ProductName: "Gudbrandsdalsost", + UnitPrice: 28.8000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 576.0000, + Freight: 36.7100 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 10632, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 2, + ProductName: "Chang", + UnitPrice: 19.0000, + Quantity: 30, + Discontinued: true, + ExtendedPrice: 541.5000, + Freight: 41.3800 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 10632, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 33, + ProductName: "Geitost", + UnitPrice: 2.5000, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 47.5000, + Freight: 41.3800 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 11046, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 12, + ProductName: "Queso Manchego La Pastora", + UnitPrice: 38.0000, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 722.0000, + Freight: 71.6400 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 11046, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 32, + ProductName: "Mascarpone Fabioli", + UnitPrice: 32.0000, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 456.0000, + Freight: 71.6400 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 11046, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 35, + ProductName: "Steeleye Stout", + UnitPrice: 18.0000, + Quantity: 18, + Discontinued: true, + ExtendedPrice: 307.8000, + Freight: 71.6400 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Nancy Davolio", + OrderID: 10668, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 31, + ProductName: "Gorgonzola Telino", + UnitPrice: 12.5000, + Quantity: 8, + Discontinued: true, + ExtendedPrice: 90.0000, + Freight: 47.2200 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Nancy Davolio", + OrderID: 10668, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 55, + ProductName: "P\u00e2t\u00e9 chinois", + UnitPrice: 24.0000, + Quantity: 4, + Discontinued: true, + ExtendedPrice: 86.4000, + Freight: 47.2200 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Nancy Davolio", + OrderID: 10668, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 64, + ProductName: "Wimmers gute Semmelkn\u00f6del", + UnitPrice: 33.2500, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 448.8700, + Freight: 47.2200 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10348, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 1, + ProductName: "Chai", + UnitPrice: 14.4000, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 183.6000, + Freight: 0.7800 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Robert King", + OrderID: 10513, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 21, + ProductName: "Sir Rodney's Scones", + UnitPrice: 10.0000, + Quantity: 40, + Discontinued: true, + ExtendedPrice: 320.0000, + Freight: 105.6500 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Robert King", + OrderID: 10513, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 32, + ProductName: "Mascarpone Fabioli", + UnitPrice: 32.0000, + Quantity: 50, + Discontinued: true, + ExtendedPrice: 1280.0000, + Freight: 105.6500 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Robert King", + OrderID: 10513, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 61, + ProductName: "Sirop d'\u00e9rable", + UnitPrice: 28.5000, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 342.0000, + Freight: 105.6500 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10640, + OrderDate: new Date("4/14/2017"), + ShipperName: "Speedy Express", + ProductID: 69, + ProductName: "Gudbrandsdalsost", + UnitPrice: 36.0000, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 540.0000, + Freight: 23.5500 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10640, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 70, + ProductName: "Outback Lager", + UnitPrice: 15.0000, + Quantity: 15, + Discontinued: true, + ExtendedPrice: 168.7500, + Freight: 23.5500 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 10651, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 19, + ProductName: "Teatime Chocolate Biscuits", + UnitPrice: 9.2000, + Quantity: 12, + Discontinued: true, + ExtendedPrice: 82.8000, + Freight: 20.6000 +}, { + ShipName: "Die Wandernde Kuh", + ShipAddress: "Adenauerallee 900", + ShipCity: "Stuttgart", + ShipRegion: null, + ShipPostalCode: "70563", + ShipCountry: "Germany", + CustomerID: "WANDK", + CustomerName: "Die Wandernde Kuh", + Address: "Adenauerallee 900", + City: "Stuttgart", + Region: null, + PostalCode: "70563", + Country: "Germany", + Salesperson: "Laura Callahan", + OrderID: 10651, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 22, + ProductName: "Gustaf's Kn\u00e4ckebr\u00f6d", + UnitPrice: 21.0000, + Quantity: 20, + Discontinued: true, + ExtendedPrice: 315.0000, + Freight: 20.6000 +}, { + ShipName: "Drachenblut Delikatessen", + ShipAddress: "Walserweg 21", + ShipCity: "Aachen", + ShipRegion: null, + ShipPostalCode: "52066", + ShipCountry: "Germany", + CustomerID: "DRACD", + CustomerName: "Drachenblut Delikatessen", + Address: "Walserweg 21", + City: "Aachen", + Region: null, + PostalCode: "52066", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10363, + OrderDate: new Date("4/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 31, + ProductName: "Gorgonzola Telino", + UnitPrice: 10.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 200.0000, + Freight: 30.5400 +}, { + ShipName: "Drachenblut Delikatessen", + ShipAddress: "Walserweg 21", + ShipCity: "Aachen", + ShipRegion: null, + ShipPostalCode: "52066", + ShipCountry: "Germany", + CustomerID: "DRACD", + CustomerName: "Drachenblut Delikatessen", + Address: "Walserweg 21", + City: "Aachen", + Region: null, + PostalCode: "52066", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10363, + OrderDate: new Date("12/4/2016"), + ShipperName: "Federal Shipping", + ProductID: 75, + ProductName: "Rh\u00f6nbr\u00e4u Klosterbier", + UnitPrice: 6.2000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 74.4000, + Freight: 30.5400 +}, { + ShipName: "Drachenblut Delikatessen", + ShipAddress: "Walserweg 21", + ShipCity: "Aachen", + ShipRegion: null, + ShipPostalCode: "52066", + ShipCountry: "Germany", + CustomerID: "DRACD", + CustomerName: "Drachenblut Delikatessen", + Address: "Walserweg 21", + City: "Aachen", + Region: null, + PostalCode: "52066", + Country: "Germany", + Salesperson: "Margaret Peacock", + OrderID: 10363, + OrderDate: new Date("2/8/2016"), + ShipperName: "Federal Shipping", + ProductID: 76, + ProductName: "Lakkalik\u00f6\u00f6ri", + UnitPrice: 14.4000, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 172.8000, + Freight: 30.5400 +}, { + ShipName: "Drachenblut Delikatessen", + ShipAddress: "Walserweg 21", + ShipCity: "Aachen", + ShipRegion: null, + ShipPostalCode: "52066", + ShipCountry: "Germany", + CustomerID: "DRACD", + CustomerName: "Drachenblut Delikatessen", + Address: "Walserweg 21", + City: "Aachen", + Region: null, + PostalCode: "52066", + Country: "Germany", + Salesperson: "Janet Leverling", + OrderID: 10391, + OrderDate: new Date("4/14/2017"), + ShipperName: "Federal Shipping", + ProductID: 13, + ProductName: "Konbu", + UnitPrice: 4.8000, + Quantity: 18, + Discontinued: false, + ExtendedPrice: 86.4000, + Freight: 5.4500 +}, { + ShipName: "Drachenblut Delikatessen", + ShipAddress: "Walserweg 21", + ShipCity: "Aachen", + ShipRegion: null, + ShipPostalCode: "52066", + ShipCountry: "Germany", + CustomerID: "DRACD", + CustomerName: "Drachenblut Delikatessen", + Address: "Walserweg 21", + City: "Aachen", + Region: null, + PostalCode: "52066", + Country: "Germany", + Salesperson: "Robert King", + OrderID: 10797, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 11, + ProductName: "Queso Cabrales", + UnitPrice: 21.0000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 420.0000, + Freight: 33.3500 +}, { + ShipName: "Drachenblut Delikatessen", + ShipAddress: "Walserweg 21", + ShipCity: "Aachen", + ShipRegion: null, + ShipPostalCode: "52066", + ShipCountry: "Germany", + CustomerID: "DRACD", + CustomerName: "Drachenblut Delikatessen", + Address: "Walserweg 21", + City: "Aachen", + Region: null, + PostalCode: "52066", + Country: "Germany", + Salesperson: "Nancy Davolio", + OrderID: 11067, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 41, + ProductName: "Jack's New England Clam Chowder", + UnitPrice: 9.6500, + Quantity: 9, + Discontinued: false, + ExtendedPrice: 86.8500, + Freight: 7.9800 +}, { + ShipName: "Du monde entier", + ShipAddress: "67, rue des Cinquante Otages", + ShipCity: "Nantes", + ShipRegion: null, + ShipPostalCode: "44000", + ShipCountry: "France", + CustomerID: "DUMON", + CustomerName: "Du monde entier", + Address: "67, rue des Cinquante Otages", + City: "Nantes", + Region: null, + PostalCode: "44000", + Country: "France", + Salesperson: "Nancy Davolio", + OrderID: 10311, + OrderDate: new Date("8/14/2016"), + ShipperName: "Federal Shipping", + ProductID: 42, + ProductName: "Singaporean Hokkien Fried Mee", + UnitPrice: 11.2000, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 67.2000, + Freight: 24.6900 +}, { + ShipName: "Du monde entier", + ShipAddress: "67, rue des Cinquante Otages", + ShipCity: "Nantes", + ShipRegion: null, + ShipPostalCode: "44000", + ShipCountry: "France", + CustomerID: "DUMON", + CustomerName: "Du monde entier", + Address: "67, rue des Cinquante Otages", + City: "Nantes", + Region: null, + PostalCode: "44000", + Country: "France", + Salesperson: "Nancy Davolio", + OrderID: 10311, + OrderDate: new Date("4/14/2017"), + ShipperName: "Federal Shipping", + ProductID: 69, + ProductName: "Gudbrandsdalsost", + UnitPrice: 28.8000, + Quantity: 7, + Discontinued: false, + ExtendedPrice: 201.6000, + Freight: 24.6900 +}, { + ShipName: "Du monde entier", + ShipAddress: "67, rue des Cinquante Otages", + ShipCity: "Nantes", + ShipRegion: null, + ShipPostalCode: "44000", + ShipCountry: "France", + CustomerID: "DUMON", + CustomerName: "Du monde entier", + Address: "67, rue des Cinquante Otages", + City: "Nantes", + Region: null, + PostalCode: "44000", + Country: "France", + Salesperson: "Robert King", + OrderID: 10609, + OrderDate: new Date("6/14/2016"), + ShipperName: "United Package", + ProductID: 1, + ProductName: "Chai", + UnitPrice: 18.0000, + Quantity: 3, + Discontinued: false, + ExtendedPrice: 54.0000, + Freight: 1.8500 +}, { + ShipName: "Du monde entier", + ShipAddress: "67, rue des Cinquante Otages", + ShipCity: "Nantes", + ShipRegion: null, + ShipPostalCode: "44000", + ShipCountry: "France", + CustomerID: "DUMON", + CustomerName: "Du monde entier", + Address: "67, rue des Cinquante Otages", + City: "Nantes", + Region: null, + PostalCode: "44000", + Country: "France", + Salesperson: "Robert King", + OrderID: 10609, + OrderDate: new Date("4/14/2015"), + ShipperName: "United Package", + ProductID: 10, + ProductName: "Ikura", + UnitPrice: 31.0000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 310.0000, + Freight: 1.8500 +}, { + ShipName: "Du monde entier", + ShipAddress: "67, rue des Cinquante Otages", + ShipCity: "Nantes", + ShipRegion: null, + ShipPostalCode: "44000", + ShipCountry: "France", + CustomerID: "DUMON", + CustomerName: "Du monde entier", + Address: "67, rue des Cinquante Otages", + City: "Nantes", + Region: null, + PostalCode: "44000", + Country: "France", + Salesperson: "Robert King", + OrderID: 10609, + OrderDate: new Date("4/14/2016"), + ShipperName: "United Package", + ProductID: 21, + ProductName: "Sir Rodney's Scones", + UnitPrice: 10.0000, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 60.0000, + Freight: 1.8500 +}, { + ShipName: "Du monde entier", + ShipAddress: "67, rue des Cinquante Otages", + ShipCity: "Nantes", + ShipRegion: null, + ShipPostalCode: "44000", + ShipCountry: "France", + CustomerID: "DUMON", + CustomerName: "Du monde entier", + Address: "67, rue des Cinquante Otages", + City: "Nantes", + Region: null, + PostalCode: "44000", + Country: "France", + Salesperson: "Andrew Fuller", + OrderID: 10683, + OrderDate: new Date("4/14/2017"), + ShipperName: "Speedy Express", + ProductID: 52, + ProductName: "Filo Mix", + UnitPrice: 7.0000, + Quantity: 9, + Discontinued: false, + ExtendedPrice: 63.0000, + Freight: 4.4000 +}, { + ShipName: "Du monde entier", + ShipAddress: "67, rue des Cinquante Otages", + ShipCity: "Nantes", + ShipRegion: null, + ShipPostalCode: "44000", + ShipCountry: "France", + CustomerID: "DUMON", + CustomerName: "Du monde entier", + Address: "67, rue des Cinquante Otages", + City: "Nantes", + Region: null, + PostalCode: "44000", + Country: "France", + Salesperson: "Robert King", + OrderID: 10890, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 17, + ProductName: "Alice Mutton", + UnitPrice: 39.0000, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 585.0000, + Freight: 32.7600 +}, { + ShipName: "Du monde entier", + ShipAddress: "67, rue des Cinquante Otages", + ShipCity: "Nantes", + ShipRegion: null, + ShipPostalCode: "44000", + ShipCountry: "France", + CustomerID: "DUMON", + CustomerName: "Du monde entier", + Address: "67, rue des Cinquante Otages", + City: "Nantes", + Region: null, + PostalCode: "44000", + Country: "France", + Salesperson: "Robert King", + OrderID: 10890, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 34, + ProductName: "Sasquatch Ale", + UnitPrice: 14.0000, + Quantity: 10, + Discontinued: false, + ExtendedPrice: 140.0000, + Freight: 32.7600 +}, { + ShipName: "Du monde entier", + ShipAddress: "67, rue des Cinquante Otages", + ShipCity: "Nantes", + ShipRegion: null, + ShipPostalCode: "44000", + ShipCountry: "France", + CustomerID: "DUMON", + CustomerName: "Du monde entier", + Address: "67, rue des Cinquante Otages", + City: "Nantes", + Region: null, + PostalCode: "44000", + Country: "France", + Salesperson: "Robert King", + OrderID: 10890, + OrderDate: new Date("2/14/2016"), + ShipperName: "Speedy Express", + ProductID: 41, + ProductName: "Jack's New England Clam Chowder", + UnitPrice: 9.6500, + Quantity: 14, + Discontinued: false, + ExtendedPrice: 135.1000, + Freight: 32.7600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10364, + OrderDate: new Date("4/14/2017"), + ShipperName: "Speedy Express", + ProductID: 69, + ProductName: "Gudbrandsdalsost", + UnitPrice: 28.8000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 864.0000, + Freight: 71.9700 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10364, + OrderDate: new Date("4/4/2016"), + ShipperName: "Speedy Express", + ProductID: 71, + ProductName: "Flotemysost", + UnitPrice: 17.2000, + Quantity: 5, + Discontinued: false, + ExtendedPrice: 86.0000, + Freight: 71.9700 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10400, + OrderDate: new Date("3/12/2016"), + ShipperName: "Federal Shipping", + ProductID: 29, + ProductName: "Th\u00fcringer Rostbratwurst", + UnitPrice: 99.0000, + Quantity: 21, + Discontinued: false, + ExtendedPrice: 2079.0000, + Freight: 83.9300 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10400, + OrderDate: new Date("4/14/2017"), + ShipperName: "Federal Shipping", + ProductID: 35, + ProductName: "Steeleye Stout", + UnitPrice: 14.4000, + Quantity: 35, + Discontinued: false, + ExtendedPrice: 504.0000, + Freight: 83.9300 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Nancy Davolio", + OrderID: 10400, + OrderDate: new Date("1/1/2017"), + ShipperName: "Federal Shipping", + ProductID: 49, + ProductName: "Maxilaku", + UnitPrice: 16.0000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 480.0000, + Freight: 83.9300 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Robert King", + OrderID: 10532, + OrderDate: new Date("4/22/2017"), + ShipperName: "Federal Shipping", + ProductID: 30, + ProductName: "Nord-Ost Matjeshering", + UnitPrice: 25.8900, + Quantity: 15, + Discontinued: false, + ExtendedPrice: 388.3500, + Freight: 74.4600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Robert King", + OrderID: 10532, + OrderDate: new Date("9/25/2017"), + ShipperName: "Federal Shipping", + ProductID: 66, + ProductName: "Louisiana Hot Spiced Okra", + UnitPrice: 17.0000, + Quantity: 24, + Discontinued: false, + ExtendedPrice: 408.0000, + Freight: 74.4600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 10726, + OrderDate: new Date("4/24/2015"), + ShipperName: "Speedy Express", + ProductID: 4, + ProductName: "Chef Anton's Cajun Seasoning", + UnitPrice: 22.0000, + Quantity: 25, + Discontinued: false, + ExtendedPrice: 550.0000, + Freight: 16.5600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 10726, + OrderDate: new Date("3/11/2017"), + ShipperName: "Speedy Express", + ProductID: 11, + ProductName: "Queso Cabrales", + UnitPrice: 21.0000, + Quantity: 5, + Discontinued: false, + ExtendedPrice: 105.0000, + Freight: 16.5600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 10987, + OrderDate: new Date("4/14/2016"), + ShipperName: "Speedy Express", + ProductID: 7, + ProductName: "Uncle Bob's Organic Dried Pears", + UnitPrice: 30.0000, + Quantity: 60, + Discontinued: false, + ExtendedPrice: 1800.0000, + Freight: 185.4800 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 10987, + OrderDate: new Date("1/24/2017"), + ShipperName: "Speedy Express", + ProductID: 43, + ProductName: "Ipoh Coffee", + UnitPrice: 46.0000, + Quantity: 6, + Discontinued: false, + ExtendedPrice: 276.0000, + Freight: 185.4800 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 10987, + OrderDate: new Date("3/31/2017"), + ShipperName: "Speedy Express", + ProductID: 72, + ProductName: "Mozzarella di Giovanni", + UnitPrice: 34.8000, + Quantity: 20, + Discontinued: false, + ExtendedPrice: 696.0000, + Freight: 185.4800 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 11024, + OrderDate: new Date("4/15/2018"), + ShipperName: "Speedy Express", + ProductID: 26, + ProductName: "Gumb\u00e4r Gummib\u00e4rchen", + UnitPrice: 31.2300, + Quantity: 12, + Discontinued: false, + ExtendedPrice: 374.7600, + Freight: 74.3600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 11024, + OrderDate: new Date("5/17/2017"), + ShipperName: "Speedy Express", + ProductID: 33, + ProductName: "Geitost", + UnitPrice: 2.5000, + Quantity: 30, + Discontinued: false, + ExtendedPrice: 75.0000, + Freight: 74.3600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 11024, + OrderDate: new Date("4/24/2016"), + ShipperName: "Speedy Express", + ProductID: 65, + ProductName: "Louisiana Fiery Hot Pepper Sauce", + UnitPrice: 21.0500, + Quantity: 21, + Discontinued: false, + ExtendedPrice: 442.0500, + Freight: 74.3600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Margaret Peacock", + OrderID: 11024, + OrderDate: new Date("6/24/2017"), + ShipperName: "Speedy Express", + ProductID: 71, + ProductName: "Flotemysost", + UnitPrice: 21.5000, + Quantity: 50, + Discontinued: false, + ExtendedPrice: 1075.0000, + Freight: 74.3600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 11056, + OrderDate: new Date("4/24/2017"), + ShipperName: "United Package", + ProductID: 7, + ProductName: "Uncle Bob's Organic Dried Pears", + UnitPrice: 30.0000, + Quantity: 40, + Discontinued: false, + ExtendedPrice: 1200.0000, + Freight: 278.9600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 11056, + OrderDate: new Date("3/24/2018"), + ShipperName: "United Package", + ProductID: 55, + ProductName: "P\u00e2t\u00e9 chinois", + UnitPrice: 24.0000, + Quantity: 35, + Discontinued: false, + ExtendedPrice: 840.0000, + Freight: 278.9600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Laura Callahan", + OrderID: 11056, + OrderDate: new Date("4/28/2018"), + ShipperName: "United Package", + ProductID: 60, + ProductName: "Camembert Pierrot", + UnitPrice: 34.0000, + Quantity: 50, + Discontinued: false, + ExtendedPrice: 1700.0000, + Freight: 278.9600 +}, { + ShipName: "Eastern Connection", + ShipAddress: "35 King George", + ShipCity: "London", + ShipRegion: null, + ShipPostalCode: "WX3 6FW", + ShipCountry: "UK", + CustomerID: "EASTC", + CustomerName: "Eastern Connection", + Address: "35 King George", + City: "London", + Region: null, + PostalCode: "WX3 6FW", + Country: "UK", + Salesperson: "Robert King", + OrderID: 11047, + OrderDate: new Date("4/24/2017"), + ShipperName: "Federal Shipping", + ProductID: 1, + ProductName: "Chai", + UnitPrice: 18.0000, + Quantity: 25, + Discontinued: true, + ExtendedPrice: 337.5000, + Freight: 46.6200 +}]; +/* tslint:enable */ From 5ab08a8872fc6200f65ebc7376082b2147fb17f7 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 11:56:34 +0300 Subject: [PATCH 55/99] chore(*): In case cell is merged cell placeholder, do not render content. --- .../src/lib/grids/cell.component.html | 2 ++ .../src/lib/grids/cell.component.ts | 13 +++++++++++ .../lib/grids/grid/grid-row.component.html | 23 +++++++++++-------- .../src/lib/grids/row.directive.ts | 2 +- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/cell.component.html b/projects/igniteui-angular/src/lib/grids/cell.component.html index b6f3f84656c..53095cebb22 100644 --- a/projects/igniteui-angular/src/lib/grids/cell.component.html +++ b/projects/igniteui-angular/src/lib/grids/cell.component.html @@ -57,6 +57,8 @@ } + + @if (column.dataType !== 'boolean' || (column.dataType === 'boolean' && this.formatter)) {
{ + if (this.isPlaceholder) { + return this.emptyCellTemplate; + } if (this.editMode && this.formGroup) { const inlineEditorTemplate = this.column.inlineEditorTemplate; return inlineEditorTemplate ? inlineEditorTemplate : this.inlineEditorTemplate; @@ -704,6 +714,9 @@ export class IgxGridCellComponent implements OnInit, OnChanges, OnDestroy, CellT @ViewChild('defaultCell', { read: TemplateRef, static: true }) protected defaultCellTemplate: TemplateRef; + @ViewChild('emptyCell', { read: TemplateRef, static: true }) + protected emptyCellTemplate: TemplateRef; + @ViewChild('defaultPinnedIndicator', { read: TemplateRef, static: true }) protected defaultPinnedIndicator: TemplateRef; diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index e8f2a00cab7..7c36e7d111a 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -28,11 +28,11 @@ @if (pinnedColumns.length > 0 && grid.isPinningToStart) { @for (col of pinnedColumns | igxNotGrouped; track trackPinnedColumn(col)) { @if (this.hasMergedCells) { -
@@ -44,11 +44,11 @@ } @if (this.hasMergedCells) { -
@@ -61,11 +61,11 @@ @if (pinnedColumns.length > 0 && !grid.isPinningToStart) { @for (col of pinnedColumns | igxNotGrouped; track trackPinnedColumn(col)) { @if (this.hasMergedCells) { -
@@ -135,6 +135,7 @@ [class.igx-grid__td--merged]="metaData?.cellMergeMeta.get(col.field)?.rowSpan > 1" [class.igx-grid__td--merged-selected]="isSelectionRoot(col)" [class.igx-grid__td--merged-hovered]="isHoveredRoot(col)" + [isPlaceholder]="!!this.metaData?.cellMergeMeta.get(col.field)?.root" class="igx-grid__td igx-grid__td--fw" [class.igx-grid__td--edited]="key | transactionState:col.field:grid.rowEditable:grid.transactions:grid.pipeTrigger:grid.gridAPI.crudService.cell:grid.gridAPI.crudService.row" [class.igx-grid__td--pinned]="col.pinned" @@ -170,6 +171,10 @@ 0; } /** From 229bcd149c7bb780b4cb189cd64a98cf29d71561 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 16:04:38 +0300 Subject: [PATCH 56/99] chore(*): Adjust indexes when there are pinned rows to top. --- .../src/lib/grids/grid-base.directive.ts | 5 +++-- .../src/lib/grids/grid/grid.component.html | 4 ++-- .../igniteui-angular/src/lib/grids/grid/grid.pipes.ts | 7 +++++-- .../hierarchical-grid/hierarchical-grid.component.html | 4 ++-- .../src/lib/grids/tree-grid/tree-grid.component.html | 4 ++-- src/app/grid-cellMerging/grid-cellMerging.component.html | 9 ++++++--- src/app/grid-cellMerging/grid-cellMerging.component.ts | 7 ++++--- 7 files changed, 24 insertions(+), 16 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 685cdba6c26..c16111026cb 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3697,7 +3697,7 @@ export abstract class IgxGridBaseDirective implements GridType, } protected getMergeCellOffset(rowData) { - const index = rowData.index; + const index = rowData.dataIndex; let offset = this.verticalScrollContainer.scrollPosition - this.verticalScrollContainer.getScrollForIndex(index); if (this.hasPinnedRecords && this.isRowPinningToTop) { offset -= this.pinnedRowHeight; @@ -3892,7 +3892,8 @@ export abstract class IgxGridBaseDirective implements GridType, if (rec.cellMergeMeta && // index + maxRowSpan is within view startIndex < (index + Math.max(...rec.cellMergeMeta.values().toArray().map(x => x.rowSpan)))) { - data.push({record: rec, index: index }); + const visibleIndex = this.isRowPinningToTop ? index + this.pinnedRecordsCount : index; + data.push({record: rec, index: visibleIndex, dataIndex: index }); } } this._mergedDataInView = data; diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index 4e86643da62..3e6df0c38e9 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -63,7 +63,7 @@ | gridRowPinning:id:true:pipeTrigger | gridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:id:pipeTrigger:filteringPipeTrigger:true | gridSort:sortingExpressions:groupingExpressions:sortStrategy:id:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:pipeTrigger; as pinnedData) { + | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData) { @if (pinnedData.length > 0) {
x - this.grid.pinnedRecordsCount); + } const result = DataUtil.merge(cloneArray(collection), colsToMerge, this.grid.mergeStrategy, activeRowIndexes, this.grid); return result; } diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html index 26e9b5efd5c..f097f45acf6 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html @@ -46,7 +46,7 @@ | gridRowPinning:id:true:pipeTrigger | gridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:id:pipeTrigger:filteringPipeTrigger:true | gridSort:sortingExpressions:[]:sortStrategy:id:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:pipeTrigger; as pinnedData + | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData ) { @if (pinnedData.length > 0) {
diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index 9e16f82611d..b77b0fa216c 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -47,7 +47,7 @@ | gridRowPinning:id:true:pipeTrigger | treeGridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:pipeTrigger:filteringPipeTrigger:true | treeGridSorting:sortingExpressions:treeGroupArea?.expressions:sortStrategy:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:pipeTrigger; as pinnedData + | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData ) { @if (pinnedData.length > 0) {
diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index bc53e1cb9fa..63c02663d1d 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -45,7 +45,10 @@

Grid with cell merge

- + + + Value: {{val}},Index: {{cell.row.index}} + @@ -69,9 +72,9 @@

Grid with cell merge

- + diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index d8cb3919a15..45130d806d2 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -38,8 +38,8 @@ import { INVOICE_DATA } from '../shared/invoiceData'; IgxColumnComponent, IgxGridComponent, IgxPaginatorComponent, - // IgxActionStripComponent, - // IgxGridPinningActionsComponent, + IgxActionStripComponent, + IgxGridPinningActionsComponent, IgxGridToolbarComponent, IgxGridToolbarActionsComponent, IgxGridToolbarPinningComponent, @@ -51,7 +51,8 @@ import { INVOICE_DATA } from '../shared/invoiceData'; IgxPrefixDirective, IgxSuffixDirective, IgxIconComponent, - IgxInputDirective + IgxInputDirective, + IgxCellTemplateDirective ] }) export class GridCellMergingComponent { From e64ee0199df612f5d2ae2cc093ec01b082a2626a Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 16:52:08 +0300 Subject: [PATCH 57/99] chore(*): Fix templates in hgrid and tgrid. --- .../hierarchical-row.component.html | 6 +++--- .../lib/grids/tree-grid/tree-grid-row.component.html | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html index 1f0b9e60507..913bf19f5a9 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html @@ -55,11 +55,11 @@ @if (this.hasMergedCells) { -
diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html index 16ccc6ae995..2832120f68c 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html @@ -24,11 +24,11 @@ } @if (this.hasMergedCells) { -
@@ -60,11 +60,11 @@ @for (col of pinnedColumns | igxNotGrouped; track trackPinnedColumn(col)) { @if (this.hasMergedCells) { -
From 238390f2abc2d738d95635aa75db24486d079985 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 17:07:18 +0300 Subject: [PATCH 58/99] chore(*): Update external merge container on data changing. --- .../src/lib/grids/grid-base.directive.ts | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index c16111026cb..a5f9995bd02 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3859,6 +3859,8 @@ export abstract class IgxGridBaseDirective implements GridType, $event.containerSize = this.calcHeight; } this.evaluateLoadingState(); + this.updateMergedData(); + this.cdr.detectChanges(); }); this.verticalScrollContainer.scrollbarVisibilityChanged.pipe(filter(() => !this._init), destructor).subscribe(() => { @@ -3882,22 +3884,7 @@ export abstract class IgxGridBaseDirective implements GridType, }); this.verticalScrollContainer.chunkPreload.pipe(filter(() => !this._init), destructor).subscribe(() => { - // recalc merged data - if (this.columnsToMerge.length > 0) { - const startIndex = this.verticalScrollContainer.state.startIndex; - const prevDataView = this.verticalScrollContainer.igxForOf?.slice(0, startIndex); - const data = []; - for (let index = 0; index < startIndex; index++) { - const rec = prevDataView[index]; - if (rec.cellMergeMeta && - // index + maxRowSpan is within view - startIndex < (index + Math.max(...rec.cellMergeMeta.values().toArray().map(x => x.rowSpan)))) { - const visibleIndex = this.isRowPinningToTop ? index + this.pinnedRecordsCount : index; - data.push({record: rec, index: visibleIndex, dataIndex: index }); - } - } - this._mergedDataInView = data; - } + this.updateMergedData(); }); // notifier for column autosize requests @@ -8137,4 +8124,25 @@ export abstract class IgxGridBaseDirective implements GridType, return recreateTreeFromFields(value, this._columns) as IFilteringExpressionsTree; } } + + private updateMergedData(){ + // recalc merged data + if (this.columnsToMerge.length > 0) { + const startIndex = this.verticalScrollContainer.state.startIndex; + const prevDataView = this.verticalScrollContainer.igxForOf?.slice(0, startIndex); + const data = []; + for (let index = 0; index < startIndex; index++) { + const rec = prevDataView[index]; + if (rec.cellMergeMeta && + // index + maxRowSpan is within view + startIndex < (index + Math.max(...rec.cellMergeMeta.values().toArray().map(x => x.rowSpan)))) { + const visibleIndex = this.isRowPinningToTop ? index + this.pinnedRecordsCount : index; + data.push({record: rec, index: visibleIndex, dataIndex: index }); + } + } + this._mergedDataInView = data; + console.log(data); + //this._activeRowIndexes = null; + } + } } From c071635c28f5d55bbab632aa84eaa1783e248405 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 17:25:28 +0300 Subject: [PATCH 59/99] chore(*): Adjust selection check for pinned row root. --- projects/igniteui-angular/src/lib/grids/grid-base.directive.ts | 2 -- projects/igniteui-angular/src/lib/grids/row.directive.ts | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index a5f9995bd02..d75a06544ef 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -8141,8 +8141,6 @@ export abstract class IgxGridBaseDirective implements GridType, } } this._mergedDataInView = data; - console.log(data); - //this._activeRowIndexes = null; } } } diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 1e6359b0433..45fa0c88cf7 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -626,7 +626,8 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { const mergeMeta = this.metaData?.cellMergeMeta; const rowCount = mergeMeta?.get(col.field)?.rowSpan; if (mergeMeta && rowCount > 1) { - const indexInData = this.pinned && this.grid.isRowPinningToTop ? this.index - this.grid.pinnedRecordsCount : this.index; + const isPinned = this.pinned && this.disabled; + const indexInData = isPinned && this.grid.isRowPinningToTop ? this.index - this.grid.pinnedRecordsCount : this.index; const range = this.grid.verticalScrollContainer.igxForOf.slice(indexInData, indexInData + rowCount); const inRange = range.filter(x => this.selectionService.isRowSelected(this.grid.primaryKey ? (x.recordRef || x)[this.grid.primaryKey] : (x.recordRef || x))).length > 0; return inRange; From 9f1ffd046da2d5fad7bb883454b6092742e7e7e7 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 17:34:46 +0300 Subject: [PATCH 60/99] chore(*): Fix more indexes due to row pinning. --- projects/igniteui-angular/src/lib/grids/row.directive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 45fa0c88cf7..fd86cd9a4d2 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -626,8 +626,8 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { const mergeMeta = this.metaData?.cellMergeMeta; const rowCount = mergeMeta?.get(col.field)?.rowSpan; if (mergeMeta && rowCount > 1) { - const isPinned = this.pinned && this.disabled; - const indexInData = isPinned && this.grid.isRowPinningToTop ? this.index - this.grid.pinnedRecordsCount : this.index; + const isPinned = this.pinned && !this.disabled; + const indexInData = this.grid.isRowPinningToTop && !isPinned ? this.index - this.grid.pinnedRecordsCount : this.index; const range = this.grid.verticalScrollContainer.igxForOf.slice(indexInData, indexInData + rowCount); const inRange = range.filter(x => this.selectionService.isRowSelected(this.grid.primaryKey ? (x.recordRef || x)[this.grid.primaryKey] : (x.recordRef || x))).length > 0; return inRange; From 0ae58e023ad0e92c01dae7e67c1582a85e88375c Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 17:56:15 +0300 Subject: [PATCH 61/99] chore(*): Fix tgrid check. --- .../lib/grids/tree-grid/tree-grid-row.component.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html index 2832120f68c..8208ac102ac 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html @@ -24,11 +24,11 @@ } @if (this.hasMergedCells) { -
@@ -60,11 +60,11 @@ @for (col of pinnedColumns | igxNotGrouped; track trackPinnedColumn(col)) { @if (this.hasMergedCells) { -
From 2267e03a87239d09d0f79ee27c7817a534684e01 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 18:02:31 +0300 Subject: [PATCH 62/99] chore(*): Clear active row indexes when selection is cleared. --- .../igniteui-angular/src/lib/grids/grid-base.directive.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index d75a06544ef..99f51a28e23 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -2926,6 +2926,7 @@ export abstract class IgxGridBaseDirective implements GridType, this._cellSelectionMode = selectionMode; // if (this.gridAPI.grid) { this.selectionService.clear(true); + this._activeRowIndexes = null; this.notifyChanges(); // } } @@ -4120,6 +4121,7 @@ export abstract class IgxGridBaseDirective implements GridType, .pipe(takeUntil(this.destroy$)) .subscribe(() => { this.selectionService.clear(true); + this._activeRowIndexes = null; this.crudService.endEdit(false); this.pipeTrigger++; this.navigateTo(0); @@ -4130,6 +4132,7 @@ export abstract class IgxGridBaseDirective implements GridType, .pipe(takeUntil(this.destroy$)) .subscribe(() => { this.selectionService.clear(true); + this._activeRowIndexes = null; this.page = 0; this.crudService.endEdit(false); this.notifyChanges(); @@ -5855,6 +5858,7 @@ export abstract class IgxGridBaseDirective implements GridType, */ public clearCellSelection(): void { this.selectionService.clear(true); + this._activeRowIndexes = null; this.notifyChanges(); } @@ -8114,6 +8118,7 @@ export abstract class IgxGridBaseDirective implements GridType, private clearActiveNode() { this.navigation.lastActiveNode = this.navigation.activeNode; this.navigation.activeNode = {} as IActiveNode; + this._activeRowIndexes = null; this.notifyChanges(); } From 6178a239c24e3dcf67fb0aecf160a64413ed2667 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 18:13:50 +0300 Subject: [PATCH 63/99] chore(*): Adjust selection check to use pinned view if row is pinned. --- projects/igniteui-angular/src/lib/grids/row.directive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index fd86cd9a4d2..4b9cf7df371 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -628,7 +628,7 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { if (mergeMeta && rowCount > 1) { const isPinned = this.pinned && !this.disabled; const indexInData = this.grid.isRowPinningToTop && !isPinned ? this.index - this.grid.pinnedRecordsCount : this.index; - const range = this.grid.verticalScrollContainer.igxForOf.slice(indexInData, indexInData + rowCount); + const range = isPinned ? this.grid.pinnedDataView.slice(indexInData, indexInData + rowCount) : this.grid.verticalScrollContainer.igxForOf.slice(indexInData, indexInData + rowCount); const inRange = range.filter(x => this.selectionService.isRowSelected(this.grid.primaryKey ? (x.recordRef || x)[this.grid.primaryKey] : (x.recordRef || x))).length > 0; return inRange; } From d044219e91ee57504edac746b377a72ad130a353 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 18:25:33 +0300 Subject: [PATCH 64/99] chore(*): Fix scrollbar disappearing on data changing. --- projects/igniteui-angular/src/lib/grids/grid-base.directive.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 99f51a28e23..67299234441 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3861,7 +3861,6 @@ export abstract class IgxGridBaseDirective implements GridType, } this.evaluateLoadingState(); this.updateMergedData(); - this.cdr.detectChanges(); }); this.verticalScrollContainer.scrollbarVisibilityChanged.pipe(filter(() => !this._init), destructor).subscribe(() => { From b8a77e2a0ab2ad001cdd8fe63e87b363a52944c1 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 28 Jul 2025 18:34:59 +0300 Subject: [PATCH 65/99] chore(*): Notify changes after merge data is updated. --- projects/igniteui-angular/src/lib/grids/grid-base.directive.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 67299234441..d862abbfed9 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -8145,6 +8145,7 @@ export abstract class IgxGridBaseDirective implements GridType, } } this._mergedDataInView = data; + this.notifyChanges(); } } } From 03511667adbe6e53e070b09f1433120747204fdc Mon Sep 17 00:00:00 2001 From: MKirova Date: Tue, 29 Jul 2025 11:29:27 +0300 Subject: [PATCH 66/99] chore(*): Apply review comments on styles. --- .../components/grid/_grid-component.scss | 3 ++ .../styles/components/grid/_grid-theme.scss | 34 +++---------------- 2 files changed, 7 insertions(+), 30 deletions(-) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss index 92ab66cf5c9..fa2c8d91ac0 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss @@ -300,6 +300,9 @@ @extend %igx-grid__td--merged !optional; } + @include e(td, $mods: (merged-selected, merged-hovered)) { + @extend %igx-grid__td--merged-selected-hovered !optional; + } @include e(td, $m: merged-selected) { @extend %igx-grid__td--merged-selected !optional; diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index 4537aa86c68..52fcb901fee 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1849,27 +1849,6 @@ color: var-get($theme, 'row-selected-text-color'); background: var-get($theme, 'row-selected-background'); - &%grid-row--mrl { - %grid-mrl-block { - %igx-grid__td--merged { - color: var-get($theme, 'row-selected-text-color'); - background: var-get($theme, 'row-selected-background'); - - &:hover { - background: var-get($theme, 'row-selected-hover-background'); - color: var-get($theme, 'row-selected-hover-text-color'); - } - - &%igx-grid__td--merged-hovered { - background: var-get($theme, 'row-selected-hover-background'); - color: var-get($theme, 'row-selected-hover-text-color'); - } - } - } - } - - - %grid-cell--selected, %grid-cell--pinned-selected { color: var-get($theme, 'cell-selected-within-text-color'); @@ -1925,21 +1904,16 @@ %igx-grid__td--merged-selected { color: var-get($theme, 'row-selected-text-color'); background: var-get($theme, 'row-selected-background'); - - &%igx-grid__td--merged-hovered { - background: var-get($theme, 'row-selected-hover-background'); - color: var-get($theme, 'row-selected-hover-text-color'); - } } %igx-grid__td--merged-hovered { background: var-get($theme, 'row-hover-background'); color: var-get($theme, 'row-hover-text-color'); + } - &%igx-grid__td--merged-selected { - background: var-get($theme, 'row-selected-hover-background'); - color: var-get($theme, 'row-selected-hover-text-color'); - } + %igx-grid__td--merged-selected-hovered { + background: var-get($theme, 'row-selected-hover-background'); + color: var-get($theme, 'row-selected-hover-text-color'); } %igx-grid__tr--deleted { From 9cc56b68f48c102f3e2a249df2db1eccfc0a84a1 Mon Sep 17 00:00:00 2001 From: MKirova Date: Tue, 29 Jul 2025 11:54:41 +0300 Subject: [PATCH 67/99] chore(*): Extract key from tree grid record. --- .../src/lib/grids/row.directive.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index 4b9cf7df371..cb7a50009b0 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -629,7 +629,7 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { const isPinned = this.pinned && !this.disabled; const indexInData = this.grid.isRowPinningToTop && !isPinned ? this.index - this.grid.pinnedRecordsCount : this.index; const range = isPinned ? this.grid.pinnedDataView.slice(indexInData, indexInData + rowCount) : this.grid.verticalScrollContainer.igxForOf.slice(indexInData, indexInData + rowCount); - const inRange = range.filter(x => this.selectionService.isRowSelected(this.grid.primaryKey ? (x.recordRef || x)[this.grid.primaryKey] : (x.recordRef || x))).length > 0; + const inRange = range.filter(x => this.selectionService.isRowSelected(this.extractRecordKey(x))).length > 0; return inRange; } return false; @@ -646,6 +646,18 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { return false; } + protected extractRecordKey(rec: any) { + let recData = rec; + if (this.grid.isRecordMerged(recData)) { + recData = rec.recordRef; + } + + if(this.grid.isTreeRow && this.grid.isTreeRow(recData)){ + recData = recData.data; + } + return this.grid.primaryKey ? recData[this.grid.primaryKey] : recData; + } + protected getRowHeight() { const indexInData = this.grid.verticalScrollContainer.igxForOf.indexOf(this.metaData); const size = this.grid.verticalScrollContainer.getSizeAt(indexInData) - 1; From cbf4a1c1ab37f129bc891aa4438af4af4ed31e1c Mon Sep 17 00:00:00 2001 From: MKirova Date: Tue, 29 Jul 2025 12:11:56 +0300 Subject: [PATCH 68/99] chore(*): Update merge indexes when in a paged grid context. --- .../igniteui-angular/src/lib/grids/grid-base.directive.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index d862abbfed9..da7ec8310f3 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -7985,7 +7985,12 @@ export abstract class IgxGridBaseDirective implements GridType, const searchText = caseSensitive ? this._lastSearchInfo.searchText : this._lastSearchInfo.searchText.toLowerCase(); let data = this.filteredSortedData; if (this.hasCellsToMerge) { - data = DataUtil.merge(cloneArray(this.filteredSortedData), this.columnsToMerge, this.mergeStrategy, this.activeRowIndexes, this); + let indexes = this.activeRowIndexes; + if (this.page > 0) { + indexes = indexes.map(x => this.perPage * this.page + x ); + } + + data = DataUtil.merge(cloneArray(this.filteredSortedData), this.columnsToMerge, this.mergeStrategy, indexes, this); } const columnItems = this.visibleColumns.filter((c) => !c.columnGroup).sort((c1, c2) => c1.visibleIndex - c2.visibleIndex); const columnsPathParts = columnItems.map(col => columnFieldPath(col.field)); From e4916812aaac1dd4e73804f1e5b6ecfdfea7d9d0 Mon Sep 17 00:00:00 2001 From: MKirova Date: Tue, 29 Jul 2025 13:05:06 +0300 Subject: [PATCH 69/99] chore(*): Update size if repaint was requested. Optimize a bit index calc. --- .../igniteui-angular/src/lib/grids/row.directive.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/row.directive.ts b/projects/igniteui-angular/src/lib/grids/row.directive.ts index cb7a50009b0..24d4b0989b7 100644 --- a/projects/igniteui-angular/src/lib/grids/row.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/row.directive.ts @@ -613,8 +613,8 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { protected getMergeCellSpan(col: ColumnType) { const rowCount = this.metaData.cellMergeMeta.get(col.field).rowSpan; let sizeSpans = ""; - const indexInData = this.pinned ? this.grid.getInitialPinnedIndex(this.data): - this.grid.verticalScrollContainer.igxForOf.indexOf(this.metaData); + const isPinned = this.pinned && !this.disabled; + const indexInData = this.grid.isRowPinningToTop && !isPinned ? this.index - this.grid.pinnedRecordsCount : this.index; for (let index = indexInData; index < indexInData + rowCount; index++) { const size = this.grid.verticalScrollContainer.getSizeAt(index); sizeSpans += size + 'px '; @@ -659,7 +659,12 @@ export class IgxRowDirective implements DoCheck, AfterViewInit, OnDestroy { } protected getRowHeight() { - const indexInData = this.grid.verticalScrollContainer.igxForOf.indexOf(this.metaData); + const isPinned = this.pinned && !this.disabled; + const indexInData = this.grid.isRowPinningToTop && !isPinned ? this.index - this.grid.pinnedRecordsCount : this.index; + if ((this.grid as any)._cdrRequests) { + // recalc size if repaint is requested. + this.grid.verticalScrollContainer.recalcUpdateSizes(); + } const size = this.grid.verticalScrollContainer.getSizeAt(indexInData) - 1; return size || this.grid.rowHeight; } From 5d2a5ce4947289df1ba657b22b60d81d13f63be5 Mon Sep 17 00:00:00 2001 From: MKirova Date: Tue, 29 Jul 2025 14:35:48 +0300 Subject: [PATCH 70/99] chore(*): Remove unnecessary inherit that overrides selection in pin area. --- .../src/lib/core/styles/components/grid/_grid-theme.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index 52fcb901fee..b0eeb099572 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1948,7 +1948,6 @@ %grid-cell--pinned { position: relative; - background: inherit; z-index: 9999; } From db23777b9333baeb337f0dd5bd4334d2b246a453 Mon Sep 17 00:00:00 2001 From: MKirova Date: Tue, 29 Jul 2025 16:20:00 +0300 Subject: [PATCH 71/99] chore(*): Fix timing issue between activation and drag selection. --- .../igniteui-angular/src/lib/grids/grid-base.directive.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index da7ec8310f3..d9138a16862 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3897,7 +3897,10 @@ export abstract class IgxGridBaseDirective implements GridType, this._firstAutoResize = false; }); - this.activeNodeChange.pipe(filter(() => !this._init), destructor).subscribe(() => { + this.activeNodeChange.pipe( + throttleTime(0, this.platform.isBrowser ? animationFrameScheduler : undefined, { leading: false, trailing: true }), + destructor + ).subscribe(() => { this._activeRowIndexes = null; if (this.hasCellsToMerge) { this.refreshSearch(); From 3f7c27e35890f10ae4741e66a922664dd8f5a5c2 Mon Sep 17 00:00:00 2001 From: MKirova Date: Tue, 29 Jul 2025 16:31:01 +0300 Subject: [PATCH 72/99] chore(*): Make activation and merge tests async. --- .../src/lib/grids/grid/cell-merge.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index 0493bdef316..963ddd7726c 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -414,7 +414,7 @@ describe('IgxGrid - Cell merging #grid', () => { describe('Activation', () => { - it('should interrupt merge sequence so that active row has no merging.', () => { + it('should interrupt merge sequence so that active row has no merging.', async() => { const col = grid.getColumnByName('ProductName'); GridFunctions.verifyColumnMergedState(grid, col, [ { value: 'Ignite UI for JavaScript', span: 2 }, @@ -428,6 +428,7 @@ describe('IgxGrid - Cell merging #grid', () => { const row1 = grid.rowList.toArray()[0]; UIInteractions.simulateClickAndSelectEvent(row1.cells.toArray()[1].nativeElement); + await wait(1); fix.detectChanges(); GridFunctions.verifyColumnMergedState(grid, col, [ @@ -451,7 +452,7 @@ describe('IgxGrid - Cell merging #grid', () => { fix.detectChanges(); }); - it('should edit the individual row values for the active row.', () => { + it('should edit the individual row values for the active row.', async() => { const col = grid.getColumnByName('ProductName'); grid.rowEditable = true; fix.detectChanges(); @@ -459,6 +460,7 @@ describe('IgxGrid - Cell merging #grid', () => { const row = grid.gridAPI.get_row_by_index(0); const cell = grid.gridAPI.get_cell_by_index(0, 'ProductName'); UIInteractions.simulateDoubleClickAndSelectEvent(cell.nativeElement); + await wait(1); fix.detectChanges(); expect(row.inEditMode).toBe(true); @@ -652,13 +654,14 @@ describe('IgxGrid - Cell merging #grid', () => { expect(activeHighlight[0].closest("igx-grid-cell")).toBe(cell0); }); - it('should update matches if a cell becomes unmerged.', () => { + it('should update matches if a cell becomes unmerged.', async() => { let matches = grid.findNext('JavaScript'); fix.detectChanges(); expect(matches).toBe(2); UIInteractions.simulateClickAndSelectEvent(grid.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement); + await wait(1); fix.detectChanges(); matches = grid.findNext('JavaScript'); From 2bbd9b82f023adb56bddeffe99f66a94814cb8e6 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 30 Jul 2025 13:03:54 +0300 Subject: [PATCH 73/99] chore(*): Approximate click position in merge cell to activate closest row. --- .../src/lib/grids/cell.component.ts | 18 ++++++++++++++++++ .../src/lib/grids/grid/grid-row.component.html | 2 ++ .../hierarchical-row.component.html | 2 ++ .../src/lib/grids/row.directive.ts | 8 ++++++++ .../tree-grid/tree-grid-row.component.html | 4 ++++ 5 files changed, 34 insertions(+) diff --git a/projects/igniteui-angular/src/lib/grids/cell.component.ts b/projects/igniteui-angular/src/lib/grids/cell.component.ts index 29c9b8c8444..1a8acc786a8 100644 --- a/projects/igniteui-angular/src/lib/grids/cell.component.ts +++ b/projects/igniteui-angular/src/lib/grids/cell.component.ts @@ -151,6 +151,11 @@ export class IgxGridCellComponent implements OnInit, OnChanges, OnDestroy, CellT @Input() public isPlaceholder: boolean; + /** + Gets whether this cell is a merged cell. + */ + @Input() + public isMerged: boolean; /** * @hidden @@ -1016,6 +1021,19 @@ export class IgxGridCellComponent implements OnInit, OnChanges, OnDestroy, CellT * @internal */ public pointerdown = (event: PointerEvent) => { + + if (this.isMerged) { + // need an approximation of where in the cell the user clicked to get actual index to be activated. + const scrollOffset = this.grid.verticalScrollContainer.scrollPosition + (event.y - this.grid.tbody.nativeElement.getBoundingClientRect().y); + const targetRowIndex = this.grid.verticalScrollContainer.getIndexAtScroll(scrollOffset); + if (targetRowIndex != this.rowIndex) { + const row = this.grid.rowList.toArray().find(x => x.index === targetRowIndex); + const actualTarget = row.cells.find(x => x.column === this.column); + actualTarget.pointerdown(event); + return; + } + } + if (this.cellSelectionMode !== GridSelectionMode.multiple) { this.activate(event); return; diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index 7c36e7d111a..75f3df188dc 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -135,6 +135,7 @@ [class.igx-grid__td--merged]="metaData?.cellMergeMeta.get(col.field)?.rowSpan > 1" [class.igx-grid__td--merged-selected]="isSelectionRoot(col)" [class.igx-grid__td--merged-hovered]="isHoveredRoot(col)" + [isMerged]="metaData?.cellMergeMeta.get(col.field)?.rowSpan > 1" [isPlaceholder]="!!this.metaData?.cellMergeMeta.get(col.field)?.root" class="igx-grid__td igx-grid__td--fw" [class.igx-grid__td--edited]="key | transactionState:col.field:grid.rowEditable:grid.transactions:grid.pipeTrigger:grid.gridAPI.crudService.cell:grid.gridAPI.crudService.row" @@ -174,6 +175,7 @@ [class.igx-grid__td--merged]="metaData?.cellMergeMeta.get(col.field)?.rowSpan > 1" [class.igx-grid__td--merged-selected]="isSelectionRoot(col)" [class.igx-grid__td--merged-hovered]="isHoveredRoot(col)" + [isMerged]="metaData?.cellMergeMeta.get(col.field)?.rowSpan > 1" [isPlaceholder]="!!this.metaData?.cellMergeMeta.get(col.field)?.root" [class.igx-grid__td--pinned]="col.pinned" class="igx-grid__td igx-grid__td--fw igx-grid__td--tree-cell" diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html index 913bf19f5a9..c8192215d8b 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-row.component.html @@ -123,6 +123,8 @@ x.index === targetRowIndex); + row.onClick(event); + return; + } + } this.grid.rowClick.emit({ row: this, event diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html index 8208ac102ac..b2a4ed217ec 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.html @@ -81,6 +81,8 @@ [class.igx-grid__td--merged-selected]="isSelectionRoot(col)" [class.igx-grid__td--merged-hovered]="isHoveredRoot(col)" [class.igx-grid__td--pinned]="col.pinned" + [isMerged]="metaData?.cellMergeMeta.get(col.field)?.rowSpan > 1" + [isPlaceholder]="!!this.metaData?.cellMergeMeta.get(col.field)?.root" class="igx-grid__td igx-grid__td--fw" [class.igx-grid__td--edited]="key | transactionState:col.field:grid.rowEditable:grid.transactions:grid.pipeTrigger:grid.gridAPI.crudService.cell:grid.gridAPI.crudService.row" [class.igx-grid__td--number]="col.dataType === 'number' || col.dataType === 'percent' || col.dataType === 'currency'" @@ -115,6 +117,8 @@ [class.igx-grid__td--merged]="metaData?.cellMergeMeta.get(col.field)?.rowSpan > 1" [class.igx-grid__td--merged-selected]="isSelectionRoot(col)" [class.igx-grid__td--merged-hovered]="isHoveredRoot(col)" + [isMerged]="metaData?.cellMergeMeta.get(col.field)?.rowSpan > 1" + [isPlaceholder]="!!this.metaData?.cellMergeMeta.get(col.field)?.root" [class.igx-grid__td--pinned]="col.pinned" [class.igx-grid__td--edited]="key | transactionState:col.field:grid.rowEditable:grid.transactions:grid.pipeTrigger:grid.gridAPI.crudService.cell:grid.gridAPI.crudService.row" [class.igx-grid__td--number]="(col.dataType === 'number' || col.dataType === 'percent' || col.dataType === 'currency') && col.visibleIndex !== 0" From bcae71b27ac4bbbbc287a3e089fad67d98f7a299 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 30 Jul 2025 14:20:38 +0300 Subject: [PATCH 74/99] chore(*): Pass clientY when simulating pointer events in tests. --- .../igniteui-angular/src/lib/test-utils/ui-interactions.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/igniteui-angular/src/lib/test-utils/ui-interactions.spec.ts b/projects/igniteui-angular/src/lib/test-utils/ui-interactions.spec.ts index 0b72f5a3c7b..eb470e8137f 100644 --- a/projects/igniteui-angular/src/lib/test-utils/ui-interactions.spec.ts +++ b/projects/igniteui-angular/src/lib/test-utils/ui-interactions.spec.ts @@ -345,6 +345,7 @@ export class UIInteractions { cancelable: true, pointerId: 1, buttons: 1, + clientY: element.getBoundingClientRect().y, button: eventName === 'pointerenter' ? -1 : 0, shiftKey, ctrlKey From 2d6b4431eed4c68868df4ae1b7aeaa8cc70a6379 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 31 Jul 2025 11:56:38 +0300 Subject: [PATCH 75/99] chore(*): Fix background styles when pinned and merged. --- .../src/lib/core/styles/components/grid/_grid-theme.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index b0eeb099572..2197341510d 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1903,11 +1903,11 @@ %igx-grid__td--merged-selected { color: var-get($theme, 'row-selected-text-color'); - background: var-get($theme, 'row-selected-background'); + background: var-get($theme, 'row-selected-background') !important; } %igx-grid__td--merged-hovered { - background: var-get($theme, 'row-hover-background'); + background: var-get($theme, 'row-hover-background') !important; color: var-get($theme, 'row-hover-text-color'); } @@ -1948,6 +1948,7 @@ %grid-cell--pinned { position: relative; + background: inherit; z-index: 9999; } From f69b105422b5ad5b8872801cfe3924be714f85a0 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 31 Jul 2025 13:18:49 +0300 Subject: [PATCH 76/99] chore(*): Fix background styles when merged, hovered and selected. --- .../src/lib/core/styles/components/grid/_grid-theme.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index ef4af4af632..de61ec6aacb 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1356,7 +1356,7 @@ } %igx-grid__td--merged-selected-hovered { - background: var-get($theme, 'row-selected-hover-background'); + background: var-get($theme, 'row-selected-hover-background') !important; color: var-get($theme, 'row-selected-hover-text-color'); } From 13b4c24a20ade84ecdfcdea50119b4f785486744 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 31 Jul 2025 17:31:15 +0300 Subject: [PATCH 77/99] chore(*): Add merge strategy to pipe trigger so that it can be changed runtime. --- .../igniteui-angular/src/lib/grids/grid/grid.component.html | 4 ++-- projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts | 5 +++-- .../grids/hierarchical-grid/hierarchical-grid.component.html | 4 ++-- .../src/lib/grids/tree-grid/tree-grid.component.html | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index 3e6df0c38e9..727aee7cf5a 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -63,7 +63,7 @@ | gridRowPinning:id:true:pipeTrigger | gridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:id:pipeTrigger:filteringPipeTrigger:true | gridSort:sortingExpressions:groupingExpressions:sortStrategy:id:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData) { + | gridCellMerge:visibleColumns:cellMergeMode:mergeStrategy:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData) { @if (pinnedData.length > 0) {
x - this.grid.pinnedRecordsCount); } - const result = DataUtil.merge(cloneArray(collection), colsToMerge, this.grid.mergeStrategy, activeRowIndexes, this.grid); + const result = DataUtil.merge(cloneArray(collection), colsToMerge, mergeStrategy, activeRowIndexes, this.grid); return result; } } diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html index f097f45acf6..b42fa1064b8 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html @@ -46,7 +46,7 @@ | gridRowPinning:id:true:pipeTrigger | gridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:id:pipeTrigger:filteringPipeTrigger:true | gridSort:sortingExpressions:[]:sortStrategy:id:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData + | gridCellMerge:visibleColumns:cellMergeMode:mergeStrategy:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData ) { @if (pinnedData.length > 0) {
diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index b77b0fa216c..40eeeb3229f 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -47,7 +47,7 @@ | gridRowPinning:id:true:pipeTrigger | treeGridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:pipeTrigger:filteringPipeTrigger:true | treeGridSorting:sortingExpressions:treeGroupArea?.expressions:sortStrategy:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData + | gridCellMerge:visibleColumns:cellMergeMode:mergeStrategy:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData ) { @if (pinnedData.length > 0) {
From 05e42c644d5acb6bba16d06a246c5668a41de0a6 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 31 Jul 2025 17:51:07 +0300 Subject: [PATCH 78/99] chore(*): Change detect on runtime strategy change. --- .../src/lib/grids/grid-base.directive.ts | 3 +++ src/app/grid-cellMerging/grid-cellMerging.component.html | 2 +- src/app/grid-cellMerging/grid-cellMerging.component.ts | 9 +++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index d9138a16862..b524a4541dd 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -2510,6 +2510,9 @@ export abstract class IgxGridBaseDirective implements GridType, } public set mergeStrategy(value) { this._mergeStrategy = value; + if (!this._init) { + this.cdr.detectChanges(); + } } /** diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index 63c02663d1d..29b78e55884 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -111,7 +111,7 @@

Hierarchical grid with cell merge

Tree grid with cell merge

- + diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.ts b/src/app/grid-cellMerging/grid-cellMerging.component.ts index 45130d806d2..3374c5e606f 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.ts +++ b/src/app/grid-cellMerging/grid-cellMerging.component.ts @@ -1,6 +1,7 @@ import { Component, HostBinding, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { + DefaultTreeGridMergeStrategy, IgxActionStripComponent, IgxButtonDirective, IgxCellTemplateDirective, @@ -65,6 +66,14 @@ export class GridCellMergingComponent { @ViewChild('grid1', { static: true }) public grid: IgxGridComponent; public data = INVOICE_DATA; + public toggleStrategy() { + if (this.treeGridMergeStrategy instanceof ByLevelTreeGridMergeStrategy) { + this.treeGridMergeStrategy = new DefaultTreeGridMergeStrategy(); + } else { + this.treeGridMergeStrategy = new ByLevelTreeGridMergeStrategy(); + } + } + public searchKeyDown(ev) { if (ev.key === 'Enter' || ev.key === 'ArrowDown' || ev.key === 'ArrowRight') { ev.preventDefault(); From a06132a43111b44c1bbe5a8c6a374f92447844a1 Mon Sep 17 00:00:00 2001 From: MKirova Date: Fri, 1 Aug 2025 12:12:29 +0300 Subject: [PATCH 79/99] fix(*): Fix positioning in pin right scenario. Extract styles in class. --- .../src/lib/core/styles/components/grid/_grid-component.scss | 4 ++++ .../src/lib/core/styles/components/grid/_grid-theme.scss | 5 +++++ .../igniteui-angular/src/lib/grids/grid/grid.component.html | 2 +- .../grids/hierarchical-grid/hierarchical-grid.component.html | 2 +- .../src/lib/grids/tree-grid/tree-grid.component.html | 2 +- 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss index fa2c8d91ac0..8b0f9b88af0 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss @@ -272,6 +272,10 @@ @extend %igx-grid__tr--pinned !optional; } + @include e(tr, $m: merged) { + @extend %igx-grid__tr--merged !optional; + } + @include e(tr, $m: pinned-top) { @extend %igx-grid__tr--pinned-top !optional; } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index de61ec6aacb..25f0fc3addf 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1340,6 +1340,11 @@ } } + %igx-grid__tr--merged { + position: absolute; + width: 100%; + } + %igx-grid__td--merged { z-index: 1; grid-row: 1 / -1; diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index 727aee7cf5a..830d3a95c06 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -50,7 +50,7 @@ @for (rowData of mergedDataInView; track rowData.record;) { - diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html index b42fa1064b8..adca8662b94 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html @@ -33,7 +33,7 @@ } @for (rowData of mergedDataInView; track rowData.record;) { - diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index 40eeeb3229f..c3ecb711690 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -32,7 +32,7 @@ } @for (rowData of mergedDataInView; track rowData.record;) { - From 55288673177439902d2b4680bae638c84fbc3850 Mon Sep 17 00:00:00 2001 From: MKirova Date: Fri, 1 Aug 2025 12:37:39 +0300 Subject: [PATCH 80/99] chore(*): Fix border styles for cell merging. --- .../src/lib/core/styles/components/grid/_grid-component.scss | 4 ++++ .../src/lib/core/styles/components/grid/_grid-theme.scss | 4 ++++ projects/igniteui-angular/src/lib/grids/common/pipes.ts | 1 + .../igniteui-angular/src/lib/grids/grid/grid.component.html | 2 +- .../grids/hierarchical-grid/hierarchical-grid.component.html | 2 +- .../src/lib/grids/tree-grid/tree-grid.component.html | 2 +- 6 files changed, 12 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss index 8b0f9b88af0..91b864e6c87 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-component.scss @@ -276,6 +276,10 @@ @extend %igx-grid__tr--merged !optional; } + @include e(tr, $m: merged-top) { + @extend %igx-grid__tr--merged-top !optional; + } + @include e(tr, $m: pinned-top) { @extend %igx-grid__tr--pinned-top !optional; } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index 25f0fc3addf..c992c2b95cc 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1341,6 +1341,10 @@ } %igx-grid__tr--merged { + border-bottom: 0px; + } + + %igx-grid__tr--merged-top { position: absolute; width: 100%; } diff --git a/projects/igniteui-angular/src/lib/grids/common/pipes.ts b/projects/igniteui-angular/src/lib/grids/common/pipes.ts index 27d0667fbeb..23bc4d4a9b0 100644 --- a/projects/igniteui-angular/src/lib/grids/common/pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/common/pipes.ts @@ -128,6 +128,7 @@ export class IgxGridRowClassesPipe implements PipeTransform { [deleted, 'igx-grid__tr--deleted'], [dragging, 'igx-grid__tr--drag'], [mrl || merged, 'igx-grid__tr--mrl'], + [merged, 'igx-grid__tr--merged'], // Tree grid only [filteredOut, 'igx-grid__tr--filtered'] ]; diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index 830d3a95c06..77a3b166b91 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -50,7 +50,7 @@ @for (rowData of mergedDataInView; track rowData.record;) { - diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html index adca8662b94..4e07604a4f3 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html @@ -33,7 +33,7 @@ } @for (rowData of mergedDataInView; track rowData.record;) { - diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index c3ecb711690..1c665795621 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -32,7 +32,7 @@ } @for (rowData of mergedDataInView; track rowData.record;) { - From 8e45f72fb456474d3b4925027e2e7f496d1bc28b Mon Sep 17 00:00:00 2001 From: Maya Date: Thu, 7 Aug 2025 15:38:29 +0300 Subject: [PATCH 81/99] chore(*): Apply review comments. Co-authored-by: Simeon Simeonoff --- .../src/lib/core/styles/components/grid/_grid-theme.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss index c992c2b95cc..ffa36f14e90 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/grid/_grid-theme.scss @@ -1341,7 +1341,7 @@ } %igx-grid__tr--merged { - border-bottom: 0px; + border-block-end: 0; } %igx-grid__tr--merged-top { From d6b9e8ea18368732263fd90a44c2a6927993e403 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 7 Aug 2025 16:13:30 +0300 Subject: [PATCH 82/99] chore(*): Update tests since border is now removed. --- .../src/lib/grids/grid/cell-merge.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index 963ddd7726c..9ac6d33f653 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -164,8 +164,8 @@ describe('IgxGrid - Cell merging #grid', () => { grid.verticalScrollContainer.recalcUpdateSizes(); grid.dataRowList.toArray().forEach(x => x.cdr.detectChanges()); const mergedCell = fix.debugElement.queryAll(By.css(MERGE_CELL_CSS_CLASS))[0].nativeNode; - // one row is 100px, other is 200, 4px border - expect(mergedCell.getBoundingClientRect().height).toBe(100 + 200 + 4); + // one row is 100px, other is 200, 2px border + expect(mergedCell.getBoundingClientRect().height).toBe(100 + 200 + 2); }); }); }); @@ -220,7 +220,7 @@ describe('IgxGrid - Cell merging #grid', () => { it('horizontal virtualization should not be affected by vertically merged cells.', async() => { let mergedCell = grid.rowList.first.cells.find(x => x.column.field === 'ProductName'); expect(mergedCell.value).toBe('Ignite UI for JavaScript'); - expect(mergedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + expect(mergedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("50px 50px"); // scroll horizontally grid.navigateTo(0, 4); @@ -238,7 +238,7 @@ describe('IgxGrid - Cell merging #grid', () => { mergedCell = grid.rowList.first.cells.find(x => x.column.field === 'ProductName'); expect(mergedCell.value).toBe('Ignite UI for JavaScript'); - expect(mergedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + expect(mergedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("50px 50px"); }); }); @@ -356,7 +356,7 @@ describe('IgxGrid - Cell merging #grid', () => { const mergedCell = grid.rowList.first.cells.find(x => x.column.field === 'ProductName'); expect(mergedCell.value).toBe('Ignite UI for JavaScript'); - expect(mergedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + expect(mergedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("50px 50px"); }); }); @@ -374,7 +374,7 @@ describe('IgxGrid - Cell merging #grid', () => { expect(pinnedRow.metaData.cellMergeMeta.get(col.field)?.rowSpan).toBe(2); const mergedPinnedCell = pinnedRow.cells.find(x => x.column.field === 'ProductName'); expect(mergedPinnedCell.value).toBe('Ignite UI for JavaScript'); - expect(mergedPinnedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + expect(mergedPinnedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("50px 50px"); }); it('should merge adjacent ghost rows in unpinned area.', () => { @@ -391,7 +391,7 @@ describe('IgxGrid - Cell merging #grid', () => { expect(ghostRow.metaData.cellMergeMeta.get(col.field)?.rowSpan).toBe(2); const mergedPinnedCell = ghostRow.cells.find(x => x.column.field === 'ProductName'); expect(mergedPinnedCell.value).toBe('Ignite UI for JavaScript'); - expect(mergedPinnedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("51px 51px"); + expect(mergedPinnedCell.nativeElement.parentElement.style.gridTemplateRows).toBe("50px 50px"); }); it('should not merge ghost and data rows together.', () => { From e91b0971aef1c02eb965b39d4e756947d5192f33 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 14 Aug 2025 16:05:14 +0300 Subject: [PATCH 83/99] chore(*): Add explicit notifyChange after activeNode is changed. --- projects/igniteui-angular/src/lib/grids/grid-base.directive.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 8930a292755..a71b6275f44 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3914,6 +3914,7 @@ export abstract class IgxGridBaseDirective implements GridType, this._activeRowIndexes = null; if (this.hasCellsToMerge) { this.refreshSearch(); + this.notifyChanges(); } }); From 6e103acbbb64c9aa1ff0c2d69df662b4a9329c80 Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 14 Aug 2025 16:20:38 +0300 Subject: [PATCH 84/99] chore(*): Add merge strategy interface api docs. --- .../src/lib/data-operations/merge-strategy.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts index d1e5b5f9b5a..f50e83feecc 100644 --- a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts +++ b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts @@ -6,19 +6,34 @@ import { GridType } from '../grids/common/grid.interface'; export interface IMergeByResult { rowSpan: number; root?: any; - prev?: any; } +/** + * Merge strategy interface. + */ export interface IGridMergeStrategy { /* blazorSuppress */ + /** + * Function that processes merging of the whole data per merged field. + * Returns collection where object has reference to the original record and map of the cell merge metadata per field. + */ merge: ( + /* The original data to merge. */ data: any[], + /* The field in the data to merge. */ field: string, + /* Custom comparer function to use for field. */ comparer: (prevRecord: any, currentRecord: any, field: string) => boolean, + /* Existing merge result to which to add the field specific metadata for merging. */ result: any[], - activeRowIndexes : number[], + /* The active row indexes, where merging should break the sequence. */ + activeRowIndexes: number[], + /* Optional reference to the grid */ grid?: GridType ) => any[]; + /** + * Function that compares values for merging. Returns true if same, false if different. + */ comparer: (prevRecord: any, record: any, field: string) => boolean; } @@ -35,7 +50,7 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { field: string, comparer: (prevRecord: any, record: any, field: string) => boolean = this.comparer, result: any[], - activeRowIndexes : number[], + activeRowIndexes: number[], grid?: GridType ) { let prev = null; @@ -45,7 +60,7 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { const recData = result[index]; // if this is active row or some special record type - add and skip merging if (activeRowIndexes.indexOf(index) != -1 || (grid && grid.isDetailRecord(rec) || grid.isGroupByRecord(rec) || grid.isChildGridRecord(rec))) { - if(!recData) { + if (!recData) { result.push(rec); } prev = null; From 75fd965e6590fac0fa1b5fb656e890f59159453f Mon Sep 17 00:00:00 2001 From: MKirova Date: Thu, 14 Aug 2025 16:25:58 +0300 Subject: [PATCH 85/99] chore(*): Small review comments. --- .../igniteui-angular/src/lib/grids/grid-base.directive.ts | 7 +------ .../src/lib/grids/grid/grid-row.component.html | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index a71b6275f44..f8f179e5d3f 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -4083,12 +4083,7 @@ export abstract class IgxGridBaseDirective implements GridType, } protected get hasCellsToMerge() { - const columnToMerge = this.visibleColumns.filter( - x => x.merge && (this.cellMergeMode ==='always' || - (this.cellMergeMode === 'onSort' && !!this.sortingExpressions - .find(y => y.fieldName === x.field))) - ); - return columnToMerge.length > 0; + return this.columnsToMerge.length > 0; } /** diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index 75f3df188dc..0180b72cee9 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -171,7 +171,6 @@ Date: Thu, 14 Aug 2025 18:06:31 +0300 Subject: [PATCH 86/99] chore(*): Limit how often visibleColumns array changes. --- projects/igniteui-angular/src/lib/core/utils.ts | 11 +++++++++++ .../src/lib/grids/grid-base.directive.ts | 10 ++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/core/utils.ts b/projects/igniteui-angular/src/lib/core/utils.ts index a787a9cdcd5..902d58e76d0 100644 --- a/projects/igniteui-angular/src/lib/core/utils.ts +++ b/projects/igniteui-angular/src/lib/core/utils.ts @@ -33,6 +33,17 @@ export function cloneArray(array: T[], deep = false): T[] { return deep ? (array ?? []).map(cloneValue) : (array ?? []).slice(); } +/** + * @hidden + */ +export function areEqualArrays(arr1: T[], arr2: T[]): boolean { + if (arr1.length !== arr2.length) return false; + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) return false; + } + return true; +} + /** * Doesn't clone leaf items * diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index f8f179e5d3f..68b648d5ce2 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -33,7 +33,7 @@ import { ViewContainerRef, DOCUMENT } from '@angular/core'; -import { columnFieldPath, formatDate, resizeObservable } from '../core/utils'; +import { areEqualArrays, columnFieldPath, formatDate, resizeObservable } from '../core/utils'; import { IgcTrialWatermark } from 'igniteui-trial-watermark'; import { Subject, pipe, fromEvent, animationFrameScheduler, merge } from 'rxjs'; import { takeUntil, first, filter, throttleTime, map, shareReplay, takeWhile } from 'rxjs/operators'; @@ -3358,6 +3358,7 @@ export abstract class IgxGridBaseDirective implements GridType, private _defaultRowHeight = 50; private _rowCount: number; private _cellMergeMode: GridCellMergeMode = GridCellMergeMode.onSort; + private _prevVisibleColumns: IgxColumnComponent[] = []; /** * @hidden @internal @@ -4733,7 +4734,12 @@ export abstract class IgxGridBaseDirective implements GridType, if (this._visibleColumns.length) { return this._visibleColumns; } - this._visibleColumns = this._columns.filter(c => !c.hidden); + const newCollection = this._columns.filter(c => !c.hidden); + if (areEqualArrays(newCollection, this._prevVisibleColumns)) { + return this._prevVisibleColumns; + } + this._visibleColumns = newCollection; + this._prevVisibleColumns = [...this._visibleColumns]; return this._visibleColumns; } From 1a4fbd9a56e3cfdaf466ea72eaa3b4e61a3e3c2a Mon Sep 17 00:00:00 2001 From: MKirova Date: Fri, 15 Aug 2025 11:24:18 +0300 Subject: [PATCH 87/99] chore(*): Cache columnsToMerge and use as pipe trigger. --- .../src/lib/grids/grid-base.directive.ts | 22 ++++++++++++------- .../src/lib/grids/grid/grid.component.html | 4 ++-- .../src/lib/grids/grid/grid.pipes.ts | 3 +-- .../hierarchical-grid.component.html | 4 ++-- .../grids/tree-grid/tree-grid.component.html | 4 ++-- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 68b648d5ce2..71199ea711e 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -3358,7 +3358,8 @@ export abstract class IgxGridBaseDirective implements GridType, private _defaultRowHeight = 50; private _rowCount: number; private _cellMergeMode: GridCellMergeMode = GridCellMergeMode.onSort; - private _prevVisibleColumns: IgxColumnComponent[] = []; + private _prevColsToMerge: IgxColumnComponent[] = []; + private _columnsToMerge: IgxColumnComponent[] = []; /** * @hidden @internal @@ -3994,10 +3995,19 @@ export abstract class IgxGridBaseDirective implements GridType, * @internal */ public get columnsToMerge() : ColumnType[] { - return this.visibleColumns.filter( + if (this._columnsToMerge.length) { + return this._columnsToMerge; + } + const cols = this.visibleColumns.filter( x => x.merge && (this.cellMergeMode ==='always' || (this.cellMergeMode === 'onSort' && !!this.sortingExpressions.find( y => y.fieldName === x.field))) ); + if (areEqualArrays(cols, this._prevColsToMerge)) { + return this._prevColsToMerge; + } + this._columnsToMerge = cols; + this._prevColsToMerge = [...cols]; + return this._columnsToMerge; } protected get mergedDataInView() { @@ -4015,6 +4025,7 @@ export abstract class IgxGridBaseDirective implements GridType, this._visibleColumns.length = 0; this._pinnedVisible.length = 0; this._unpinnedVisible.length = 0; + this._columnsToMerge.length = 0; } /** @@ -4734,12 +4745,7 @@ export abstract class IgxGridBaseDirective implements GridType, if (this._visibleColumns.length) { return this._visibleColumns; } - const newCollection = this._columns.filter(c => !c.hidden); - if (areEqualArrays(newCollection, this._prevVisibleColumns)) { - return this._prevVisibleColumns; - } - this._visibleColumns = newCollection; - this._prevVisibleColumns = [...this._visibleColumns]; + this._visibleColumns = this._columns.filter(c => !c.hidden); return this._visibleColumns; } diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index a27794ce4c7..8b2202aa031 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -62,7 +62,7 @@ | gridRowPinning:id:true:pipeTrigger | gridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:id:pipeTrigger:filteringPipeTrigger:true | gridSort:sortingExpressions:groupingExpressions:sortStrategy:id:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:mergeStrategy:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData) { + | gridCellMerge:columnsToMerge:cellMergeMode:mergeStrategy:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData) { @if (pinnedData.length > 0) {
0) {
diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index 1c665795621..dd57a128572 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -47,7 +47,7 @@ | gridRowPinning:id:true:pipeTrigger | treeGridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:pipeTrigger:filteringPipeTrigger:true | treeGridSorting:sortingExpressions:treeGroupArea?.expressions:sortStrategy:pipeTrigger:true - | gridCellMerge:visibleColumns:cellMergeMode:mergeStrategy:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData + | gridCellMerge:columnsToMerge:cellMergeMode:mergeStrategy:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData ) { @if (pinnedData.length > 0) {
From 048327355ddf2783762d96ab49f5e67bd2d458c4 Mon Sep 17 00:00:00 2001 From: MKirova Date: Fri, 15 Aug 2025 11:35:12 +0300 Subject: [PATCH 88/99] chore(*): Remove sortExpr as pipe trigger, since no longer needed. --- .../igniteui-angular/src/lib/grids/grid/grid.component.html | 4 ++-- projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts | 2 +- .../grids/hierarchical-grid/hierarchical-grid.component.html | 4 ++-- .../src/lib/grids/tree-grid/tree-grid.component.html | 4 ++-- src/app/grid-cellMerging/grid-cellMerging.component.html | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index 8b2202aa031..2569e614de8 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -62,7 +62,7 @@ | gridRowPinning:id:true:pipeTrigger | gridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:id:pipeTrigger:filteringPipeTrigger:true | gridSort:sortingExpressions:groupingExpressions:sortStrategy:id:pipeTrigger:true - | gridCellMerge:columnsToMerge:cellMergeMode:mergeStrategy:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData) { + | gridCellMerge:columnsToMerge:cellMergeMode:mergeStrategy:activeRowIndexes:true:pipeTrigger; as pinnedData) { @if (pinnedData.length > 0) {
0) {
diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index dd57a128572..0c9a7f12a9a 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -47,7 +47,7 @@ | gridRowPinning:id:true:pipeTrigger | treeGridFiltering:filteringExpressionsTree:filterStrategy:advancedFilteringExpressionsTree:pipeTrigger:filteringPipeTrigger:true | treeGridSorting:sortingExpressions:treeGroupArea?.expressions:sortStrategy:pipeTrigger:true - | gridCellMerge:columnsToMerge:cellMergeMode:mergeStrategy:sortingExpressions:activeRowIndexes:true:pipeTrigger; as pinnedData + | gridCellMerge:columnsToMerge:cellMergeMode:mergeStrategy:activeRowIndexes:true:pipeTrigger; as pinnedData ) { @if (pinnedData.length > 0) {
diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index 29b78e55884..a0b8501c44d 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -44,7 +44,7 @@

Grid with cell merge

+ [cellMergeMode]="'onSort'" [rowSelection]="'single'"> Value: {{val}},Index: {{cell.row.index}} From 6d148a498faceea03d0893c6111fa865d8ce08b1 Mon Sep 17 00:00:00 2001 From: MKirova Date: Fri, 15 Aug 2025 14:52:59 +0300 Subject: [PATCH 89/99] chore(*): More optimizations for merged cols eval. --- .../src/lib/grids/columns/column.component.ts | 8 ++++- .../src/lib/grids/grid-base.directive.ts | 30 ++++++++++++++----- .../grid-cellMerging.component.html | 2 +- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts index e091a03dcf6..1e044edf6a6 100644 --- a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts +++ b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts @@ -124,7 +124,13 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy console.warn('Merging is not supported with multi-row layouts.'); return; } - this._merge = value; + if (value !== this._merge) { + this._merge = value; + if (this.grid) { + this.grid.resetColumnCollections(); + this.grid.notifyChanges(); + } + } } /** diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 71199ea711e..70b7e533d59 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -2828,6 +2828,9 @@ export abstract class IgxGridBaseDirective implements GridType, public set sortingExpressions(value: ISortingExpression[]) { this._sortingExpressions = cloneArray(value); this.sortingExpressionsChange.emit(this._sortingExpressions); + if (this.cellMergeMode === GridCellMergeMode.onSort) { + this.resetColumnCollections(); + } this.notifyChanges(); } @@ -2951,7 +2954,11 @@ export abstract class IgxGridBaseDirective implements GridType, } public set cellMergeMode(value: GridCellMergeMode) { - this._cellMergeMode = value; + if (value !== this._cellMergeMode) { + this._cellMergeMode = value; + this.resetColumnCollections(); + this.notifyChanges(); + } } /** @@ -3358,7 +3365,6 @@ export abstract class IgxGridBaseDirective implements GridType, private _defaultRowHeight = 50; private _rowCount: number; private _cellMergeMode: GridCellMergeMode = GridCellMergeMode.onSort; - private _prevColsToMerge: IgxColumnComponent[] = []; private _columnsToMerge: IgxColumnComponent[] = []; /** @@ -4002,14 +4008,22 @@ export abstract class IgxGridBaseDirective implements GridType, x => x.merge && (this.cellMergeMode ==='always' || (this.cellMergeMode === 'onSort' && !!this.sortingExpressions.find( y => y.fieldName === x.field))) ); - if (areEqualArrays(cols, this._prevColsToMerge)) { - return this._prevColsToMerge; - } this._columnsToMerge = cols; - this._prevColsToMerge = [...cols]; return this._columnsToMerge; } + protected allowResetOfColumnsToMerge() { + const cols = this.visibleColumns.filter( + x => x.merge && (this.cellMergeMode ==='always' || + (this.cellMergeMode === 'onSort' && !!this.sortingExpressions.find( y => y.fieldName === x.field))) + ); + if (areEqualArrays(cols, this._columnsToMerge)) { + return false; + } else { + return true + } + } + protected get mergedDataInView() { return this._mergedDataInView; } @@ -4025,7 +4039,9 @@ export abstract class IgxGridBaseDirective implements GridType, this._visibleColumns.length = 0; this._pinnedVisible.length = 0; this._unpinnedVisible.length = 0; - this._columnsToMerge.length = 0; + if (this.allowResetOfColumnsToMerge()) { + this._columnsToMerge.length = 0; + } } /** diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index a0b8501c44d..29b78e55884 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -44,7 +44,7 @@

Grid with cell merge

+ [cellMergeMode]="'always'" [rowSelection]="'single'"> Value: {{val}},Index: {{cell.row.index}} From a17df96e68d89fac4b6fc0057402ce674f190648 Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 18 Aug 2025 14:09:07 +0300 Subject: [PATCH 90/99] chore(*): Remove change detect on mergeStrategy change. --- projects/igniteui-angular/src/lib/grids/grid-base.directive.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 70b7e533d59..5e840192f1a 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -2519,9 +2519,6 @@ export abstract class IgxGridBaseDirective implements GridType, } public set mergeStrategy(value) { this._mergeStrategy = value; - if (!this._init) { - this.cdr.detectChanges(); - } } /** From a892a578d4d59d0c2f78df0b47bc7743b40bc62a Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 18 Aug 2025 14:59:48 +0300 Subject: [PATCH 91/99] chore(*): Add handling for different date related dataTypes on column. --- .../src/lib/data-operations/data-util.ts | 16 ++++- .../src/lib/data-operations/merge-strategy.ts | 66 +++++++++++++++---- .../src/lib/grids/grid/cell-merge.spec.ts | 2 + .../grid-cellMerging.component.html | 2 +- 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/projects/igniteui-angular/src/lib/data-operations/data-util.ts b/projects/igniteui-angular/src/lib/data-operations/data-util.ts index 0066cd58f4e..52a594c085a 100644 --- a/projects/igniteui-angular/src/lib/data-operations/data-util.ts +++ b/projects/igniteui-angular/src/lib/data-operations/data-util.ts @@ -25,7 +25,7 @@ import { DefaultMergeStrategy, IGridMergeStrategy } from './merge-strategy'; /** * @hidden */ - export const DataType = { +export const DataType = { String: 'string', Number: 'number', Boolean: 'boolean', @@ -95,10 +95,20 @@ export class DataUtil { ): any[] { let result = []; for (const col of columns) { - strategy.merge(data, col.field, col.mergingComparer, result, activeRowIndexes, grid); + const isDate = col?.dataType === 'date' || col?.dataType === 'dateTime'; + const isTime = col?.dataType === 'time' || col?.dataType === 'dateTime'; + strategy.merge( + data, + col.field, + col.mergingComparer, + result, + activeRowIndexes, + isDate, + isTime, + grid); } return result; -} + } public static page(data: T[], state: IPagingState, dataLength?: number): T[] { if (!state) { diff --git a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts index f50e83feecc..726140f962f 100644 --- a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts +++ b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts @@ -1,3 +1,4 @@ +import { columnFieldPath, parseDate, resolveNestedPath } from '../core/utils'; import { GridType } from '../grids/common/grid.interface'; @@ -28,7 +29,11 @@ export interface IGridMergeStrategy { result: any[], /* The active row indexes, where merging should break the sequence. */ activeRowIndexes: number[], - /* Optional reference to the grid */ + /* (Optional) Indicates if the field is of type Date. */ + isDate?: boolean, + /* (Optional) Indicates if the field is of type Time. */ + isTime?: boolean, + /* (Optional) Reference to the grid */ grid?: GridType ) => any[]; /** @@ -48,9 +53,11 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { public merge( data: any[], field: string, - comparer: (prevRecord: any, record: any, field: string) => boolean = this.comparer, + comparer: (prevRecord: any, record: any, field: string, isDate?: boolean, isTime?: boolean) => boolean = this.comparer, result: any[], activeRowIndexes: number[], + isDate = false, + isTime = false, grid?: GridType ) { let prev = null; @@ -69,7 +76,7 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { } let recToUpdateData = recData ?? { recordRef: grid.isGhostRecord(rec) ? rec.recordRef : rec, cellMergeMeta: new Map(), ghostRecord: rec.ghostRecord }; recToUpdateData.cellMergeMeta.set(field, { rowSpan: 1 }); - if (prev && comparer(prev.recordRef, recToUpdateData.recordRef, field) && prev.ghostRecord === recToUpdateData.ghostRecord) { + if (prev && comparer.call(this, prev.recordRef, recToUpdateData.recordRef, field, isDate, isTime) && prev.ghostRecord === recToUpdateData.ghostRecord) { const root = prev.cellMergeMeta.get(field)?.root ?? prev; root.cellMergeMeta.get(field).rowSpan += 1; recToUpdateData.cellMergeMeta.get(field).root = root; @@ -84,9 +91,9 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { } /* blazorSuppress */ - public comparer(prevRecord: any, record: any, field: string): boolean { - const a = prevRecord[field]; - const b = record[field]; + public comparer(prevRecord: any, record: any, field: string, isDate = false, isTime = false): boolean { + const a = this.getFieldValue(prevRecord,field, isDate, isTime); + const b = this.getFieldValue(record,field, isDate, isTime); const an = (a === null || a === undefined); const bn = (b === null || b === undefined); if (an) { @@ -99,14 +106,49 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { } return a === b; } + + /** + * Retrieves the value of the specified field from the given object, considering date and time data types. + * `key`: The key of the field to retrieve. + * `isDate`: (Optional) Indicates if the field is of type Date. + * `isTime`: (Optional) Indicates if the field is of type Time. + * Returns the value of the specified field in the data object. + * @internal + */ + protected getFieldValue(obj: T, key: string, isDate = false, isTime = false) { + let resolvedValue = resolveNestedPath(obj, columnFieldPath(key)); + if (isDate || isTime) { + resolvedValue = this.getDateValue(resolvedValue, isDate, isTime); + } + return resolvedValue; + } + + /** + * @internal + */ + protected getDateValue(obj: T, isDate = false, isTime = false) { + let date = obj instanceof Date ? obj : parseDate(obj); + let resolvedValue; + if (isDate && isTime) { + // date + time + resolvedValue = date.getTime(); + } else if (date && isDate && !isTime) { + // date, but no time + resolvedValue = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0).getTime(); + } else if (date && isTime && !isDate) { + // just time + resolvedValue = new Date(new Date().setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds())).getTime(); + } + return resolvedValue; + } } export class DefaultTreeGridMergeStrategy extends DefaultMergeStrategy { /* blazorSuppress */ - public override comparer(prevRecord: any, record: any, field: string): boolean { - const a = prevRecord.data[field]; - const b = record.data[field]; + public override comparer(prevRecord: any, record: any, field: string, isDate = false, isTime = false): boolean { + const a = this.getFieldValue( prevRecord.data, field, isDate, isTime); + const b = this.getFieldValue(record.data,field, isDate, isTime); const an = (a === null || a === undefined); const bn = (b === null || b === undefined); if (an) { @@ -123,9 +165,9 @@ export class DefaultTreeGridMergeStrategy extends DefaultMergeStrategy { export class ByLevelTreeGridMergeStrategy extends DefaultMergeStrategy { /* blazorSuppress */ - public override comparer(prevRecord: any, record: any, field: string): boolean { - const a = prevRecord.data[field]; - const b = record.data[field]; + public override comparer(prevRecord: any, record: any, field: string, isDate = false, isTime = false): boolean { + const a = this.getFieldValue( prevRecord.data, field, isDate, isTime); + const b = this.getFieldValue(record.data,field, isDate, isTime); const levelA = prevRecord.level; const levelB = record.level; const an = (a === null || a === undefined); diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index 9ac6d33f653..9d7adeb7cb6 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -1021,6 +1021,8 @@ class NoopMergeStrategy extends DefaultMergeStrategy { comparer: (prevRecord: any, record: any, field: string) => boolean = this.comparer, result: any[], activeRowIndexes: number[], + isDate?: boolean, + isTime?: boolean, grid?: GridType ) { return data; diff --git a/src/app/grid-cellMerging/grid-cellMerging.component.html b/src/app/grid-cellMerging/grid-cellMerging.component.html index 29b78e55884..73233eb9c1c 100644 --- a/src/app/grid-cellMerging/grid-cellMerging.component.html +++ b/src/app/grid-cellMerging/grid-cellMerging.component.html @@ -52,7 +52,7 @@

Grid with cell merge

- + From a503659ce8bc338379e89a191ee645701e03e97d Mon Sep 17 00:00:00 2001 From: MKirova Date: Mon, 18 Aug 2025 16:52:46 +0300 Subject: [PATCH 92/99] chore(*): Add test for date column. --- .../src/lib/grids/grid/cell-merge.spec.ts | 22 +++++++++++++++++++ .../src/lib/test-utils/grid-functions.spec.ts | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts index 9d7adeb7cb6..4bcf4a4ef54 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/cell-merge.spec.ts @@ -138,6 +138,28 @@ describe('IgxGrid - Cell merging #grid', () => { { value: 'Ignite UI for JavaScript', span: 9 } ]); }); + + it('should merge date column correctly.', () => { + const col = grid.getColumnByName('ReleaseDate'); + + grid.sort({ fieldName: 'ReleaseDate', dir: SortingDirection.Desc, ignoreCase: false }); + fix.detectChanges(); + + // merge date column + col.merge = true; + fix.detectChanges(); + + const today: Date = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate(), 0, 0, 0); + const nextDay = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() + 1, 0, 0, 0); + const prevDay = new Date(new Date().getFullYear(), new Date().getMonth(), new Date().getDate() - 1, 0, 0, 0); + + GridFunctions.verifyColumnMergedState(grid, col, [ + { value: nextDay, span: 2 }, + { value: today, span: 2 }, + { value: prevDay, span: 3 }, + { value: null, span: 2 } + ]); + }); }); describe('UI', () => { diff --git a/projects/igniteui-angular/src/lib/test-utils/grid-functions.spec.ts b/projects/igniteui-angular/src/lib/test-utils/grid-functions.spec.ts index 0a757a3ef3d..483332e700c 100644 --- a/projects/igniteui-angular/src/lib/test-utils/grid-functions.spec.ts +++ b/projects/igniteui-angular/src/lib/test-utils/grid-functions.spec.ts @@ -112,8 +112,8 @@ export class GridFunctions { const cellValue = row.cells.toArray().find(x => x.column === col).value; const rowSpan = row.metaData?.cellMergeMeta.get(col.field)?.rowSpan || 1; const currState = state[index - totalSpan]; - expect(cellValue).toBe(currState.value); - expect(rowSpan).toBe(currState.span); + expect(cellValue).toEqual(currState.value); + expect(rowSpan).toEqual(currState.span); totalSpan += (rowSpan - 1); index += (rowSpan - 1); } From 69ba10dd37258ad73b2d332d3e23185ef4c4d05c Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 20 Aug 2025 12:51:53 +0300 Subject: [PATCH 93/99] chore(*): Apply review comments. --- .../igniteui-angular/src/lib/data-operations/merge-strategy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts index 726140f962f..3768b84c458 100644 --- a/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts +++ b/projects/igniteui-angular/src/lib/data-operations/merge-strategy.ts @@ -137,7 +137,7 @@ export class DefaultMergeStrategy implements IGridMergeStrategy { resolvedValue = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0).getTime(); } else if (date && isTime && !isDate) { // just time - resolvedValue = new Date(new Date().setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds())).getTime(); + resolvedValue = new Date().setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); } return resolvedValue; } From 638df68bb4ab6d44ff23af601665499e1aa41794 Mon Sep 17 00:00:00 2001 From: MKirova Date: Wed, 20 Aug 2025 13:12:01 +0300 Subject: [PATCH 94/99] chore(*): Add scroll inertia in merged rows outside of virt.frame. --- .../src/lib/grids/grid/grid.component.html | 18 ++++++++++++------ .../src/lib/grids/grid/grid.component.ts | 4 +++- .../hierarchical-grid.component.html | 17 +++++++++++------ .../hierarchical-grid.component.ts | 4 +++- .../grids/tree-grid/tree-grid.component.html | 16 +++++++++++----- .../lib/grids/tree-grid/tree-grid.component.ts | 4 +++- 6 files changed, 43 insertions(+), 20 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html index 2569e614de8..c68eb11c48d 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.html @@ -47,13 +47,19 @@ - - @for (rowData of mergedDataInView; track rowData.record;) { - - + @if (mergedDataInView && mergedDataInView.length > 0) { +
+ @for (rowData of mergedDataInView; track rowData.record;) { + + + } +
} + @if (data | gridTransaction:id:pipeTrigger diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts index 7a670eff860..247975c757f 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.component.ts @@ -54,6 +54,7 @@ import { IgxGridBodyDirective } from '../grid.common'; import { IgxGridHeaderRowComponent } from '../headers/grid-header-row.component'; import { IgxGridGroupByAreaComponent } from '../grouping/grid-group-by-area.component'; import { Observable, Subject } from 'rxjs'; +import { IgxScrollInertiaDirective } from '../../directives/scroll-inertia/scroll_inertia.directive'; let NEXT_ID = 0; @@ -152,7 +153,8 @@ export interface IGroupingDoneEventArgs extends IBaseEventArgs { IgxGridSummaryPipe, IgxGridDetailsPipe, IgxStringReplacePipe, - IgxGridCellMergePipe + IgxGridCellMergePipe, + IgxScrollInertiaDirective ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html index 2bb8d815e2a..fa977e5f472 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.html @@ -30,12 +30,17 @@ [igxColumnMovingDrop]="headerContainer" [attr.droppable]="true" id="left" class="igx-grid__scroll-on-drag-pinned" [style.left.px]="pinnedWidth"> } - - @for (rowData of mergedDataInView; track rowData.record;) { - - + @if (mergedDataInView && mergedDataInView.length > 0) { +
+ @for (rowData of mergedDataInView; track rowData.record;) { + + + } +
} @if (data diff --git a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.ts b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.ts index c17c6d67207..988fa60ec80 100644 --- a/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/hierarchical-grid/hierarchical-grid.component.ts @@ -68,6 +68,7 @@ import { IgxGridHeaderRowComponent } from '../headers/grid-header-row.component' import { IgxActionStripToken } from '../../action-strip/token'; import { flatten } from '../../core/utils'; import { IFilteringExpressionsTree } from '../../data-operations/filtering-expressions-tree'; +import { IgxScrollInertiaDirective } from '../../directives/scroll-inertia/scroll_inertia.directive'; let NEXT_ID = 0; @@ -351,7 +352,8 @@ export class IgxChildGridRowComponent implements AfterViewInit, OnInit { IgxGridHierarchicalPipe, IgxGridHierarchicalPagingPipe, IgxStringReplacePipe, - IgxGridCellMergePipe + IgxGridCellMergePipe, + IgxScrollInertiaDirective ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html index 0c9a7f12a9a..7da608e583d 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.html @@ -31,11 +31,17 @@ class="igx-grid__scroll-on-drag-pinned" [style.left.px]="pinnedWidth"> } - @for (rowData of mergedDataInView; track rowData.record;) { - - + @if (mergedDataInView && mergedDataInView.length > 0) { +
+ @for (rowData of mergedDataInView; track rowData.record;) { + + + } +
} diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.ts b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.ts index 32a424a37a7..4820bca8339 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.ts +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.component.ts @@ -84,6 +84,7 @@ import { IgxGridHeaderRowComponent } from '../headers/grid-header-row.component' import { IgxTextHighlightService } from '../../directives/text-highlight/text-highlight.service'; import { IgxGridCellMergePipe } from '../grid/grid.pipes'; import { DefaultTreeGridMergeStrategy, IGridMergeStrategy } from '../../data-operations/merge-strategy'; +import { IgxScrollInertiaDirective } from '../../directives/scroll-inertia/scroll_inertia.directive'; let NEXT_ID = 0; @@ -171,7 +172,8 @@ let NEXT_ID = 0; IgxTreeGridNormalizeRecordsPipe, IgxTreeGridAddRowPipe, IgxStringReplacePipe, - IgxGridCellMergePipe + IgxGridCellMergePipe, + IgxScrollInertiaDirective ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) From 173532f0d6c24d38960a9b3e4fa85465edb1dc31 Mon Sep 17 00:00:00 2001 From: Stamen Stoychev Date: Tue, 26 Aug 2025 14:56:26 +0300 Subject: [PATCH 95/99] chore(*): merging pinning with cell merging --- .github/workflows/nodejs.yml | 2 +- CHANGELOG.md | 94 ++- .../src/analyzer/elements.config.ts | 23 +- .../i18n/BG/date-range-picker-resources.ts | 7 +- .../i18n/CS/date-range-picker-resources.ts | 7 +- .../i18n/DA/date-range-picker-resources.ts | 7 +- .../i18n/DE/date-range-picker-resources.ts | 7 +- .../i18n/ES/date-range-picker-resources.ts | 7 +- .../i18n/FR/date-range-picker-resources.ts | 7 +- .../i18n/HU/date-range-picker-resources.ts | 7 +- .../i18n/IT/date-range-picker-resources.ts | 7 +- .../i18n/JA/date-range-picker-resources.ts | 7 +- .../i18n/KO/date-range-picker-resources.ts | 7 +- .../i18n/NB/date-range-picker-resources.ts | 7 +- .../i18n/NL/date-range-picker-resources.ts | 7 +- .../i18n/PL/date-range-picker-resources.ts | 7 +- .../i18n/PT/date-range-picker-resources.ts | 7 +- .../i18n/RO/date-range-picker-resources.ts | 7 +- .../i18n/SV/date-range-picker-resources.ts | 7 +- .../i18n/TR/date-range-picker-resources.ts | 7 +- .../ZH-HANS/date-range-picker-resources.ts | 7 +- .../ZH-HANT/date-range-picker-resources.ts | 7 +- .../core/i18n/date-range-picker-resources.ts | 12 +- .../components/calendar/_calendar-theme.scss | 8 +- .../styles/components/combo/_combo-theme.scss | 14 - .../components/input/_input-group-theme.scss | 57 +- .../components/select/_select-theme.scss | 16 - .../tooltip/_tooltip-component.scss | 16 + .../components/tooltip/_tooltip-theme.scss | 50 +- .../calendar-container.component.html | 23 +- .../calendar-container.component.ts | 15 + .../date-range-picker-inputs.common.ts | 5 + .../date-range-picker.component.html | 2 +- .../date-range-picker.component.spec.ts | 260 +++++++- .../date-range-picker.component.ts | 87 ++- .../predefined-ranges-area-component.html | 7 + .../predefined-ranges-area-component.spec.ts | 158 +++++ .../predefined-ranges-area.component.ts | 73 +++ .../src/lib/directives/tooltip/README.md | 88 ++- .../src/lib/directives/tooltip/public_api.ts | 1 + .../tooltip/tooltip-close-button.component.ts | 28 + .../tooltip/tooltip-target.directive.ts | 467 +++++++++++---- .../lib/directives/tooltip/tooltip.common.ts | 332 +++++++++++ .../tooltip/tooltip.directive.spec.ts | 564 +++++++++++++++--- .../directives/tooltip/tooltip.directive.ts | 107 ++-- .../expansion-panel-header.component.html | 3 +- .../grids/columns/column-layout.component.ts | 15 +- .../src/lib/grids/columns/column.component.ts | 127 ++-- .../src/lib/grids/common/grid.interface.ts | 78 ++- .../excel-style-moving.component.ts | 16 +- .../src/lib/grids/grid-base.directive.ts | 149 +++-- .../lib/grids/grid/column-resizing.spec.ts | 103 +++- .../grids/grid/grid-mrl-keyboard-nav.spec.ts | 2 +- .../lib/grids/grid/grid-row.component.html | 18 +- .../src/lib/grids/grid/grid-row.component.ts | 5 - .../lib/grids/grid/grid-validation.spec.ts | 14 +- .../src/lib/grids/grid/grid.component.html | 9 +- .../src/lib/grids/grid/grid.groupby.spec.ts | 4 +- .../src/lib/grids/grid/grid.pinning.spec.ts | 276 ++++++++- .../headers/grid-header-row.component.html | 16 +- .../headers/grid-header-row.component.ts | 9 +- .../hierarchical-cell.component.ts | 2 +- .../hierarchical-grid.component.html | 9 +- .../hierarchical-grid.selection.spec.ts | 7 + .../hierarchical-row.component.html | 12 +- .../pivot-grid/pivot-grid.component.html | 6 +- .../grids/pivot-grid/pivot-grid.component.ts | 17 +- .../lib/grids/pivot-grid/pivot-grid.spec.ts | 6 +- .../pivot-header-row.component.html | 8 +- .../grids/resizing/resize-handle.directive.ts | 14 +- .../lib/grids/resizing/resizer.directive.ts | 4 +- .../src/lib/grids/row.directive.ts | 14 + .../summaries/summary-row.component.html | 12 +- .../grids/summaries/summary-row.component.ts | 21 +- .../tree-grid/tree-grid-row.component.html | 12 +- .../grids/tree-grid/tree-grid.component.html | 9 +- .../src/lib/icon/icon.component.spec.ts | 14 +- .../src/lib/icon/icon.component.ts | 8 + .../query-builder/query-builder.component.ts | 44 +- .../src/lib/select/select.component.html | 2 +- .../position/base-fit-position-strategy.ts | 6 +- .../connected-positioning-strategy.ts | 18 +- .../position/global-position-strategy.ts | 1 - .../src/lib/services/overlay/utilities.ts | 49 ++ .../src/lib/test-utils/grid-functions.spec.ts | 9 +- .../src/lib/test-utils/grid-samples.spec.ts | 23 +- .../lib/test-utils/tooltip-components.spec.ts | 31 +- src/app/date-range/date-range.sample.html | 5 +- src/app/date-range/date-range.sample.ts | 28 +- .../grid-column-moving.sample.html | 2 +- .../grid-column-moving.sample.ts | 6 +- .../grid-column-pinning.sample.html | 2 +- .../grid-column-pinning.sample.ts | 4 +- .../grid-mrl.sample.html | 8 +- .../grid-multi-row-layout/grid-mrl.sample.ts | 4 +- .../hierarchical-grid-add-row.sample.html | 13 +- .../hierarchical-grid-add-row.sample.ts | 17 +- src/app/tooltip/tooltip.sample.css | 8 + src/app/tooltip/tooltip.sample.html | 21 +- src/app/tooltip/tooltip.sample.ts | 9 +- src/app/tree-grid/tree-grid.sample.html | 2 +- src/app/tree-grid/tree-grid.sample.ts | 14 +- 102 files changed, 3378 insertions(+), 609 deletions(-) create mode 100644 projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area-component.html create mode 100644 projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area-component.spec.ts create mode 100644 projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area.component.ts create mode 100644 projects/igniteui-angular/src/lib/directives/tooltip/tooltip-close-button.component.ts create mode 100644 projects/igniteui-angular/src/lib/directives/tooltip/tooltip.common.ts diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 93a7a3ed1b6..c5d9b6c8d62 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [20.x, 22.x] + node-version: [20.x, 22.17.1] steps: - name: Checkout diff --git a/CHANGELOG.md b/CHANGELOG.md index e1b35d048a7..5c159f897d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,32 +2,57 @@ All notable changes for each version of this project will be documented in this file. + ## 20.1.0 ### New Features - `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid` - Introduced a new cell merging feature that allows you to configure and merge cells in a column based on same data or other custom condition, into a single cell. - - It can be enabled on the individual columns: - ```html - - ``` - The merging can be configured on the grid level to apply either: - - `onSort` - only when the column is sorted. - - `always` - always, regardless of data operations. + It can be enabled on the individual columns: - ```html + ```html + + ``` + The merging can be configured on the grid level to apply either: + - `onSort` - only when the column is sorted. + - `always` - always, regardless of data operations. + + ```html - ``` + ``` - The default `cellMergeMode` is `onSort`. + The default `cellMergeMode` is `onSort`. - The functionality can be modified by setting a custom `mergeStrategy` on the grid, in case some other merge conditions or logic is needed for a custom scenario. + The functionality can be modified by setting a custom `mergeStrategy` on the grid, in case some other merge conditions or logic is needed for a custom scenario. + + It's possible also to set a `mergeComparer` on the individual columns, in case some custom handling is needed for a particular data field. + + - Added ability to pin individual columns to a specific side (start or end of the grid), so that you can now have pinning from both sides. This can be done either declaratively by setting the `pinningPosition` property on the column: + + ```html + + + ``` + + ```ts + public pinningPosition = ColumnPinningPosition.End; + ``` + + Or with the API, via optional parameter: + + ```ts + grid.pinColumn('Col1', 0, ColumnPinningPosition.End); + grid.pinColumn('Col2', 0, ColumnPinningPosition.Start); + ``` + + If property `pinningPosition` is not set on a column, the column will default to the position specified on the grid's `pinning` options for `columns`. + +- `IgxDateRangePicker` + - Added cancel button to the dialog, allowing the user to cancel the selection. - It's possible also to set a `mergeComparer` on the individual columns, in case some custom handling is needed for a particular data field. - `IgxCarousel` - Added `select` method overload accepting index. @@ -35,9 +60,32 @@ All notable changes for each version of this project will be documented in this this.carousel.select(2, Direction.NEXT); ``` +- `IgxDateRangePicker` + - Added new properties: + - `usePredefinedRanges` - Whether to render built-in predefined ranges + - `customRanges` - Allows the user to provide custom ranges rendered as chips + - `resourceStrings` - Allows the user to provide set of resource strings + +- `IgxPredefinedRangesAreaComponent` + - Added new component for rendering the predefined or custom ranges inside the calendar of the `IgxDateRangePicker` + +- `IgxOverlay` + - Position Settings now accept a new optional `offset` input property of type `number`. Used to set the offset of the element from the target in pixels. + +- `IgxTooltip` + - The tooltip now remains open while interacting with it. +- `IgxTooltipTarget` + - Introduced several new properties to enhance customization of tooltip content and behavior. Those include `positionSettings`, `hasArrow`, `sticky`, `closeButtonTemplate`. For detailed usage and examples, please refer to the Tooltip [README](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/directives/tooltip/README.md). + + ### General - `IgxDropDown` now exposes a `role` input property, allowing users to customize the role attribute based on the use case. The default is `listbox`. +- `IgxTooltipTarget` + - **Behavioral Changes** + - The `showDelay` input property now defaults to `200`. + - The `hideDelay` input property now defaults to `300`. + - The `showTooltip` and `hideTooltip` methods do not take `showDelay`/`hideDelay` into account. ## 20.0.6 ### General @@ -130,24 +178,24 @@ All notable changes for each version of this project will be documented in this - Added the `canCommit`, `commit` and `discard` public methods that allows the user to save/discard the current state of the expression tree. - Added option to template the search value input: ``` - @if (selectedField?.field === 'Id' && selectedCondition === 'equals'){ - } @else { + } @else { } - + ``` - - **Behavioral Changes** + - **Behavioral Changes** - Expression enters edit mode on single click, `Enter` or `Space`. - Selecting conditions inside the `IgxQueryBuilderComponent` is no longer supported. Grouping/ungrouping expressions is now achieved via the newly exposed Drag & Drop functionality. - Deleting multiple expressions through the context menu is no longer supported. - `IgxQueryBuilderHeaderComponent` - - **Behavioral Change** + - **Behavioral Change** - Legend is no longer shown. - If the `title` input property is not set, by default it would be empty string. - **Deprecation** @@ -229,9 +277,9 @@ All notable changes for each version of this project will be documented in this ### Themes - **Breaking Change** `Palettes` - - All palette colors have been migrated to the [CSS relative colors syntax](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors/Relative_colors). This means that color consumed as CSS variables no longer need to be wrapped in an `hsl` function. + - All palette colors have been migrated to the [CSS relative colors syntax](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors/Relative_colors). This means that color consumed as CSS variables no longer need to be wrapped in an `hsl` function. - Example: + Example: ```css /* 18.1.x and before: */ background: hsl(var(--ig-primary-600)); @@ -242,7 +290,7 @@ All notable changes for each version of this project will be documented in this This change also opens up the door for declaring the base (500) variants of each color in CSS from any color, including other CSS variables, whereas before the Sass `palette` function was needed to generate color shades from a base color. - Example: + Example: ```scss /* 18.1.x and before: */ $my-palette: palette($primary: #09f, ...); @@ -282,7 +330,7 @@ For Firefox users, we provide limited scrollbar styling options through the foll - `animationType` input property is now of type `CarouselAnimationType`. `HorizontalAnimationType` can also be used, however, to accommodate the new vertical mode, which supports vertical slide animations, it is recommended to use `CarouselAnimationType`. - **Behavioral Changes** - the `keyboardSupport` input property now defaults to `false`. - - **Deprecation** - the `keyboardSupport` input property has been deprecated and will be removed in a future version. Keyboard navigation with `ArrowLeft`, `ArrowRight`, `Home`, and `End` keys will be supported when focusing the indicators' container via ` Tab`/`Shift+Tab`. + - **Deprecation** - the `keyboardSupport` input property has been deprecated and will be removed in a future version. Keyboard navigation with `ArrowLeft`, `ArrowRight`, `Home`, and `End` keys will be supported when focusing the indicators' container via ` Tab`/`Shift+Tab`. - `IgxCombo`: - **Breaking Change** The deprecated `filterable` property is replaced with `disableFiltering`. diff --git a/projects/igniteui-angular-elements/src/analyzer/elements.config.ts b/projects/igniteui-angular-elements/src/analyzer/elements.config.ts index 810ff415f4a..f17b7ad8fb8 100644 --- a/projects/igniteui-angular-elements/src/analyzer/elements.config.ts +++ b/projects/igniteui-angular-elements/src/analyzer/elements.config.ts @@ -277,6 +277,8 @@ export var registerConfig = [ { name: "defaultHeaderGroupMinWidth" }, { name: "columns" }, { name: "pinnedColumns" }, + { name: "pinnedStartColumns" }, + { name: "pinnedEndColumns" }, { name: "pinnedRows" }, { name: "unpinnedColumns" }, { name: "visibleColumns" }, @@ -330,7 +332,8 @@ export var registerConfig = [ "findPrev", "refreshSearch", "clearSearch", - "getPinnedWidth", + "getPinnedStartWidth", + "getPinnedEndWidth", "selectRows", "deselectRows", "selectAllRows", @@ -571,6 +574,8 @@ export var registerConfig = [ { name: "defaultHeaderGroupMinWidth" }, { name: "columns" }, { name: "pinnedColumns" }, + { name: "pinnedStartColumns" }, + { name: "pinnedEndColumns" }, { name: "pinnedRows" }, { name: "unpinnedColumns" }, { name: "visibleColumns" }, @@ -616,7 +621,8 @@ export var registerConfig = [ "findPrev", "refreshSearch", "clearSearch", - "getPinnedWidth", + "getPinnedStartWidth", + "getPinnedEndWidth", "selectRows", "deselectRows", "selectAllRows", @@ -751,6 +757,8 @@ export var registerConfig = [ { name: "defaultRowHeight" }, { name: "defaultHeaderGroupMinWidth" }, { name: "columns" }, + { name: "pinnedStartColumns" }, + { name: "pinnedEndColumns" }, { name: "visibleColumns" }, { name: "dataView" }, ], @@ -783,6 +791,7 @@ export var registerConfig = [ "clearFilter", "clearSort", "reflow", + "getPinnedEndWidth", "selectRows", "deselectRows", "selectAllRows", @@ -875,6 +884,8 @@ export var registerConfig = [ { name: "defaultRowHeight" }, { name: "defaultHeaderGroupMinWidth" }, { name: "columns" }, + { name: "pinnedStartColumns" }, + { name: "pinnedEndColumns" }, { name: "pinnedRows" }, ], methods: [ @@ -911,7 +922,8 @@ export var registerConfig = [ "findPrev", "refreshSearch", "clearSearch", - "getPinnedWidth", + "getPinnedStartWidth", + "getPinnedEndWidth", "selectRows", "deselectRows", "selectAllRows", @@ -1025,6 +1037,8 @@ export var registerConfig = [ { name: "defaultHeaderGroupMinWidth" }, { name: "columns" }, { name: "pinnedColumns" }, + { name: "pinnedStartColumns" }, + { name: "pinnedEndColumns" }, { name: "pinnedRows" }, { name: "unpinnedColumns" }, { name: "visibleColumns" }, @@ -1072,7 +1086,8 @@ export var registerConfig = [ "findPrev", "refreshSearch", "clearSearch", - "getPinnedWidth", + "getPinnedStartWidth", + "getPinnedEndWidth", "selectRows", "deselectRows", "selectAllRows", diff --git a/projects/igniteui-angular-i18n/src/i18n/BG/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/BG/date-range-picker-resources.ts index 654c8557353..6100c9f727a 100644 --- a/projects/igniteui-angular-i18n/src/i18n/BG/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/BG/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsBG = { igx_date_range_picker_date_separator: 'до', - igx_date_range_picker_done_button: 'Завърши' + igx_date_range_picker_done_button: 'Завърши', + igx_date_range_picker_cancel_button: 'Отмени', + igx_date_range_picker_last7Days: 'Последните 7 дни', + igx_date_range_picker_currentMonth: 'Текущ месец', + igx_date_range_picker_last30Days: 'Последните 30 дни', + igx_date_range_picker_yearToDate: 'От началото на годината' } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/CS/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/CS/date-range-picker-resources.ts index 8095dde0e71..2948610aa33 100644 --- a/projects/igniteui-angular-i18n/src/i18n/CS/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/CS/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsCS = { igx_date_range_picker_date_separator: 'na', - igx_date_range_picker_done_button: 'Hotovo' + igx_date_range_picker_done_button: 'Hotovo', + igx_date_range_picker_cancel_button: 'Zrušit', + igx_date_range_picker_last7Days: 'Posledních 7 dní', + igx_date_range_picker_currentMonth: 'Tento měsíc', + igx_date_range_picker_last30Days: 'Posledních 30 dní', + igx_date_range_picker_yearToDate: 'Od začátku roku', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/DA/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/DA/date-range-picker-resources.ts index 6d9ee56c3bb..3cb1539e56b 100644 --- a/projects/igniteui-angular-i18n/src/i18n/DA/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/DA/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsDA = { igx_date_range_picker_date_separator: 'till', - igx_date_range_picker_done_button: 'Færdigt' + igx_date_range_picker_done_button: 'Færdigt', + igx_date_range_picker_cancel_button: 'Annuller', + igx_date_range_picker_last7Days: 'Sidste 7 dage', + igx_date_range_picker_currentMonth: 'Denne måned', + igx_date_range_picker_last30Days: 'Sidste 30 dage', + igx_date_range_picker_yearToDate: 'Året til dato', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/DE/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/DE/date-range-picker-resources.ts index f9483f2d0c9..20d86f0f1c3 100644 --- a/projects/igniteui-angular-i18n/src/i18n/DE/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/DE/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsDE = { igx_date_range_picker_date_separator: 'bis', - igx_date_range_picker_done_button: 'Fertig' + igx_date_range_picker_done_button: 'Fertig', + igx_date_range_picker_cancel_button: 'Abbrechen', + igx_date_range_picker_last7Days: 'Letzte 7 Tage', + igx_date_range_picker_currentMonth: 'Aktueller Monat', + igx_date_range_picker_last30Days: 'Letzte 30 Tage', + igx_date_range_picker_yearToDate: 'Jahr bis heute', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/ES/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/ES/date-range-picker-resources.ts index a7367bbb588..70698ffd0a7 100644 --- a/projects/igniteui-angular-i18n/src/i18n/ES/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/ES/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsES = { igx_date_range_picker_date_separator: 'a', - igx_date_range_picker_done_button: 'Listo' + igx_date_range_picker_done_button: 'Listo', + igx_date_range_picker_cancel_button: 'Cancelar', + igx_date_range_picker_last7Days: 'Últimos 7 días', + igx_date_range_picker_currentMonth: 'Mes actual', + igx_date_range_picker_last30Days: 'Últimos 30 días', + igx_date_range_picker_yearToDate: 'Año hasta la fecha', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/FR/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/FR/date-range-picker-resources.ts index 4a9dc66a55e..e20d341495a 100644 --- a/projects/igniteui-angular-i18n/src/i18n/FR/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/FR/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsFR = { igx_date_range_picker_date_separator: 'à', - igx_date_range_picker_done_button: 'Terminée' + igx_date_range_picker_done_button: 'Terminée', + igx_date_range_picker_cancel_button: 'Annuler', + igx_date_range_picker_last7Days: '7 derniers jours', + igx_date_range_picker_currentMonth: 'Mois en cours', + igx_date_range_picker_last30Days: '30 derniers jours', + igx_date_range_picker_yearToDate: 'Année à ce jour', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/HU/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/HU/date-range-picker-resources.ts index e71b93c5a09..babadac03fd 100644 --- a/projects/igniteui-angular-i18n/src/i18n/HU/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/HU/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsHU = { igx_date_range_picker_date_separator: '-', - igx_date_range_picker_done_button: 'Kész' + igx_date_range_picker_done_button: 'Kész', + igx_date_range_picker_cancel_button: 'Mégse', + igx_date_range_picker_last7Days: 'Az elmúlt 7 nap', + igx_date_range_picker_currentMonth: 'Aktuális hónap', + igx_date_range_picker_last30Days: 'Az elmúlt 30 nap', + igx_date_range_picker_yearToDate: 'Év elejétől napjainkig', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/IT/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/IT/date-range-picker-resources.ts index a67887a21da..508bd953069 100644 --- a/projects/igniteui-angular-i18n/src/i18n/IT/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/IT/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsIT = { igx_date_range_picker_date_separator: 'a', - igx_date_range_picker_done_button: 'Fine' + igx_date_range_picker_done_button: 'Fine', + igx_date_range_picker_cancel_button: 'Annulla', + igx_date_range_picker_last7Days: 'Ultimi 7 giorni', + igx_date_range_picker_currentMonth: 'Mese corrente', + igx_date_range_picker_last30Days: 'Ultimi 30 giorni', + igx_date_range_picker_yearToDate: 'Anno fino ad oggi', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/JA/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/JA/date-range-picker-resources.ts index b2af1024ca0..15375c13059 100644 --- a/projects/igniteui-angular-i18n/src/i18n/JA/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/JA/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsJA = { igx_date_range_picker_date_separator: '~', - igx_date_range_picker_done_button: '完了' + igx_date_range_picker_done_button: '完了', + igx_date_range_picker_cancel_button: 'キャンセル', + igx_date_range_picker_last7Days: '過去7日間', + igx_date_range_picker_currentMonth: '今月', + igx_date_range_picker_last30Days: '過去30日間', + igx_date_range_picker_yearToDate: '年初来', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/KO/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/KO/date-range-picker-resources.ts index def99afdc7f..25076d94c2d 100644 --- a/projects/igniteui-angular-i18n/src/i18n/KO/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/KO/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsKO = { igx_date_range_picker_date_separator: '에', - igx_date_range_picker_done_button: '완료' + igx_date_range_picker_done_button: '완료', + igx_date_range_picker_cancel_button: '취소', + igx_date_range_picker_last7Days: '지난 7일', + igx_date_range_picker_currentMonth: '이번 달', + igx_date_range_picker_last30Days: '지난 30일', + igx_date_range_picker_yearToDate: '올해 초부터 현재까지', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/NB/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/NB/date-range-picker-resources.ts index e36e6959f84..793022218ef 100644 --- a/projects/igniteui-angular-i18n/src/i18n/NB/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/NB/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsNB = { igx_date_range_picker_date_separator: 'til', - igx_date_range_picker_done_button: 'Ferdig' + igx_date_range_picker_done_button: 'Ferdig', + igx_date_range_picker_cancel_button: 'Avbryt', + igx_date_range_picker_last7Days: 'Siste 7 dager', + igx_date_range_picker_currentMonth: 'Denne måneden', + igx_date_range_picker_last30Days: 'Siste 30 dager', + igx_date_range_picker_yearToDate: 'Året til dato', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/NL/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/NL/date-range-picker-resources.ts index 31ada214108..43d66ebc4ff 100644 --- a/projects/igniteui-angular-i18n/src/i18n/NL/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/NL/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsNL = { igx_date_range_picker_date_separator: 'tot', - igx_date_range_picker_done_button: 'Gereed' + igx_date_range_picker_done_button: 'Gereed', + igx_date_range_picker_cancel_button: 'Annuleren', + igx_date_range_picker_last7Days: 'Laatste 7 dagen', + igx_date_range_picker_currentMonth: 'Huidige maand', + igx_date_range_picker_last30Days: 'Laatste 30 dagen', + igx_date_range_picker_yearToDate: 'Jaar tot datum', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/PL/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/PL/date-range-picker-resources.ts index 41c9240ea27..2f46ac0cb93 100644 --- a/projects/igniteui-angular-i18n/src/i18n/PL/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/PL/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsPL = { igx_date_range_picker_date_separator: 'do', - igx_date_range_picker_done_button: 'Gotowe' + igx_date_range_picker_done_button: 'Gotowe', + igx_date_range_picker_cancel_button: 'Anuluj', + igx_date_range_picker_last7Days: 'Ostatnie 7 dni', + igx_date_range_picker_currentMonth: 'Bieżący miesiąc', + igx_date_range_picker_last30Days: 'Ostatnie 30 dni', + igx_date_range_picker_yearToDate: 'Od początku roku', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/PT/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/PT/date-range-picker-resources.ts index 6fdc9d0a1aa..ec9b08992d1 100644 --- a/projects/igniteui-angular-i18n/src/i18n/PT/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/PT/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsPT = { igx_date_range_picker_date_separator: 'para', - igx_date_range_picker_done_button: 'Concluído' + igx_date_range_picker_done_button: 'Concluído', + igx_date_range_picker_cancel_button: 'Cancelar', + igx_date_range_picker_last7Days: 'Últimos 7 dias', + igx_date_range_picker_currentMonth: 'Mês atual', + igx_date_range_picker_last30Days: 'Últimos 30 dias', + igx_date_range_picker_yearToDate: 'Ano até hoje', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/RO/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/RO/date-range-picker-resources.ts index a09900d3814..12e8c9007b0 100644 --- a/projects/igniteui-angular-i18n/src/i18n/RO/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/RO/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsRO = { igx_date_range_picker_date_separator: 'la', - igx_date_range_picker_done_button: 'Gata' + igx_date_range_picker_done_button: 'Gata', + igx_date_range_picker_cancel_button: 'Anulează', + igx_date_range_picker_last7Days: 'Ultimele 7 zile', + igx_date_range_picker_currentMonth: 'Luna curentă', + igx_date_range_picker_last30Days: 'Ultimele 30 de zile', + igx_date_range_picker_yearToDate: 'De la începutul anului', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/SV/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/SV/date-range-picker-resources.ts index d77a61d3077..7332568a6e1 100644 --- a/projects/igniteui-angular-i18n/src/i18n/SV/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/SV/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsSV = { igx_date_range_picker_date_separator: 'till', - igx_date_range_picker_done_button: 'Färdig' + igx_date_range_picker_done_button: 'Färdig', + igx_date_range_picker_cancel_button: 'Avbryt', + igx_date_range_picker_last7Days: 'Senaste 7 dagarna', + igx_date_range_picker_currentMonth: 'Aktuell månad', + igx_date_range_picker_last30Days: 'Senaste 30 dagarna', + igx_date_range_picker_yearToDate: 'Året hittills', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/TR/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/TR/date-range-picker-resources.ts index 1615b8d100c..d2664e3580a 100644 --- a/projects/igniteui-angular-i18n/src/i18n/TR/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/TR/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsTR = { igx_date_range_picker_date_separator: '-', - igx_date_range_picker_done_button: 'Bitti' + igx_date_range_picker_done_button: 'Bitti', + igx_date_range_picker_cancel_button: 'İptal', + igx_date_range_picker_last7Days: 'Son 7 gün', + igx_date_range_picker_currentMonth: 'Geçerli ay', + igx_date_range_picker_last30Days: 'Son 30 gün', + igx_date_range_picker_yearToDate: 'Yılbaşı itibarıyla', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/ZH-HANS/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/ZH-HANS/date-range-picker-resources.ts index 8a5cf97d998..3613a51959c 100644 --- a/projects/igniteui-angular-i18n/src/i18n/ZH-HANS/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/ZH-HANS/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsZHHANS = { igx_date_range_picker_date_separator: '至', - igx_date_range_picker_done_button: '完成' + igx_date_range_picker_done_button: '完成', + igx_date_range_picker_cancel_button: '取消', + igx_date_range_picker_last7Days: '最近7天', + igx_date_range_picker_currentMonth: '本月', + igx_date_range_picker_last30Days: '最近30天', + igx_date_range_picker_yearToDate: '年初至今', } satisfies MakeRequired; diff --git a/projects/igniteui-angular-i18n/src/i18n/ZH-HANT/date-range-picker-resources.ts b/projects/igniteui-angular-i18n/src/i18n/ZH-HANT/date-range-picker-resources.ts index 1095f5a3e5e..a58f09701a9 100644 --- a/projects/igniteui-angular-i18n/src/i18n/ZH-HANT/date-range-picker-resources.ts +++ b/projects/igniteui-angular-i18n/src/i18n/ZH-HANT/date-range-picker-resources.ts @@ -6,5 +6,10 @@ import { IDateRangePickerResourceStrings } from 'igniteui-angular'; */ export const DateRangePickerResourceStringsZHHANT = { igx_date_range_picker_date_separator: '到', - igx_date_range_picker_done_button: '完成' + igx_date_range_picker_done_button: '完成', + igx_date_range_picker_cancel_button: '取消', + igx_date_range_picker_last7Days: '最近7天', + igx_date_range_picker_currentMonth: '本月', + igx_date_range_picker_last30Days: '最近30天', + igx_date_range_picker_yearToDate: '年初至今', } satisfies MakeRequired; diff --git a/projects/igniteui-angular/src/lib/core/i18n/date-range-picker-resources.ts b/projects/igniteui-angular/src/lib/core/i18n/date-range-picker-resources.ts index 3b0d2729e02..2229be14bfe 100644 --- a/projects/igniteui-angular/src/lib/core/i18n/date-range-picker-resources.ts +++ b/projects/igniteui-angular/src/lib/core/i18n/date-range-picker-resources.ts @@ -1,9 +1,19 @@ export interface IDateRangePickerResourceStrings { igx_date_range_picker_date_separator?: string; igx_date_range_picker_done_button?: string; + igx_date_range_picker_cancel_button?: string; + igx_date_range_picker_last7Days?: string; + igx_date_range_picker_currentMonth?: string; + igx_date_range_picker_last30Days?: string; + igx_date_range_picker_yearToDate?: string; } export const DateRangePickerResourceStringsEN: IDateRangePickerResourceStrings = { igx_date_range_picker_date_separator: 'to', - igx_date_range_picker_done_button: 'Done' + igx_date_range_picker_done_button: 'Done', + igx_date_range_picker_cancel_button: 'Cancel', + igx_date_range_picker_last7Days: 'Last 7 Days', + igx_date_range_picker_currentMonth: 'Current Month', + igx_date_range_picker_last30Days: 'Last 30 Days', + igx_date_range_picker_yearToDate: 'Year to Date', }; diff --git a/projects/igniteui-angular/src/lib/core/styles/components/calendar/_calendar-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/calendar/_calendar-theme.scss index 5a8d23731e8..358cf91c790 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/calendar/_calendar-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/calendar/_calendar-theme.scss @@ -814,11 +814,9 @@ %date-inner { color: var-get($theme, 'weekend-color'); - @if $variant == 'indigo' { - &:hover { - color: var-get($theme, 'date-hover-foreground'); - } - } + &:hover { + color: var-get($theme, 'date-hover-foreground'); + } } } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/combo/_combo-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/combo/_combo-theme.scss index 6b0f7cf4657..00cfd1f7185 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/combo/_combo-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/combo/_combo-theme.scss @@ -136,20 +136,6 @@ } @if $variant == 'bootstrap' { - .igx-input-group--disabled { - %igx-combo__toggle-button { - border-inline-start-width: rem(1px); - border-inline-start-style: solid; - border-inline-start-color: inherit; - } - } - - igx-suffix:not(.igx-combo__clear-button) + %igx-combo__toggle-button { - border-inline-start-width: rem(1px); - border-inline-start-style: solid; - border-inline-start-color: inherit; - } - .igx-input-group__bundle::after { height: rem(1px) !important; } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/input/_input-group-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/input/_input-group-theme.scss index 1b036517f8e..66643c96e65 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/input/_input-group-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/input/_input-group-theme.scss @@ -612,7 +612,7 @@ %bootstrap-file-focused, %bootstrap-file-valid, %bootstrap-file-warning, - %bootstrap-file-invalid, + %bootstrap-file-invalid { %form-group-bundle { border-radius: var-get($theme, 'box-border-radius'); @@ -670,11 +670,27 @@ %form-group-bundle { box-shadow: 0 0 0 rem(4px) var-get($theme, 'success-shadow-color'); } + + %form-group-prefix:not(:first-child) { + border-inline-start-color: var-get($theme, 'success-secondary-color'); + } + + %form-group-suffix:not(:last-child) { + border-inline-end-color: var-get($theme, 'success-secondary-color'); + } } %bootstrap-file-warning-focused { %form-group-bundle { box-shadow: 0 0 0 rem(4px) color($color: 'warn', $variant: '500', $opacity: .38); + + %form-group-prefix:not(:first-child) { + border-inline-start-color: var-get($theme, 'warning-secondary-color'); + } + + %form-group-suffix:not(:last-child) { + border-inline-end-color: var-get($theme, 'warning-secondary-color'); + } } } @@ -682,6 +698,14 @@ %form-group-bundle { box-shadow: 0 0 0 rem(4px) var-get($theme, 'error-shadow-color'); } + + %form-group-prefix:not(:first-child) { + border-inline-start-color: var-get($theme, 'error-secondary-color'); + } + + %form-group-suffix:not(:last-child) { + border-inline-end-color: var-get($theme, 'error-secondary-color'); + } } @if $variant == 'bootstrap' { @@ -691,6 +715,14 @@ %upload-button { border-color: var-get($theme, 'focused-border-color'); } + + %form-group-prefix:not(:first-child) { + border-inline-start-color: var-get($theme, 'focused-border-color'); + } + + %form-group-suffix:not(:last-child) { + border-inline-end-color: var-get($theme, 'focused-border-color'); + } } } @@ -2015,11 +2047,6 @@ }; } } - - .igx-input-group__clear-icon + igx-suffix, - .igx-input-group__clear-icon + [igxPrefix] { - border-inline-start: rem(1px) solid var-get($theme, 'border-color'); - } } %form-group-display--bootstrap-suffixed-focused { @@ -2284,6 +2311,24 @@ border-color: var-get($theme, 'disabled-border-color'); color: var-get($theme, 'disabled-text-color'); } + + %form-group-prefix--disabled-bootstrap:not(:first-child) { + border-inline-start-color: var-get($theme, 'disabled-border-color'); + } + + %form-group-suffix--disabled-bootstrap:not(:last-child) { + border-inline-end-color: var-get($theme, 'disabled-border-color'); + } + + @if $variant == 'bootstrap' { + %form-group-prefix:not(:first-child) { + border-inline-start: rem(1px) solid var-get($theme, 'border-color'); + } + + %form-group-suffix:not(:last-child) { + border-inline-end: rem(1px) solid var-get($theme, 'border-color'); + } + } } /// Adds typography styles for the igx-input-group component. diff --git a/projects/igniteui-angular/src/lib/core/styles/components/select/_select-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/select/_select-theme.scss index 07a5faf511b..388d18940f6 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/select/_select-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/select/_select-theme.scss @@ -13,22 +13,6 @@ display: block; } - @if $variant == 'bootstrap' { - .igx-input-group--disabled { - %igx-select__toggle-button { - border-inline-start-width: rem(1px); - border-inline-start-style: solid; - border-inline-start-color: inherit; - } - } - - igx-suffix + %igx-select__toggle-button { - border-inline-start-width: rem(1px); - border-inline-start-style: solid; - border-inline-start-color: inherit; - } - } - .igx-input-group { %igx-select__toggle-button { background: var-get($theme, 'toggle-button-background'); diff --git a/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-component.scss b/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-component.scss index 003fd366ba9..a58b1bb237a 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-component.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-component.scss @@ -10,6 +10,22 @@ @extend %tooltip-display !optional; + @include m(top) { + @extend %arrow--top !optional; + } + + @include m(bottom) { + @extend %arrow--bottom !optional; + } + + @include m(left) { + @extend %arrow--left !optional; + } + + @include m(right) { + @extend %arrow--right !optional; + } + @include m(hidden) { @extend %tooltip--hidden !optional; } diff --git a/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-theme.scss b/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-theme.scss index c5ac8b82e8d..2af405f6499 100644 --- a/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-theme.scss +++ b/projects/igniteui-angular/src/lib/core/styles/components/tooltip/_tooltip-theme.scss @@ -8,23 +8,58 @@ @include css-vars($theme); $variant: map.get($theme, '_meta', 'theme'); + $transparent-border: rem(4px) solid transparent; + $color-border: rem(4px) solid var-get($theme, 'background'); + %tooltip-display { - display: inline-flex; - justify-content: center; - flex-flow: column wrap; + display: flex; + align-items: flex-start; + text-align: start; background: var-get($theme, 'background'); color: var-get($theme, 'text-color'); border-radius: var-get($theme, 'border-radius'); box-shadow: map.get($theme, 'shadow'); - margin: 0 auto; - padding: 0 rem(8px); + padding: rem(4px) rem(8px); + gap: rem(8px); min-height: rem(24px); + min-width: rem(24px); + max-width: 200px; + width: fit-content; + + igx-icon { + --component-size: 1; + } - @if $variant == 'indigo' { - padding: rem(4px) rem(8px); + igx-tooltip-close-button { + display: flex; + cursor: default; } } + %arrow--top { + border-left: $transparent-border; + border-right: $transparent-border; + border-top: $color-border; + } + + %arrow--bottom { + border-left: $transparent-border; + border-right: $transparent-border; + border-bottom: $color-border; + } + + %arrow--left { + border-top: $transparent-border; + border-bottom: $transparent-border; + border-left: $color-border; + } + + %arrow--right { + border-top: $transparent-border; + border-bottom: $transparent-border; + border-right: $color-border; + } + %tooltip--hidden { display: none; } @@ -45,6 +80,7 @@ } } @else { %tooltip-display { + line-height: rem(16px); font-size: rem(10px); font-weight: 600; } diff --git a/projects/igniteui-angular/src/lib/date-common/calendar-container/calendar-container.component.html b/projects/igniteui-angular/src/lib/date-common/calendar-container/calendar-container.component.html index 0d54b8105b3..72eafaf7a3f 100644 --- a/projects/igniteui-angular/src/lib/date-common/calendar-container/calendar-container.component.html +++ b/projects/igniteui-angular/src/lib/date-common/calendar-container/calendar-container.component.html @@ -1,13 +1,22 @@ - @if (closeButtonLabel || todayButtonLabel) { + @if (closeButtonLabel || cancelButtonLabel || todayButtonLabel) {
+ @if (cancelButtonLabel) { + + } @if (closeButtonLabel) { @@ -28,10 +37,18 @@ + @if( usePredefinedRanges || (customRanges?.length || 0) > 0 ){ + + + } @if (pickerActions?.template || (closeButtonLabel || todayButtonLabel)) { } -@if (pickerActions?.template || (closeButtonLabel || todayButtonLabel)) { +@if (pickerActions?.template || (closeButtonLabel || cancelButtonLabel || todayButtonLabel)) {
(); + @Output() + public calendarCancel = new EventEmitter(); + @Output() public todaySelection = new EventEmitter(); + @Output() + public rangeSelected = new EventEmitter(); + + @HostBinding('class.igx-date-picker') public styleClass = 'igx-date-picker'; @@ -45,8 +56,12 @@ export class IgxCalendarContainerComponent { return this.mode === PickerInteractionMode.DropDown; } + public usePredefinedRanges = false; + public customRanges: CustomDateRange[] = []; + public resourceStrings!: IDateRangePickerResourceStrings; public vertical = false; public closeButtonLabel: string; + public cancelButtonLabel: string; public todayButtonLabel: string; public mode: PickerInteractionMode = PickerInteractionMode.DropDown; public pickerActions: IgxPickerActionsDirective; diff --git a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker-inputs.common.ts b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker-inputs.common.ts index ea426fde4b9..9eb9c3b9b42 100644 --- a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker-inputs.common.ts +++ b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker-inputs.common.ts @@ -17,6 +17,11 @@ export interface DateRange { start: Date | string; end: Date | string; } +/** Represents a range between two dates and a label used for predefined and custom date ranges. */ +export interface CustomDateRange { + label: string; + dateRange: DateRange; +} /** @hidden @internal */ @Pipe({ diff --git a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.html b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.html index 6ea322900a2..ef9c89352b7 100644 --- a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.html +++ b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.html @@ -16,7 +16,7 @@ - + {{ dateSeparator }} diff --git a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.spec.ts b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.spec.ts index 01d03dc791d..d310ec0fa50 100644 --- a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.spec.ts +++ b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.spec.ts @@ -9,7 +9,7 @@ import { ControlsFunction } from '../test-utils/controls-functions.spec'; import { UIInteractions } from '../test-utils/ui-interactions.spec'; import { HelperTestFunctions } from '../test-utils/calendar-helper-utils'; import { CancelableEventArgs } from '../core/utils'; -import { DateRange, IgxDateRangeSeparatorDirective, IgxDateRangeStartComponent } from './date-range-picker-inputs.common'; +import { CustomDateRange, DateRange, IgxDateRangeSeparatorDirective, IgxDateRangeStartComponent } from './date-range-picker-inputs.common'; import { IgxDateTimeEditorDirective } from '../directives/date-time-editor/public_api'; import { DateRangeType } from '../core/dates'; import { IgxDateRangePickerComponent, IgxDateRangeEndComponent } from './public_api'; @@ -25,6 +25,7 @@ import { IgxIconComponent } from '../icon/icon.component'; import { registerLocaleData } from "@angular/common"; import localeJa from "@angular/common/locales/ja"; import localeBg from "@angular/common/locales/bg"; +import { CalendarDay } from '../calendar/common/model'; // The number of milliseconds in one day const DEBOUNCE_TIME = 16; @@ -38,7 +39,7 @@ const CSS_CLASS_INPUT_GROUP_REQUIRED = 'igx-input-group--required'; const CSS_CLASS_INPUT_GROUP_INVALID = 'igx-input-group--invalid'; const CSS_CLASS_CALENDAR = 'igx-calendar'; const CSS_CLASS_ICON = 'igx-icon'; -const CSS_CLASS_DONE_BUTTON = 'igx-button--flat'; +const CSS_CLASS_DIALOG_BUTTON = 'igx-button--flat'; const CSS_CLASS_LABEL = 'igx-input-group__label'; const CSS_CLASS_OVERLAY_CONTENT = 'igx-overlay__content'; const CSS_CLASS_DATE_RANGE = 'igx-date-range-picker'; @@ -280,7 +281,7 @@ describe('IgxDateRangePicker', () => { let calendar: DebugElement | Element; let calendarDays: DebugElement[] | HTMLCollectionOf; - const selectDateRangeFromCalendar = (sDate: Date, eDate: Date) => { + const selectDateRangeFromCalendar = (sDate: Date, eDate: Date, autoClose:boolean = true) => { dateRange.open(); fixture.detectChanges(); calendarDays = document.getElementsByClassName(CSS_CLASS_CALENDAR_DATE); @@ -300,9 +301,13 @@ describe('IgxDateRangePicker', () => { if (endIndex !== -1 && endIndex !== startIndex) { // do not click same date twice UIInteractions.simulateClickAndSelectEvent(calendarDays[endIndex].firstChild as HTMLElement); } + fixture.detectChanges(); - dateRange.close(); - fixture.detectChanges(); + + if (autoClose){ + dateRange.close(); + fixture.detectChanges(); + } }; describe('Single Input', () => { @@ -484,7 +489,7 @@ describe('IgxDateRangePicker', () => { fixture.detectChanges(); expect(dateRange.collapsed).toBeFalsy(); - const doneBtn = document.getElementsByClassName(CSS_CLASS_DONE_BUTTON)[0]; + const doneBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[0]; UIInteractions.simulateClickAndSelectEvent(doneBtn); tick(); fixture.detectChanges(); @@ -496,14 +501,46 @@ describe('IgxDateRangePicker', () => { expect(dateRange.closed.emit).toHaveBeenCalledWith({ owner: dateRange }); })); - it('should show the "Done" button only in dialog mode', fakeAsync(() => { + it('should close the calendar with the "Cancel" button and retain original value', fakeAsync(() => { + fixture.componentInstance.mode = PickerInteractionMode.Dialog; + const orig = { start: new Date(2020, 0, 1), end: new Date(2020, 0, 5) }; + fixture.componentInstance.dateRange.value = orig; + fixture.detectChanges(); + + spyOn(dateRange.closing, 'emit').and.callThrough(); + spyOn(dateRange.closed, 'emit').and.callThrough(); + + dateRange.open(); + tick(); + fixture.detectChanges(); + expect(dateRange.collapsed).toBeFalsy(); + + selectDateRangeFromCalendar(new Date(2020, 0, 8), new Date(2020, 0, 12), false); + + const cancelBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[0]; + UIInteractions.simulateClickAndSelectEvent(cancelBtn); + tick(); + fixture.detectChanges(); + + expect(dateRange.collapsed).toBeTrue(); + expect(dateRange.closing.emit).toHaveBeenCalledTimes(1); + expect(dateRange.closing.emit).toHaveBeenCalledWith({ owner: dateRange, cancel: false, event: undefined }); + expect(dateRange.closed.emit).toHaveBeenCalledTimes(1); + expect(dateRange.closed.emit).toHaveBeenCalledWith({ owner: dateRange }); + + expect(fixture.componentInstance.dateRange.value).toEqual(orig); + })); + + it('should show the "Done" and "Cancel" buttons only in dialog mode', fakeAsync(() => { fixture.componentInstance.mode = PickerInteractionMode.Dialog; fixture.detectChanges(); dateRange.open(); fixture.detectChanges(); - let doneBtn = document.getElementsByClassName(CSS_CLASS_DONE_BUTTON)[0]; + let doneBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[0]; + let cancelBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[1]; expect(doneBtn).not.toBe(null); + expect(cancelBtn).not.toBe(null); dateRange.close(); tick(); fixture.detectChanges(); @@ -514,8 +551,10 @@ describe('IgxDateRangePicker', () => { dateRange.open(); tick(); fixture.detectChanges(); - doneBtn = document.getElementsByClassName(CSS_CLASS_DONE_BUTTON)[0]; + doneBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[0]; + cancelBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[1]; expect(doneBtn).not.toBeDefined(); + expect(cancelBtn).not.toBeDefined(); })); it('should be able to change the "Done" button text', fakeAsync(() => { @@ -524,19 +563,24 @@ describe('IgxDateRangePicker', () => { dateRange.toggle(); fixture.detectChanges(); - let doneBtn = document.getElementsByClassName(CSS_CLASS_DONE_BUTTON)[0]; + let doneBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[1]; + let cancelBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[0]; expect(doneBtn.textContent.trim()).toEqual('Done'); + expect(cancelBtn.textContent.trim()).toEqual('Cancel'); dateRange.toggle(); tick(); fixture.detectChanges(); dateRange.doneButtonText = 'Close'; + dateRange.cancelButtonText = 'Discard' fixture.detectChanges(); dateRange.toggle(); tick(); fixture.detectChanges(); - doneBtn = document.getElementsByClassName(CSS_CLASS_DONE_BUTTON)[0]; + doneBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[1]; + cancelBtn = document.getElementsByClassName(CSS_CLASS_DIALOG_BUTTON)[0]; expect(doneBtn.textContent.trim()).toEqual('Close'); + console.log(cancelBtn.textContent.trim()); })); it('should emit open/close events - open/close methods', fakeAsync(() => { @@ -1276,6 +1320,26 @@ describe('IgxDateRangePicker', () => { expect(dateRange.opening.emit).toHaveBeenCalledTimes(0); expect(dateRange.opened.emit).toHaveBeenCalledTimes(0); })); + + it('should keep the calendar open when input is focused by click and while typing', fakeAsync(() => { + fixture.componentInstance.dateRange.open(); + fixture.detectChanges(); + tick(DEBOUNCE_TIME); + + startInput.triggerEventHandler('focus', {}); + fixture.detectChanges(); + UIInteractions.simulateClickAndSelectEvent(startInput.nativeElement); + fixture.detectChanges(); + tick(DEBOUNCE_TIME); + + expect(dateRange.collapsed).toBeFalsy(); + + UIInteractions.simulateTyping('01/10/202', startInput); + fixture.detectChanges(); + tick(DEBOUNCE_TIME); + + expect(dateRange.collapsed).toBeFalsy(); + })); }); it('should focus the last focused input after the calendar closes - dropdown', fakeAsync(() => { @@ -1382,6 +1446,173 @@ describe('IgxDateRangePicker', () => { expect((fixture.componentInstance.dateRange.value.end as Date).getTime()).toEqual(range.end.getTime()); })); }); + + describe('Predefined ranges', ()=> { + const predefinedRangesLength = 4; + const today = CalendarDay.today.native; + const last7DaysEnd = CalendarDay.today.add('day', -7).native; + const last30DaysEnd = CalendarDay.today.add('day', -29).native; + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); + const startOfYear = new Date(today.getFullYear(), 0, 1); + const previousThreeDaysStart = CalendarDay.today.add('day', -3).native; + const nextThreeDaysEnd = CalendarDay.today.add('day', 3).native; + + const customRanges: CustomDateRange[] = [ + { + label: 'Previous Three Days', + dateRange: { + start: previousThreeDaysStart, + end: today, + }, + }, + { + label: 'Next Three Days', + dateRange: { + start: today, + end: nextThreeDaysEnd, + }, + }, + ]; + + const dateRanges: DateRange[] = [ + {start: last7DaysEnd, end: today}, + {start: startOfMonth, end: endOfMonth}, + {start: last30DaysEnd, end: today}, + {start: startOfYear, end: today}, + {start: previousThreeDaysStart, end: today}, + {start: today, end: nextThreeDaysEnd}, + ]; + + beforeEach(() => { + fixture = TestBed.createComponent(DateRangeTwoInputsTestComponent); + fixture.detectChanges(); + dateRange = fixture.componentInstance.dateRange; + + + }); + + it('should not render predefined area when usePredefinedRanges is false and no custom ranges are provided', () => { + dateRange.open(); + fixture.detectChanges(); + + const predefinedArea = document.querySelector('igx-predefined-ranges-area'); + const chips = document.querySelectorAll('igx-chip'); + + console.log(predefinedArea); + + expect(predefinedArea).toBeNull(); + expect(chips.length).toEqual(0); + + }); + + it('should render predefined area when usePredefinedRanges is true and no custom ranges are provided', () => { + dateRange.usePredefinedRanges = true; + fixture.detectChanges(); + + dateRange.open(); + fixture.detectChanges(); + + const predefinedArea = document.querySelector('igx-predefined-ranges-area'); + const chips = document.querySelectorAll('igx-chip'); + + expect(predefinedArea).toBeDefined(); + expect(chips.length).toEqual(predefinedRangesLength); + }); + + it('should render predefined area when only custom ranges are provided', () => { + dateRange.customRanges = customRanges; + fixture.detectChanges(); + + dateRange.open(); + fixture.detectChanges(); + + const predefinedArea = document.querySelector('igx-predefined-ranges-area'); + const chips = document.querySelectorAll('igx-chip'); + + expect(predefinedArea).toBeDefined(); + expect(chips.length).toEqual(customRanges.length); + }); + + it('should render predefined area when usePredefinedRanges is true and custom ranges are provided', () => { + dateRange.usePredefinedRanges = true; + dateRange.customRanges = customRanges; + fixture.detectChanges(); + + dateRange.open(); + fixture.detectChanges(); + + const predefinedArea = document.querySelector('igx-predefined-ranges-area'); + const chips = document.querySelectorAll('igx-chip'); + + expect(predefinedArea).toBeDefined(); + expect(chips.length).toEqual(predefinedRangesLength + customRanges.length); + }); + + it('should render predefined area and emit selection event when the user performs selection via chips', () => { + const selectionSpy = spyOn(dateRange as any, 'handleSelection').and.callThrough(); + + dateRange.usePredefinedRanges = true; + dateRange.customRanges = customRanges; + fixture.detectChanges(); + + dateRange.open(); + fixture.detectChanges(); + + const predefinedArea = document.querySelector('igx-predefined-ranges-area'); + const chips = document.querySelectorAll('igx-chip'); + + expect(predefinedArea).toBeDefined(); + expect(chips.length).toEqual(predefinedRangesLength + customRanges.length); + + + chips.forEach((chip, i) => { + chip.dispatchEvent(UIInteractions.getMouseEvent('click')); + fixture.detectChanges(); + expect(dateRange.value).toEqual(dateRanges[i]); + + }); + + expect(selectionSpy).toHaveBeenCalledTimes(predefinedRangesLength + customRanges.length); + }); + + it('should use provided resourceStrings for labels when available', () => { + const strings: any = { + last7Days: 'Last 7 - localized', + currentMonth: 'Current Month - localized', + yearToDate: 'YTD - localized', + igx_date_range_picker_last7Days: 'Last 7 - localized', + igx_date_range_picker_currentMonth: 'Current Month - localized', + igx_date_range_picker_yearToDate: 'YTD - localized', + // last30Days omitted to test fallback + }; + + dateRange.resourceStrings = strings; + dateRange.usePredefinedRanges = true; + dateRange.customRanges = []; + fixture.detectChanges(); + + dateRange.open(); + fixture.detectChanges(); + + const predefinedArea = document.querySelector('igx-predefined-ranges-area'); + const chips = document.querySelectorAll('igx-chip'); + + expect(predefinedArea).toBeDefined(); + expect(chips.length).toEqual(predefinedRangesLength); + const labels: string[] = []; + + chips.forEach((chip) => { + labels.push(chip.textContent.trim()); + }); + + expect(labels).toContain('Last 7 - localized'); + expect(labels).toContain('Current Month - localized'); + expect(labels).toContain('YTD - localized'); + + expect(labels).toContain('Last 30 Days'); + }); + }); }); describe('Rendering', () => { @@ -1606,7 +1837,10 @@ export class DateRangeDefaultComponent extends DateRangeTestComponent { [disabled]="disabled" [(ngModel)]="range" [inputFormat]="inputFormat" - [displayFormat]="displayFormat" required> + [displayFormat]="displayFormat" + required + [usePredefinedRanges]="usePredefinedRanges" + [customRanges]="customRanges"> calendar_view_day @@ -1637,6 +1871,8 @@ export class DateRangeTwoInputsTestComponent extends DateRangeTestComponent { public inputFormat: string; public displayFormat: string; public override disabled = false; + public usePredefinedRanges = false; + public customRanges: CustomDateRange[] = []; } @Component({ selector: 'igx-date-range-two-inputs-ng-model', diff --git a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.ts b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.ts index e6b5edca0ba..1d1c20c4af5 100644 --- a/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.ts +++ b/projects/igniteui-angular/src/lib/date-range-picker/date-range-picker.component.ts @@ -30,7 +30,7 @@ import { AutoPositionStrategy, IgxOverlayService, OverlayCancelableEventArgs, OverlayEventArgs, OverlaySettings, PositionSettings } from '../services/public_api'; -import { DateRange, IgxDateRangeEndComponent, IgxDateRangeInputsBaseComponent, IgxDateRangeSeparatorDirective, IgxDateRangeStartComponent, DateRangePickerFormatPipe } from './date-range-picker-inputs.common'; +import { DateRange, IgxDateRangeEndComponent, IgxDateRangeInputsBaseComponent, IgxDateRangeSeparatorDirective, IgxDateRangeStartComponent, DateRangePickerFormatPipe, CustomDateRange } from './date-range-picker-inputs.common'; import { IgxPrefixDirective } from '../directives/prefix/prefix.directive'; import { IgxIconComponent } from '../icon/icon.component'; import { getCurrentResourceStrings } from '../core/i18n/resources'; @@ -149,6 +149,29 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective } return this._doneButtonText; } + /** + * Overrides the default text of the calendar dialog **Cancel** button. + * + * @remarks + * Defaults to the value from resource strings, `"Cancel"` for the built-in EN. + * The button will only show up in `dialog` mode. + * + * @example + * ```html + * + * ``` + */ + @Input() + public set cancelButtonText(value: string) { + this._cancelButtonText = value; + } + + public get cancelButtonText(): string { + if (this._cancelButtonText === null) { + return this.resourceStrings.igx_date_range_picker_cancel_button; + } + return this._cancelButtonText; + } /** * Custom overlay settings that should be used to display the calendar. * @@ -283,6 +306,27 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective * * ``` */ + + /** + * Whether to render built-in predefined ranges. + * + * @example + * ```html + * + * `` + * */ + @Input() public usePredefinedRanges = false; + + /** + * Custom ranges rendered as chips. + * + * @example + * ```html + * + * `` + */ + @Input() public customRanges: CustomDateRange[] = []; + @Output() public valueChange = new EventEmitter(); @@ -439,8 +483,10 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective private _resourceStrings = getCurrentResourceStrings(DateRangePickerResourceStringsEN); private _doneButtonText = null; + private _cancelButtonText = null; private _dateSeparator = null; private _value: DateRange | null; + private _originalValue: DateRange | null; private _overlayId: string; private _ngControl: NgControl; private _statusChanges$: Subscription; @@ -511,6 +557,10 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective return; } + this._originalValue = this._value + ? { start: new Date(this._value.start), end: new Date(this._value.end) } + : null; + const settings = Object.assign({}, this.isDropdown ? this.dropdownOverlaySettings : this.dialogOverlaySettings @@ -789,7 +839,11 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective this.opened.emit({ owner: this }); }); - this._overlayService.closing.pipe(...this._overlaySubFilter).subscribe((e) => { + this._overlayService.closing.pipe(...this._overlaySubFilter).subscribe((e: OverlayCancelableEventArgs) => { + const isEscape = e.event && (e.event as KeyboardEvent).key === this.platform.KEYMAP.ESCAPE; + if (this.isProjectedInputTarget(e.event) && !isEscape) { + e.cancel = true; + } this.handleClosing(e as OverlayCancelableEventArgs); }); @@ -803,6 +857,16 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective }); } + private isProjectedInputTarget(event: Event): boolean { + if (!this.hasProjectedInputs || !event) { + return false; + } + const path = event.composed ? event.composedPath() : [event.target]; + return this.projectedInputs.some(i => + path.includes(i.dateTimeEditor.nativeElement) + ); + } + private updateValue(value: DateRange) { this._value = value ? value : null; this.updateInputs(); @@ -1091,7 +1155,26 @@ export class IgxDateRangePickerComponent extends PickerBaseDirective componentInstance.mode = this.mode; componentInstance.closeButtonLabel = !this.isDropdown ? this.doneButtonText : null; + componentInstance.cancelButtonLabel = !this.isDropdown ? this.cancelButtonText : null; componentInstance.pickerActions = this.pickerActions; + componentInstance.usePredefinedRanges = this.usePredefinedRanges; + componentInstance.customRanges = this.customRanges; + componentInstance.resourceStrings = this.resourceStrings; componentInstance.calendarClose.pipe(takeUntil(this._destroy$)).subscribe(() => this.close()); + componentInstance.calendarCancel.pipe(takeUntil(this._destroy$)).subscribe(() => { + this._value = this._originalValue; + this.close() + }); + componentInstance.rangeSelected + .pipe(takeUntil(this._destroy$)) + .subscribe((r: DateRange) => { + if (r?.start && r?.end) { + this.select(new Date(r.start), new Date(r.end)); + } + + if (this.isDropdown) { + this.close(); + } + }); } } diff --git a/projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area-component.html b/projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area-component.html new file mode 100644 index 00000000000..4462f89684d --- /dev/null +++ b/projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area-component.html @@ -0,0 +1,7 @@ +
+ @for (r of ranges; track r.label) { + + {{ r.label }} + + } +
diff --git a/projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area-component.spec.ts b/projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area-component.spec.ts new file mode 100644 index 00000000000..509d86b9ea5 --- /dev/null +++ b/projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area-component.spec.ts @@ -0,0 +1,158 @@ +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { IgxPredefinedRangesAreaComponent } from './predefined-ranges-area.component'; +import { CalendarDay } from '../../calendar/common/model'; +import { CustomDateRange } from '../date-range-picker-inputs.common'; +import { IDateRangePickerResourceStrings } from '../../core/i18n/date-range-picker-resources'; +import { IgxChipComponent } from '../../chips/chip.component'; +import { IgxChipsModule } from 'igniteui-angular'; +import { Component, ViewChild } from '@angular/core'; + +describe('IgxPredefinedRangesAreaComponent', () => { + let fixture: ComponentFixture; + let component: PredefinedRangesDefaultComponent; + let predefinedRanges:IgxPredefinedRangesAreaComponent; + + const customRanges: CustomDateRange[] = [ + { + label: 'Previous Three Months', + dateRange: { + start: CalendarDay.today.add('month', -3).set({ date: 1 }).native, + end: CalendarDay.today.set({ date: 1 }).add('day', -1).native, + }, + }, + { + label: 'Next Three Months', + dateRange: { + start: CalendarDay.today.add('month', 1).set({ date: 1 }).native, + end: CalendarDay.today.add('month', 4).add('day', -1).native, + }, + }, + ]; + + function getChips() { + return fixture.debugElement.queryAll(By.css('igx-chip')); + } + + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [IgxPredefinedRangesAreaComponent, IgxChipComponent, IgxChipsModule, PredefinedRangesDefaultComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(PredefinedRangesDefaultComponent); + component = fixture.componentInstance; + predefinedRanges = component.predefinedRanges; + fixture.detectChanges(); + }); + + it('should render no chips by default', () => { + expect(getChips().length).toBe(0); + }); + + it('should render predefined ranges when usePredefinedRanges = true', () => { + component.usePredefinedRanges = true; + fixture.detectChanges(); + + const chips = getChips(); + expect(chips.length).toBe(predefinedRanges.ranges.length); + chips.forEach((de, i) => { + const text = (de.nativeElement as HTMLElement).innerText.trim(); + expect(text).toBe(predefinedRanges.ranges[i].label); + }); + }); + + it('should render predefined + custom ranges together', () => { + component.usePredefinedRanges = true; + component.customRanges = customRanges; + fixture.detectChanges(); + + const chips = getChips(); + const ranges = predefinedRanges.ranges; + + expect(chips.length).toBe(ranges.length); + chips.forEach((de, i) => { + const text = (de.nativeElement as HTMLElement).innerText.trim(); + expect(text).toBe(ranges[i].label); + }); + }); + + it('should render only custom ranges when usePredefinedRanges = false', () => { + component.usePredefinedRanges = false; + component.customRanges = customRanges; + fixture.detectChanges(); + + const chips = getChips(); + const ranges = predefinedRanges.ranges; + + expect(chips.length).toBe(ranges.length); + chips.forEach((de, i) => { + const text = (de.nativeElement as HTMLElement).innerText.trim(); + expect(text).toBe(ranges[i].label); + }); + }); + + it('should emit selected range on chip click', () => { + component.usePredefinedRanges = true; + component.customRanges = customRanges; + fixture.detectChanges(); + + const chips = getChips(); + const ranges = predefinedRanges.ranges; + expect(chips.length).toBe(ranges.length); + + const emitSpy = spyOn(predefinedRanges.rangeSelect, 'emit'); + + chips.forEach((de, i) => { + (de.nativeElement as HTMLElement).click(); + fixture.detectChanges(); + expect(emitSpy).toHaveBeenCalledWith(ranges[i].dateRange); + }); + }); + + it('should use provided resourceStrings for labels when available', () => { + const strings: any = { + last7Days: 'Last 7 - localized', + currentMonth: 'Current Month - localized', + yearToDate: 'YTD - localized', + igx_date_range_picker_last7Days: 'Last 7 - localized', + igx_date_range_picker_currentMonth: 'Current Month - localized', + igx_date_range_picker_yearToDate: 'YTD - localized', + // last30Days omitted to test fallback + }; + + predefinedRanges.resourceStrings = strings; + component.usePredefinedRanges = true; + component.customRanges = []; + fixture.detectChanges(); + + const chips = getChips(); + const labels = chips.map(de => (de.nativeElement as HTMLElement).innerText.trim()); + + expect(labels).toContain('Last 7 - localized'); + expect(labels).toContain('Current Month - localized'); + expect(labels).toContain('YTD - localized'); + + expect(labels).toContain('Last 30 Days'); + }); +}); + +@Component({ + standalone: true, + template: ` + + + `, + imports: [IgxPredefinedRangesAreaComponent] +}) +class PredefinedRangesDefaultComponent { + public usePredefinedRanges = false; + public customRanges = []; + public resourceStrings?: IDateRangePickerResourceStrings; + + @ViewChild(IgxPredefinedRangesAreaComponent, { static: true }) + public predefinedRanges!: IgxPredefinedRangesAreaComponent; +} diff --git a/projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area.component.ts b/projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area.component.ts new file mode 100644 index 00000000000..090a3943fef --- /dev/null +++ b/projects/igniteui-angular/src/lib/date-range-picker/predefined-ranges/predefined-ranges-area.component.ts @@ -0,0 +1,73 @@ +import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IgxChipComponent } from '../../chips/chip.component'; +import { DateRangePickerResourceStringsEN, IDateRangePickerResourceStrings } from '../../core/i18n/date-range-picker-resources'; +import { DateRange, CustomDateRange} from '.././date-range-picker-inputs.common'; +import { CalendarDay } from '../../calendar/common/model'; + + +type PredefinedRangeKey = 'last7Days' | 'currentMonth' | 'last30Days' | 'yearToDate'; + +@Component({ + selector: 'igx-predefined-ranges-area', + standalone: true, + imports: [CommonModule, IgxChipComponent], + templateUrl: './predefined-ranges-area-component.html', + styles: [` + :host { display:block; } + .igx-predefined-ranges { + display:flex; flex-wrap:wrap; gap:.5rem; padding:.5rem .75rem; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class IgxPredefinedRangesAreaComponent { + @Input() public usePredefinedRanges = false; + @Input() public customRanges: CustomDateRange[] = []; + @Input() public resourceStrings: IDateRangePickerResourceStrings = DateRangePickerResourceStringsEN as any; + + @Output() public rangeSelect = new EventEmitter(); + + public get ranges(): CustomDateRange[] { + const base = this.usePredefinedRanges ? this.getPredefinedRanges() : []; + return [...base, ...(this.customRanges ?? [])]; + } + + public trackByLabel = (i: number, r: CustomDateRange) => r.label; + + public onSelect(range: DateRange) { + this.rangeSelect.emit(range); + } + + private getLabel(rs: any, shortKey: string, prefixedKey: string, fallback: string): string { + return rs?.[shortKey] ?? rs?.[prefixedKey] ?? fallback; + } + + private getPredefinedRanges(): CustomDateRange[] { + const today = CalendarDay.today; + const rs: any = this.resourceStrings ?? {}; + + const labels = { + last7Days: this.getLabel(rs, 'last7Days', 'igx_date_range_picker_last7Days', 'Last 7 Days'), + currentMonth: this.getLabel(rs, 'currentMonth', 'igx_date_range_picker_currentMonth', 'Current Month'), + last30Days: this.getLabel(rs, 'last30Days', 'igx_date_range_picker_last30Days', 'Last 30 Days'), + yearToDate: this.getLabel(rs, 'yearToDate', 'igx_date_range_picker_yearToDate', 'Year to Date') + }; + + const startOfMonth = new Date(today.native.getFullYear(), today.native.getMonth(), 1); + const endOfMonth = new Date(today.native.getFullYear(), today.native.getMonth() + 1, 0); + const startOfYear = new Date(today.native.getFullYear(), 0, 1); + + const predefinedRanges: { key: PredefinedRangeKey; get: () => { start: Date; end: Date } }[] = [ + { key: 'last7Days', get: () => ({ start: today.add('day', -7).native, end: today.native }) }, + { key: 'currentMonth', get: () => ({ start: startOfMonth, end: endOfMonth }) }, + { key: 'last30Days', get: () => ({ start: today.add('day', -29).native, end: today.native }) }, + { key: 'yearToDate', get: () => ({ start: startOfYear, end: today.native }) }, + ]; + + return predefinedRanges.map(range => ({ + label: labels[range.key], + dateRange: range.get() + })); + } +} diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/README.md b/projects/igniteui-angular/src/lib/directives/tooltip/README.md index cddc0a794fe..90684e30b8e 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/README.md +++ b/projects/igniteui-angular/src/lib/directives/tooltip/README.md @@ -100,15 +100,99 @@ Since the **IgxTooltip** directive extends the **IgxToggle** directive and there | tooltipDisabled | boolean | Specifies if the tooltip should not show when hovering its target with the mouse. (defaults to false) | | tooltipHidden | boolean | Indicates if the tooltip is currently hidden. | | nativeElement | any | Reference to the native element of the directive. | +| positionSettings | PositionSettings | Controls the position and animation settings used by the tooltip. | +| hasArrow | boolean | Controls whether to display an arrow indicator for the tooltip. Defaults to `false`. | +| sticky | boolean | When set to `true`, the tooltip renders a default close icon `x`. The tooltip remains visible until the user closes it via the close icon `x` or `Esc` key. Defaults to `false`. | +| closeButtonTemplate | TemplateRef | Allows templating the default close icon `x`. | + +#### Templating the close button + +```html + + info + + + + Hello there, I am a tooltip! + + + + + +``` ### Methods | Name | Type | Arguments | Description | | :--- |:--- | :--- | :--- | -| showTooltip | void | N/A | Shows the tooltip after the amount of ms specified by the `showDelay` property. | -| hideTooltip | void | N/A | Hides the tooltip after the amount of ms specified by the `hideDelay` property. | +| showTooltip | void | N/A | Shows the tooltip. | +| hideTooltip | void | N/A | Hides the tooltip. | ### Events |Name|Description|Cancelable|Event arguments| |--|--|--|--| | tooltipShow | Emitted when the tooltip starts showing. (This event is fired before the start of the countdown to showing the tooltip.) | True | ITooltipShowEventArgs | | tooltipHide | Emitted when the tooltip starts hiding. (This event is fired before the start of the countdown to hiding the tooltip.) | True | ITooltipHideEventArgs | + +### Notes + +The `IgxTooltipTarget` uses the `TooltipPositionStrategy` to position the tooltip and arrow element. If a custom position strategy is used (`overlaySettings.positionStrategy`) and `hasArrow` is set to `true`, the custom strategy should extend the `TooltipPositionStrategy`. Otherwise, the arrow will not be displayed. + +The arrow element is positioned based on the provided position settings. If the directions and starting points do not correspond to any of the predefined position values, the arrow is positioned in the top middle side of the tooltip (default tooltip position `bottom`). + + +| Position     | Horizontal Direction          | Horizontal Start Point         | Vertical Direction            | Vertical Start Point           | +|--------------|-------------------------------|--------------------------------|-------------------------------|--------------------------------| +| top          | HorizontalAlignment.Center    | HorizontalAlignment.Center     | VerticalAlignment.Top         | VerticalAlignment.Top          | +| top-start    | HorizontalAlignment.Right     | HorizontalAlignment.Left       | VerticalAlignment.Top         | VerticalAlignment.Top          | +| top-end      | HorizontalAlignment.Left      | HorizontalAlignment.Right      | VerticalAlignment.Top         | VerticalAlignment.Top          | +| bottom       | HorizontalAlignment.Center    | HorizontalAlignment.Center     | VerticalAlignment.Bottom      | VerticalAlignment.Bottom       | +| bottom-start | HorizontalAlignment.Right     | HorizontalAlignment.Left       | VerticalAlignment.Bottom      | VerticalAlignment.Bottom       | +| bottom-end   | HorizontalAlignment.Left      | HorizontalAlignment.Right      | VerticalAlignment.Bottom      | VerticalAlignment.Bottom       | +| right        | HorizontalAlignment.Right     | HorizontalAlignment.Right      | VerticalAlignment.Middle      | VerticalAlignment.Middle       | +| right-start  | HorizontalAlignment.Right     | HorizontalAlignment.Right      | VerticalAlignment.Bottom      | VerticalAlignment.Top          | +| right-end    | HorizontalAlignment.Right     | HorizontalAlignment.Right      | VerticalAlignment.Top         | VerticalAlignment.Bottom       | +| left         | HorizontalAlignment.Left      | HorizontalAlignment.Left       | VerticalAlignment.Middle      | VerticalAlignment.Middle       | +| left-start   | HorizontalAlignment.Left      | HorizontalAlignment.Left       | VerticalAlignment.Bottom      | VerticalAlignment.Top          | +| left-end     | HorizontalAlignment.Left      | HorizontalAlignment.Left       | VerticalAlignment.Top         | VerticalAlignment.Bottom       | + + +#### Customizing the arrow's position + +The arrow's position can be customized by overriding the `positionArrow(arrow: HTMLElement, arrowFit: ArrowFit)` method. + +For example: + +```ts +export class CustomStrategy extends TooltipPositioningStrategy { + constructor(settings?: PositionSettings) { + super(settings); + } + + public override positionArrow(arrow: HTMLElement, arrowFit: ArrowFit): void { + Object.assign(arrow.style, { + left: '-0.25rem', + transform: 'rotate(-45deg)', + [arrowFit.direction]: '-0.25rem', + }); + } +} + +public overlaySettings: OverlaySettings = { + positionStrategy: new CustomStrategy({ + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + }) +}; +``` + +```html + + info + + + + Hello there, I am a tooltip! + +``` diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/public_api.ts b/projects/igniteui-angular/src/lib/directives/tooltip/public_api.ts index 98a7195759d..787c162c2f9 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/public_api.ts +++ b/projects/igniteui-angular/src/lib/directives/tooltip/public_api.ts @@ -3,6 +3,7 @@ import { IgxTooltipDirective } from './tooltip.directive'; export * from './tooltip.directive'; export * from './tooltip-target.directive'; +export { ArrowFit, TooltipPositionStrategy } from './tooltip.common'; /* NOTE: Tooltip directives collection for ease-of-use import in standalone components scenario */ export const IGX_TOOLTIP_DIRECTIVES = [ diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-close-button.component.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-close-button.component.ts new file mode 100644 index 00000000000..18f9ed23b51 --- /dev/null +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-close-button.component.ts @@ -0,0 +1,28 @@ +import { Component, Output, EventEmitter, HostListener, Input, TemplateRef } from '@angular/core'; +import { IgxIconComponent } from '../../icon/icon.component'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'igx-tooltip-close-button', + template: ` + + + + + + + `, + imports: [IgxIconComponent, CommonModule], +}) +export class IgxTooltipCloseButtonComponent { + @Input() + public customTemplate: TemplateRef; + + @Output() + public clicked = new EventEmitter(); + + @HostListener('click') + public handleClick() { + this.clicked.emit(); + } +} diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-target.directive.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-target.directive.ts index c82d10bc719..93faf786554 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-target.directive.ts +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip-target.directive.ts @@ -1,14 +1,19 @@ -import { useAnimation } from '@angular/animations'; -import { Directive, OnInit, OnDestroy, Output, ElementRef, Optional, ViewContainerRef, HostListener, Input, EventEmitter, booleanAttribute } from '@angular/core'; +import { + Directive, OnInit, OnDestroy, Output, ElementRef, Optional, ViewContainerRef, HostListener, + Input, EventEmitter, booleanAttribute, TemplateRef, ComponentRef, Renderer2, OnChanges, SimpleChanges, + EnvironmentInjector, + createComponent, +} from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { IgxNavigationService } from '../../core/navigation'; import { IBaseEventArgs } from '../../core/utils'; -import { AutoPositionStrategy, HorizontalAlignment, PositionSettings } from '../../services/public_api'; +import { PositionSettings } from '../../services/public_api'; import { IgxToggleActionDirective } from '../toggle/toggle.directive'; import { IgxTooltipComponent } from './tooltip.component'; import { IgxTooltipDirective } from './tooltip.directive'; -import { fadeOut, scaleInCenter } from 'igniteui-angular/animations'; +import { IgxTooltipCloseButtonComponent } from './tooltip-close-button.component'; +import { TooltipPositionSettings, TooltipPositionStrategy } from './tooltip.common'; export interface ITooltipShowEventArgs extends IBaseEventArgs { target: IgxTooltipTargetDirective; @@ -40,7 +45,7 @@ export interface ITooltipHideEventArgs extends IBaseEventArgs { selector: '[igxTooltipTarget]', standalone: true }) -export class IgxTooltipTargetDirective extends IgxToggleActionDirective implements OnInit, OnDestroy { +export class IgxTooltipTargetDirective extends IgxToggleActionDirective implements OnChanges, OnInit, OnDestroy { /** * Gets/sets the amount of milliseconds that should pass before showing the tooltip. * @@ -56,7 +61,7 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen * ``` */ @Input() - public showDelay = 500; + public showDelay = 200; /** * Gets/sets the amount of milliseconds that should pass before hiding the tooltip. @@ -73,7 +78,139 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen * ``` */ @Input() - public hideDelay = 500; + public hideDelay = 300; + + /** + * Controls whether to display an arrow indicator for the tooltip. + * Set to true to show the arrow. Default value is `false`. + * + * ```typescript + * // get + * let isArrowDisabled = this.tooltip.hasArrow; + * ``` + * + * ```typescript + * // set + * this.tooltip.hasArrow = true; + * ``` + * + * ```html + * + * info + * ``` + */ + @Input() + public set hasArrow(value: boolean) { + if (this.target) { + this.target.arrow.style.display = value ? '' : 'none'; + } + this._hasArrow = value; + } + + public get hasArrow(): boolean { + return this._hasArrow; + } + + /** + * Specifies if the tooltip remains visible until the user closes it via the close button or Esc key. + * + * ```typescript + * // get + * let isSticky = this.tooltip.sticky; + * ``` + * + * ```typescript + * // set + * this.tooltip.sticky = true; + * ``` + * + * ```html + * + * info + * ``` + */ + @Input() + public set sticky (value: boolean) { + const changed = this._sticky !== value; + this._sticky = value; + + if (changed) { + this._createCloseTemplate(this._closeTemplate); + this._evaluateStickyState(); + } + }; + + public get sticky (): boolean { + return this._sticky; + } + + + /** + * Allows full control over the appearance of the close button inside the tooltip. + * + * ```typescript + * // get + * let customCloseTemplate = this.tooltip.customCloseTemplate; + * ``` + * + * ```typescript + * // set + * this.tooltip.customCloseTemplate = TemplateRef; + * ``` + * + * ```html + * + * info + * + * + * + * ``` + */ + @Input('closeButtonTemplate') + public set closeTemplate(value: TemplateRef) { + this._closeTemplate = value; + this._createCloseTemplate(this._closeTemplate); + this._evaluateStickyState(); + } + public get closeTemplate(): TemplateRef | undefined { + return this._closeTemplate; + } + + /** + * Get the position and animation settings used by the tooltip. + * ```typescript + * let positionSettings = this.tooltipTarget.positionSettings; + * ``` + */ + @Input() + public get positionSettings(): PositionSettings { + return this._positionSettings; + } + + /** + * Set the position and animation settings used by the tooltip. + * ```html + * info + * Hello there, I am a tooltip! + * ``` + * ```typescript + * + * import { PositionSettings, HorizontalAlignment, VerticalAlignment } from 'igniteui-angular'; + * ... + * public newPositionSettings: PositionSettings = { + * horizontalDirection: HorizontalAlignment.Right, + * horizontalStartPoint: HorizontalAlignment.Left, + * verticalDirection: VerticalAlignment.Top, + * verticalStartPoint: VerticalAlignment.Top, + * }; + * ``` + */ + public set positionSettings(settings: PositionSettings) { + this._positionSettings = settings; + if (this._overlayDefaults) { + this._overlayDefaults.positionStrategy = new TooltipPositionStrategy(this._positionSettings); + } + } /** * Specifies if the tooltip should not show when hovering its target with the mouse. (defaults to false) @@ -185,10 +322,22 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen @Output() public tooltipHide = new EventEmitter(); - private destroy$ = new Subject(); - - constructor(private _element: ElementRef, - @Optional() private _navigationService: IgxNavigationService, private _viewContainerRef: ViewContainerRef) { + private _destroy$ = new Subject(); + private _autoHideDelay = 180; + private _isForceClosed = false; + private _hasArrow = false; + private _closeButtonRef?: ComponentRef; + private _closeTemplate: TemplateRef; + private _sticky = false; + private _positionSettings: PositionSettings = TooltipPositionSettings; + + constructor( + private _element: ElementRef, + @Optional() private _navigationService: IgxNavigationService, + private _viewContainerRef: ViewContainerRef, + private _renderer: Renderer2, + private _envInjector: EnvironmentInjector + ) { super(_element, _navigationService); } @@ -198,7 +347,10 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen @HostListener('click') public override onClick() { if (!this.target.collapsed) { - this.target.forceClose(this.mergedOverlaySettings); + this._hideOnInteraction(); + } else if (this.target.timeoutId) { + clearTimeout(this.target.timeoutId); + this.target.timeoutId = null; } } @@ -207,30 +359,7 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen */ @HostListener('mouseenter') public onMouseEnter() { - if (this.tooltipDisabled) { - return; - } - - this.checkOutletAndOutsideClick(); - const shouldReturn = this.preMouseEnterCheck(); - if (shouldReturn) { - return; - } - - this.target.tooltipTarget = this; - - const showingArgs = { target: this, tooltip: this.target, cancel: false }; - this.tooltipShow.emit(showingArgs); - - if (showingArgs.cancel) { - return; - } - - this.target.toBeShown = true; - this.target.timeoutId = setTimeout(() => { - this.target.open(this.mergedOverlaySettings); // Call open() of IgxTooltipDirective - this.target.toBeShown = false; - }, this.showDelay); + this._checksBeforeShowing(() => this._showOnInteraction()); } /** @@ -242,44 +371,39 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen return; } - this.checkOutletAndOutsideClick(); - const shouldReturn = this.preMouseLeaveCheck(); - if (shouldReturn || this.target.collapsed) { - return; - } - - this.target.toBeHidden = true; - this.target.timeoutId = setTimeout(() => { - this.target.close(); // Call close() of IgxTooltipDirective - this.target.toBeHidden = false; - }, this.hideDelay); - - + this._checkOutletAndOutsideClick(); + this._hideOnInteraction(); } /** * @hidden */ public onTouchStart() { - if (this.tooltipDisabled) { - return; - } - - this.showTooltip(); + this._checksBeforeShowing(() => this._showOnInteraction()); } /** * @hidden */ public onDocumentTouchStart(event) { - if (this.tooltipDisabled) { + if (this.tooltipDisabled || this?.target?.tooltipTarget !== this) { return; } if (this.nativeElement !== event.target && !this.nativeElement.contains(event.target) ) { - this.hideTooltip(); + this._hideOnInteraction(); + } + } + + + /** + * @hidden + */ + public ngOnChanges(changes: SimpleChanges): void { + if (changes['hasArrow']) { + this.target.arrow.style.display = changes['hasArrow'].currentValue ? '' : 'none'; } } @@ -289,18 +413,11 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen public override ngOnInit() { super.ngOnInit(); - const positionSettings: PositionSettings = { - horizontalDirection: HorizontalAlignment.Center, - horizontalStartPoint: HorizontalAlignment.Center, - openAnimation: useAnimation(scaleInCenter, { params: { duration: '150ms' } }), - closeAnimation: useAnimation(fadeOut, { params: { duration: '75ms' } }) - }; - - this._overlayDefaults.positionStrategy = new AutoPositionStrategy(positionSettings); + this._overlayDefaults.positionStrategy = new TooltipPositionStrategy(this._positionSettings); this._overlayDefaults.closeOnOutsideClick = false; this._overlayDefaults.closeOnEscape = true; - this.target.closing.pipe(takeUntil(this.destroy$)).subscribe((event) => { + this.target.closing.pipe(takeUntil(this._destroy$)).subscribe((event) => { if (this.target.tooltipTarget !== this) { return; } @@ -322,105 +439,209 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen public ngOnDestroy() { this.hideTooltip(); this.nativeElement.removeEventListener('touchstart', this.onTouchStart); - this.destroy$.next(); - this.destroy$.complete(); + this._destroyCloseButton(); + this._destroy$.next(); + this._destroy$.complete(); } /** - * Shows the tooltip by respecting the 'showDelay' property. + * Shows the tooltip if not already shown. * * ```typescript * this.tooltipTarget.showTooltip(); * ``` */ public showTooltip() { - clearTimeout(this.target.timeoutId); - - if (!this.target.collapsed) { - // if close animation has started finish it, or close the tooltip with no animation - this.target.forceClose(this.mergedOverlaySettings); - this.target.toBeHidden = false; - } - this.target.tooltipTarget = this; - - const showingArgs = { target: this, tooltip: this.target, cancel: false }; - this.tooltipShow.emit(showingArgs); - - if (showingArgs.cancel) { - return; - } - - this.target.toBeShown = true; - this.target.timeoutId = setTimeout(() => { - this.target.open(this.mergedOverlaySettings); // Call open() of IgxTooltipDirective - this.target.toBeShown = false; - }, this.showDelay); + this._checksBeforeShowing(() => this._showTooltip(false, true)); } /** - * Hides the tooltip by respecting the 'hideDelay' property. + * Hides the tooltip if not already hidden. * * ```typescript * this.tooltipTarget.hideTooltip(); * ``` */ public hideTooltip() { - if (this.target.collapsed && this.target.toBeShown) { - clearTimeout(this.target.timeoutId); + this._hideTooltip(false); + } + + private get _mergedOverlaySettings() { + return Object.assign({}, this._overlayDefaults, this.overlaySettings); + } + + private _checkOutletAndOutsideClick(): void { + if (this.outlet) { + this._overlayDefaults.outlet = this.outlet; } + } - if (this.target.collapsed || this.target.toBeHidden) { + /** + * A guard method that performs precondition checks before showing the tooltip. + * It ensures that the tooltip is not disabled and not already shown in sticky mode. + * If all conditions pass, it executes the provided `action` callback. + */ + private _checksBeforeShowing(action: () => void): void { + if (this.tooltipDisabled) return; + if (!this.target.collapsed && this.target?.tooltipTarget?.sticky) return; + + this._checkOutletAndOutsideClick(); + this._checkTooltipForMultipleTargets(); + action(); + } + + private _hideTooltip(withDelay: boolean): void { + if (this.target.collapsed) { return; } - this.target.toBeHidden = true; this.target.timeoutId = setTimeout(() => { - this.target.close(); // Call close() of IgxTooltipDirective - this.target.toBeHidden = false; - }, this.hideDelay); + // Call close() of IgxTooltipDirective + this.target.close(); + }, withDelay ? this.hideDelay : 0); } - private checkOutletAndOutsideClick() { - if (this.outlet) { - this._overlayDefaults.outlet = this.outlet; + private _showTooltip(withDelay: boolean, withEvents: boolean): void { + if (!this.target.collapsed && !this._isForceClosed) { + return; } + + if (this._isForceClosed) { + this._isForceClosed = false; + } + + if (withEvents) { + const showingArgs = { target: this, tooltip: this.target, cancel: false }; + this.tooltipShow.emit(showingArgs); + + if (showingArgs.cancel) return; + } + + this._evaluateStickyState(); + + this.target.timeoutId = setTimeout(() => { + // Call open() of IgxTooltipDirective + this.target.open(this._mergedOverlaySettings); + }, withDelay ? this.showDelay : 0); } - private get mergedOverlaySettings() { - return Object.assign({}, this._overlayDefaults, this.overlaySettings); + + private _showOnInteraction(): void { + this._stopTimeoutAndAnimation(); + this._showTooltip(true, true); } - // Return true if the execution in onMouseEnter should be terminated after this method - private preMouseEnterCheck() { - // If tooltip is about to be opened - if (this.target.toBeShown) { - clearTimeout(this.target.timeoutId); - this.target.toBeShown = false; + private _hideOnInteraction(): void { + if (this.target?.tooltipTarget?.sticky) { + return; } - // If Tooltip is opened or about to be hidden - if (!this.target.collapsed || this.target.toBeHidden) { + this._setAutoHide(); + } + + private _setAutoHide(): void { + this._stopTimeoutAndAnimation(); + + this.target.timeoutId = setTimeout(() => { + this._hideTooltip(true); + }, this._autoHideDelay); + } + + /** + * Used when the browser animations are set to a lower percentage + * and the user interacts with the target or tooltip __while__ an animation is playing. + * It stops the running animation, and the tooltip is instantly shown. + */ + private _stopTimeoutAndAnimation(): void { + clearTimeout(this.target.timeoutId); + this.target.stopAnimations(); + } + + /** + * Used when a single tooltip is used for multiple targets. + * If the tooltip is shown for one target and the user interacts with another target, + * the tooltip is instantly hidden for the first target. + */ + private _checkTooltipForMultipleTargets(): void { + if (!this.target.tooltipTarget) { + this.hasArrow = this._hasArrow; + this.target.tooltipTarget = this; + } + if (this.target.tooltipTarget !== this) { + this.hasArrow = this._hasArrow; + if (this.target.tooltipTarget.sticky) { + this.target.tooltipTarget._removeCloseButtonFromTooltip(); + } + clearTimeout(this.target.timeoutId); + this.target.stopAnimations(true); - // if close animation has started finish it, or close the tooltip with no animation - this.target.forceClose(this.mergedOverlaySettings); - this.target.toBeHidden = false; + this.target.tooltipTarget = this; + this._isForceClosed = true; } + } - return false; + /** + * Updates the tooltip's sticky-related state, but only if the current target owns the tooltip. + * + * This method ensures that when the active target modifies its `sticky` or `closeTemplate` properties + * at runtime, the tooltip reflects those changes accordingly: + */ + private _evaluateStickyState(): void { + if(this?.target?.tooltipTarget === this) { + if (this.sticky) { + this._appendCloseButtonToTooltip(); + } else if (!this.sticky) { + this._removeCloseButtonFromTooltip(); + } + } } - // Return true if the execution in onMouseLeave should be terminated after this method - private preMouseLeaveCheck(): boolean { - clearTimeout(this.target.timeoutId); + /** + * Creates (if not already created) an instance of the IgxTooltipCloseButtonComponent, + * and assigns it the provided custom template. + */ + private _createCloseTemplate(template?: TemplateRef | undefined): void { + if (!this._closeButtonRef) { + this._closeButtonRef = createComponent(IgxTooltipCloseButtonComponent, { + environmentInjector: this._envInjector + }); + + this._closeButtonRef.instance.customTemplate = template; + this._closeButtonRef.instance.clicked.pipe(takeUntil(this._destroy$)).subscribe(() => { + this._hideTooltip(true); + }); + } else { + this._closeButtonRef.instance.customTemplate = template; + } + } - // If tooltip is about to be opened - if (this.target.toBeShown) { - this.target.toBeShown = false; - this.target.toBeHidden = false; - return true; + /** + * Appends the close button to the tooltip. + */ + private _appendCloseButtonToTooltip(): void { + if (this?.target && this._closeButtonRef) { + this._renderer.appendChild(this.target.element, this._closeButtonRef.location.nativeElement); + this._closeButtonRef.changeDetectorRef.detectChanges(); + this.target.role = "status" } + } - return false; + /** + * Removes the close button from the tooltip. + */ + private _removeCloseButtonFromTooltip() { + if (this?.target && this._closeButtonRef) { + this._renderer.removeChild(this.target.element, this._closeButtonRef.location.nativeElement); + this._closeButtonRef.changeDetectorRef.detectChanges(); + this.target.role = "tooltip" + } + } + + private _destroyCloseButton(): void { + if (this._closeButtonRef) { + this._closeButtonRef.destroy(); + this._closeButtonRef = undefined; + } } } diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.common.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.common.ts new file mode 100644 index 00000000000..6f25d0403fd --- /dev/null +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.common.ts @@ -0,0 +1,332 @@ +import { first } from '../../core/utils'; +import { AutoPositionStrategy } from '../../services/overlay/position/auto-position-strategy'; +import { ConnectedFit, HorizontalAlignment, Point, PositionSettings, Size, VerticalAlignment } from '../../services/overlay/utilities'; +import { useAnimation } from '@angular/animations'; +import { fadeOut, scaleInCenter } from 'igniteui-angular/animations'; + +export const TooltipRegexes = Object.freeze({ + /** Matches horizontal `Placement` end positions. `left-end` | `right-end` */ + horizontalEnd: /^(left|right)-end$/, + + /** Matches vertical `Placement` centered positions. `left` | `right` */ + horizontalCenter: /^(left|right)$/, + + /** + * Matches vertical `Placement` positions. + * `top` | `top-start` | `top-end` | `bottom` | `bottom-start` | `bottom-end` + */ + vertical: /^(top|bottom)(-(start|end))?$/, + + /** Matches vertical `Placement` end positions. `top-end` | `bottom-end` */ + verticalEnd: /^(top|bottom)-end$/, + + /** Matches vertical `Placement` centered positions. `top` | `bottom` */ + verticalCenter: /^(top|bottom)$/, +}); + +export interface ArrowFit { + /** Rectangle of the arrow element. */ + readonly arrowRect?: Partial; + /** Rectangle of the tooltip element. */ + readonly tooltipRect?: Partial; + /** Direction in which the arrow points. */ + readonly direction?: 'top' | 'bottom' | 'right' | 'left'; + /** Vertical offset of the arrow element from the tooltip */ + top?: number; + /** Horizontal offset of the arrow element from the tooltip */ + left?: number; +} + +/** + * Defines the possible positions for the tooltip relative to its target. + */ +export enum Placement { + Top = 'top', + TopStart = 'top-start', + TopEnd = 'top-end', + Bottom = 'bottom', + BottomStart = 'bottom-start', + BottomEnd = 'bottom-end', + Right = 'right', + RightStart = 'right-start', + RightEnd = 'right-end', + Left = 'left', + LeftStart = 'left-start', + LeftEnd = 'left-end' +} + +/** + * Default tooltip position settings. + */ +export const TooltipPositionSettings: PositionSettings = { + horizontalDirection: HorizontalAlignment.Center, + horizontalStartPoint: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + openAnimation: useAnimation(scaleInCenter, { params: { duration: '150ms' } }), + closeAnimation: useAnimation(fadeOut, { params: { duration: '75ms' } }), + offset: 6 +}; + +export class TooltipPositionStrategy extends AutoPositionStrategy { + + private _placement: Placement; + + constructor(settings?: PositionSettings) { + if (settings) { + settings = Object.assign({}, TooltipPositionSettings, settings); + } + + super(settings || TooltipPositionSettings); + } + + public override position( + contentElement: HTMLElement, + size: Size, + document?: Document, + initialCall?: boolean, + target?: Point | HTMLElement + ): void { + super.position(contentElement, size, document, initialCall, target); + + const tooltip = contentElement.children?.[0]; + this.configArrow(tooltip); + } + + protected override fitInViewport(element: HTMLElement, connectedFit: ConnectedFit): void { + super.fitInViewport(element, connectedFit); + + const tooltip = element.children?.[0]; + this.configArrow(tooltip); + } + + /** + * Sets the position of the arrow relative to the tooltip element. + * + * @param arrow the arrow element of the tooltip. + * @param arrowFit arrowFit object containing all necessary parameters. + */ + public positionArrow(arrow: HTMLElement, arrowFit: ArrowFit): void { + this.resetArrowPositionStyles(arrow); + + const convert = (value: number) => { + if (!value) { + return ''; + } + return `${value}px` + }; + + Object.assign(arrow.style, { + top: convert(arrowFit.top), + left: convert(arrowFit.left), + [arrowFit.direction]: convert(-4), + }); + } + + /** + * Resets the element's top / bottom / left / right style properties. + * + * @param arrow the arrow element of the tooltip. + */ + private resetArrowPositionStyles(arrow: HTMLElement): void { + arrow.style.top = ''; + arrow.style.bottom = ''; + arrow.style.left = ''; + arrow.style.right = ''; + } + + /** + * Gets values for `top` or `left` position styles. + * + * @param arrowRect + * @param tooltipRect + * @param positionProperty - for which position property to get style values. + */ + private getArrowPositionStyles( + arrowRect: Partial, + tooltipRect: Partial, + positionProperty: 'top' | 'left' + ): number { + const arrowSize = arrowRect.width > arrowRect.height + ? arrowRect.width + : arrowRect.height; + + const tooltipSize = TooltipRegexes.vertical.test(this._placement) + ? tooltipRect.width + : tooltipRect.height; + + const direction = { + top: 'horizontal', + left: 'vertical', + }[positionProperty]; + + const center = `${direction}Center`; + const end = `${direction}End`; + + if (TooltipRegexes[center].test(this._placement)) { + const offset = tooltipSize / 2 - arrowSize / 2; + return Math.round(offset); + } + if (TooltipRegexes[end].test(this._placement)) { + const endOffset = TooltipRegexes.vertical.test(this._placement) ? 8 : 4; + const offset = tooltipSize - (endOffset + arrowSize); + return Math.round(offset); + } + return 0; + } + + /** + * Configure arrow class and arrowFit. + * + * @param tooltip tooltip element. + */ + private configArrow(tooltip: Element): void { + if (!tooltip) { + return; + } + + const arrow = tooltip.querySelector('[data-arrow="true"]') as HTMLElement; + + // If display is none -> tooltipTarget's hasArrow is false + if (!arrow || arrow.style.display === 'none') { + return; + } + + this._placement = this.getPlacementByPositionSettings(this.settings) ?? Placement.Bottom; + const tooltipDirection = first(this._placement.split('-')); + arrow.className = `igx-tooltip--${tooltipDirection}`; + + // Arrow direction is the opposite of tooltip direction. + const direction = this.getOppositeDirection(tooltipDirection) as 'top' | 'right' | 'bottom' | 'left'; + const arrowRect = arrow.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const top = this.getArrowPositionStyles(arrowRect, tooltipRect, 'top'); + const left = this.getArrowPositionStyles(arrowRect, tooltipRect, 'left'); + + const arrowFit: ArrowFit = { + direction, + arrowRect, + tooltipRect, + top, + left, + }; + + this.positionArrow(arrow, arrowFit); + } + + /** + * Gets the placement that correspond to the given position settings. + * Returns `undefined` if the position settings do not match any of the predefined placement values. + * + * @param settings Position settings for which to get the corresponding placement. + */ + private getPlacementByPositionSettings(settings: PositionSettings): Placement { + const { horizontalDirection, horizontalStartPoint, verticalDirection, verticalStartPoint } = settings; + + const mapArray = Array.from(PositionsMap.entries()); + const placement = mapArray.find( + ([_, val]) => + val.horizontalDirection === horizontalDirection && + val.horizontalStartPoint === horizontalStartPoint && + val.verticalDirection === verticalDirection && + val.verticalStartPoint === verticalStartPoint + ); + + return placement ? placement[0] : undefined; + } + + /** + * Gets opposite direction, e.g., top -> bottom + * + * @param direction for which direction to return its opposite. + * @returns `top` | `bottom` | `right` | `left` + */ + private getOppositeDirection(direction: string): string { + const opposite = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[direction]; + + return opposite; + } +} + +/** + * Maps the predefined placement values to the corresponding directions and starting points. + */ +export const PositionsMap = new Map([ + [Placement.Top, { + horizontalDirection: HorizontalAlignment.Center, + horizontalStartPoint: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.TopStart, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.TopEnd, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.Bottom, { + horizontalDirection: HorizontalAlignment.Center, + horizontalStartPoint: HorizontalAlignment.Center, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + }], + [Placement.BottomStart, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + }], + [Placement.BottomEnd, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Bottom, + }], + [Placement.Right, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Middle, + verticalStartPoint: VerticalAlignment.Middle, + }], + [Placement.RightStart, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.RightEnd, { + horizontalDirection: HorizontalAlignment.Right, + horizontalStartPoint: HorizontalAlignment.Right, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Bottom, + }], + [Placement.Left, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Middle, + verticalStartPoint: VerticalAlignment.Middle, + }], + [Placement.LeftStart, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Bottom, + verticalStartPoint: VerticalAlignment.Top, + }], + [Placement.LeftEnd, { + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Left, + verticalDirection: VerticalAlignment.Top, + verticalStartPoint: VerticalAlignment.Bottom, + }] +]); diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.spec.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.spec.ts index 8b1ba79aa77..0f78506dfbd 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.spec.ts +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.spec.ts @@ -2,14 +2,17 @@ import { DebugElement } from '@angular/core'; import { fakeAsync, TestBed, tick, flush, waitForAsync, ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { IgxTooltipSingleTargetComponent, IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, IgxTooltipWithToggleActionComponent, IgxTooltipMultipleTooltipsComponent } from '../../test-utils/tooltip-components.spec'; +import { IgxTooltipSingleTargetComponent, IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, IgxTooltipWithToggleActionComponent, IgxTooltipMultipleTooltipsComponent, IgxTooltipWithCloseButtonComponent } from '../../test-utils/tooltip-components.spec'; import { UIInteractions } from '../../test-utils/ui-interactions.spec'; import { HorizontalAlignment, VerticalAlignment, AutoPositionStrategy } from '../../services/public_api'; import { IgxTooltipDirective } from './tooltip.directive'; import { IgxTooltipTargetDirective } from './tooltip-target.directive'; +import { Placement, PositionsMap } from './tooltip.common'; const HIDDEN_TOOLTIP_CLASS = 'igx-tooltip--hidden'; const TOOLTIP_CLASS = 'igx-tooltip'; +const HIDE_DELAY = 180; +const TOOLTIP_ARROW_SELECTOR = '[data-arrow="true"]'; describe('IgxTooltip', () => { let fix: ComponentFixture; @@ -24,7 +27,8 @@ describe('IgxTooltip', () => { IgxTooltipSingleTargetComponent, IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, - IgxTooltipWithToggleActionComponent + IgxTooltipWithToggleActionComponent, + IgxTooltipWithCloseButtonComponent ] }).compileComponents(); UIInteractions.clearOverlay(); @@ -44,8 +48,8 @@ describe('IgxTooltip', () => { })); it('IgxTooltipTargetDirective default values', () => { - expect(tooltipTarget.showDelay).toBe(500); - expect(tooltipTarget.hideDelay).toBe(500); + expect(tooltipTarget.showDelay).toBe(200); + expect(tooltipTarget.hideDelay).toBe(300); expect(tooltipTarget.tooltipDisabled).toBe(false); expect(tooltipTarget.overlaySettings).toBeUndefined(); }); @@ -80,6 +84,39 @@ describe('IgxTooltip', () => { verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); })); + it('should not render a default arrow', fakeAsync(() => { + expect(tooltipTarget.hasArrow).toBeFalse(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + expect(arrow).not.toBeNull(); + expect(arrow.style.display).toEqual("none"); + })); + + it('should show/hide the arrow via the `hasArrow` property', fakeAsync(() => { + expect(tooltipTarget.hasArrow).toBeFalse(); + + tooltipTarget.hasArrow = true; + fix.detectChanges(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + expect(tooltipTarget.hasArrow).toBeTrue(); + const arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + expect(arrow.style.display).toEqual(""); + + tooltipTarget.hasArrow = false; + fix.detectChanges(); + expect(arrow.style.display).toEqual("none"); + })); + it('show target tooltip when hovering its target and ignore [tooltip] input', fakeAsync(() => { hoverElement(button); flush(); @@ -91,7 +128,7 @@ describe('IgxTooltip', () => { it('verify tooltip default position', fakeAsync(() => { hoverElement(button); flush(); - verifyTooltipPosition(tooltipNativeElement, button, true); + verifyTooltipPosition(tooltipNativeElement, button); })); it('IgxTooltip is not shown when is disabled and hovering its target', fakeAsync(() => { @@ -134,6 +171,7 @@ describe('IgxTooltip', () => { flush(); unhoverElement(button); + tick(HIDE_DELAY); tick(400); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); @@ -154,20 +192,17 @@ describe('IgxTooltip', () => { verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); })); - it('showing tooltip through API respects showDelay', fakeAsync(() => { + it('showing tooltip through API does NOT respect showDelay', fakeAsync(() => { tooltipTarget.showDelay = 400; fix.detectChanges(); tooltipTarget.showTooltip(); tick(300); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); - - tick(100); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); })); - it('hiding tooltip through API respects hideDelay', fakeAsync(() => { + it('hiding tooltip through API does NOT respect hideDelay', fakeAsync(() => { tooltipTarget.hideDelay = 450; fix.detectChanges(); @@ -177,39 +212,7 @@ describe('IgxTooltip', () => { tooltipTarget.hideTooltip(); tick(400); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); - - tick(50); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); - })); - - it('IgxTooltip closes and reopens if it was opened through API and then its target is hovered', fakeAsync(() => { - tooltipTarget.showTooltip(); - flush(); - - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); - - hoverElement(button); - - tick(250); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); - - tick(250); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); - })); - - it('IgxTooltip closes and reopens if opening it through API multiple times', fakeAsync(() => { - tooltipTarget.showTooltip(); - tick(500); - - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); - - tooltipTarget.showTooltip(); - tick(250); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); - - tick(250); - verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); })); it('IgxTooltip respects the passed overlaySettings', fakeAsync(() => { @@ -217,7 +220,7 @@ describe('IgxTooltip', () => { hoverElement(button); flush(); // Verify default position of the tooltip. - verifyTooltipPosition(tooltipNativeElement, button, true); + verifyTooltipPosition(tooltipNativeElement, button); unhoverElement(button); flush(); @@ -258,6 +261,21 @@ describe('IgxTooltip', () => { verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); })); + it('IgxTooltip should not be shown if the target is clicked - #16145', fakeAsync(() => { + tooltipTarget.showDelay = 500; + fix.detectChanges(); + + hoverElement(button); + tick(300); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + + UIInteractions.simulateClickAndSelectEvent(button); + fix.detectChanges(); + + tick(300); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + it('IgxTooltip hides on pressing \'escape\' key', fakeAsync(() => { tooltipTarget.showTooltip(); flush(); @@ -443,6 +461,7 @@ describe('IgxTooltip', () => { const dummyDiv = fix.debugElement.query(By.css('.dummyDiv')); touchElement(dummyDiv); + tick(HIDE_DELAY); tick(400); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); @@ -505,7 +524,7 @@ describe('IgxTooltip', () => { // Tooltip is positioned relative to buttonOne and NOT relative to buttonTwo verifyTooltipVisibility(tooltipNativeElement, targetOne, true); - verifyTooltipPosition(tooltipNativeElement, buttonOne, true); + verifyTooltipPosition(tooltipNativeElement, buttonOne); verifyTooltipPosition(tooltipNativeElement, buttonTwo, false); unhoverElement(buttonOne); @@ -515,7 +534,7 @@ describe('IgxTooltip', () => { // Tooltip is positioned relative to buttonTwo and NOT relative to buttonOne verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); - verifyTooltipPosition(tooltipNativeElement, buttonTwo, true); + verifyTooltipPosition(tooltipNativeElement, buttonTwo); verifyTooltipPosition(tooltipNativeElement, buttonOne, false); })); @@ -534,7 +553,7 @@ describe('IgxTooltip', () => { // Tooltip is visible and positioned relative to buttonTwo // and it was not closed due to buttonOne mouseLeave logic. verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); - verifyTooltipPosition(tooltipNativeElement, buttonTwo, true); + verifyTooltipPosition(tooltipNativeElement, buttonTwo); verifyTooltipPosition(tooltipNativeElement, buttonOne, false); flush(); })); @@ -557,14 +576,14 @@ describe('IgxTooltip', () => { // Tooltip is visible and positioned relative to buttonTwo verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); - verifyTooltipPosition(tooltipNativeElement, buttonTwo, true); + verifyTooltipPosition(tooltipNativeElement, buttonTwo); // Tooltip is NOT visible and positioned relative to buttonOne verifyTooltipPosition(tooltipNativeElement, buttonOne, false); })); it('Should not call `hideTooltip` multiple times on document:touchstart', fakeAsync(() => { - spyOn(targetOne, 'hideTooltip').and.callThrough(); - spyOn(targetTwo, 'hideTooltip').and.callThrough(); + spyOn(targetOne, '_hideOnInteraction').and.callThrough(); + spyOn(targetTwo, '_hideOnInteraction').and.callThrough(); touchElement(buttonOne); tick(500); @@ -573,8 +592,8 @@ describe('IgxTooltip', () => { touchElement(dummyDiv); flush(); - expect(targetOne.hideTooltip).toHaveBeenCalledTimes(1); - expect(targetTwo.hideTooltip).not.toHaveBeenCalled(); + expect(targetOne['_hideOnInteraction']).toHaveBeenCalledTimes(1); + expect(targetTwo['_hideOnInteraction']).not.toHaveBeenCalled(); })); it('should not emit tooltipHide event multiple times', fakeAsync(() => { @@ -619,6 +638,183 @@ describe('IgxTooltip', () => { flush(); verifyTooltipVisibility(tooltipNativeElement, targetTwo, false); })); + + it('should show and remove close button depending on active sticky target', fakeAsync(() => { + targetOne.sticky = true; + fix.detectChanges(); + hoverElement(buttonOne); + flush(); + + let closeBtn = tooltipNativeElement.querySelector('igx-tooltip-close-button'); + expect(closeBtn).not.toBeNull(); + expect(fix.componentInstance.tooltip.role).toBe('status'); + + targetTwo.sticky = false; + fix.detectChanges(); + hoverElement(buttonTwo); + flush(); + + // It should still show tooltip for targetOne + expect(fix.componentInstance.tooltip.role).toBe('status'); + expect(tooltipNativeElement.querySelector('igx-tooltip-close-button')).not.toBeNull(); + + closeBtn = tooltipNativeElement.querySelector('igx-tooltip-close-button') as HTMLElement; + closeBtn.dispatchEvent(new Event('click')); + fix.detectChanges(); + flush(); + + hoverElement(buttonTwo); + flush(); + + expect(tooltipNativeElement.querySelector('igx-tooltip-close-button')).toBeNull(); + expect(fix.componentInstance.tooltip.role).toBe('tooltip'); + })); + + it('should assign close template programmatically and render it only for the sticky target', fakeAsync(() => { + const instance = fix.componentInstance; + + targetOne.sticky = true; + targetTwo.sticky = true; + targetOne.closeTemplate = instance.customCloseTemplate; + fix.detectChanges(); + + hoverElement(buttonOne); + flush(); + + const customClose = tooltipNativeElement.querySelector('.my-close-btn'); + expect(customClose).not.toBeNull(); + expect(customClose.textContent).toContain('Custom Close Button'); + + const closeBtn = tooltipNativeElement.querySelector('igx-tooltip-close-button') as HTMLElement; + closeBtn.dispatchEvent(new Event('click')); + fix.detectChanges(); + flush(); + + hoverElement(buttonTwo); + flush(); + + expect(tooltipNativeElement.querySelector('.my-close-btn')).toBeNull(); + })); + + it('should not update tooltip state when non-active target changes sticky or closeTemplate', fakeAsync(() => { + const instance = fix.componentInstance as IgxTooltipMultipleTargetsComponent; + + targetOne.sticky = true; + targetTwo.sticky = false; + targetOne.closeTemplate = instance.customCloseTemplate; + fix.detectChanges(); + + hoverElement(buttonOne); + flush(); + + // Tooltip should be shown for targetOne with custom close button and correct role + const tooltip = tooltipNativeElement; + const customClose = tooltip.querySelector('.my-close-btn'); + const roleAttr = tooltip.getAttribute('role'); + + expect(customClose).not.toBeNull(); + expect(customClose.textContent).toContain('Custom Close Button'); + expect(roleAttr).toBe('status'); + + const closeButton = tooltip.querySelector('igx-tooltip-close-button'); + + // Change sticky and template on targetTwo while tooltip is still shown for targetOne + targetTwo.sticky = true; + targetTwo.closeTemplate = instance.secondCustomCloseTemplate; + + fix.detectChanges(); + flush(); + + expect(tooltip.querySelector('igx-tooltip-close-button')).toBe(closeButton); // same reference + expect(tooltip.querySelector('.my-close-btn')).not.toBeNull(); // still the custom one + expect(tooltip.getAttribute('role')).toBe('status'); + expect(instance.tooltip.tooltipTarget).toBe(targetOne); + })); + + it('should update tooltip state when active target changes closeTemplate or sticky', fakeAsync(() => { + const instance = fix.componentInstance as IgxTooltipMultipleTargetsComponent; + + targetOne.sticky = true; + targetOne.closeTemplate = instance.customCloseTemplate; + fix.detectChanges(); + + hoverElement(buttonOne); + flush(); + fix.detectChanges(); + + const tooltip = tooltipNativeElement; + const customClose = tooltip.querySelector('.my-close-btn'); + const roleAttr = tooltip.getAttribute('role'); + + expect(customClose).not.toBeNull(); + expect(customClose.textContent).toContain('Custom Close Button'); + expect(roleAttr).toBe('status'); + + // Change closeTemplate of active targetOne + targetOne.closeTemplate = instance.secondCustomCloseTemplate; + fix.detectChanges(); + flush(); + + const updatedCustomClose = tooltip.querySelector('.my-second-close-btn'); + expect(updatedCustomClose).not.toBeNull(); + expect(updatedCustomClose.textContent).toContain('Second Custom Close Button'); + + targetOne.sticky = false; + fix.detectChanges(); + flush(); + + expect(tooltip.getAttribute('role')).toBe('tooltip'); + expect(tooltip.querySelector('igx-tooltip-close-button')).toBeNull(); + })); + + it('should correctly update tooltip when showing programmatically for sticky and non-sticky targets', fakeAsync(() => { + const tooltip = tooltipNativeElement; + + targetOne.sticky = true; + fix.detectChanges(); + targetOne.showTooltip(); + flush(); + + verifyTooltipVisibility(tooltip, targetOne, true); + expect(tooltip.role).toBe('status'); + + // Programmatically show tooltip for targetTwo (non-sticky) without closing sticky tooltip + targetTwo.sticky = false; + targetTwo.showTooltip(); + flush(); + verifyTooltipPosition(tooltip, targetTwo, false); + expect(tooltip.role).toBe('status'); + + targetOne.hideTooltip(); + flush(); + + targetTwo.showTooltip(); + flush(); + verifyTooltipPosition(tooltip, targetTwo, true); + expect(tooltip.role).toBe('tooltip'); + })); + + it('should correctly manage arrow state between different targets', fakeAsync(() => { + targetOne.hasArrow = true; + fix.detectChanges(); + + hoverElement(buttonOne); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, targetOne, true); + let arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + expect(arrow.style.display).toEqual(''); + + unhoverElement(buttonOne); + flush(); + + hoverElement(buttonTwo); + flush(); + + arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + verifyTooltipVisibility(tooltipNativeElement, targetTwo, true); + expect(arrow.style.display).toEqual('none'); + })); }); describe('Multiple tooltips', () => { @@ -670,7 +866,8 @@ describe('IgxTooltip', () => { verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); UIInteractions.simulateClickEvent(button.nativeElement); - fix.detectChanges(); + tick(HIDE_DELAY); + tick(300); verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); @@ -680,6 +877,173 @@ describe('IgxTooltip', () => { expect(fix.componentInstance.toggleDir.collapsed).toBe(false); })); }); + + describe('Tooltip Sticky with Close Button', () => { + beforeEach(() => { + fix = TestBed.createComponent(IgxTooltipWithCloseButtonComponent); + fix.detectChanges(); + tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement; + tooltipTarget = fix.componentInstance.tooltipTarget as IgxTooltipTargetDirective; + button = fix.debugElement.query(By.directive(IgxTooltipTargetDirective)); + }); + + it('should render custom close button when sticky is true', fakeAsync(() => { + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, button, true); + const closeBtn = document.querySelector('.my-close-btn'); + expect(closeBtn).toBeTruthy(); + })); + + it('should remove close button when sticky is set to false', fakeAsync(() => { + tooltipTarget.sticky = false; + fix.detectChanges(); + tick(); + + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const closeBtn = document.querySelector('.my-close-btn'); + expect(closeBtn).toBeFalsy(); + + })); + + it('should hide the tooltip custom close button is clicked', fakeAsync(() => { + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const closeBtn = tooltipNativeElement.querySelector('.my-close-btn') as HTMLElement; + UIInteractions.simulateClickAndSelectEvent(closeBtn); + + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('should use default close icon when no custom template is passed', fakeAsync(() => { + // Clear custom template + tooltipTarget.closeTemplate = null; + fix.detectChanges(); + tick(); + + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + const icon = document.querySelector('igx-icon'); + expect(icon).toBeTruthy(); + expect(icon?.textContent?.trim().toLowerCase()).toBe('close'); + })); + + it('should update the DOM role attribute correctly when sticky changes', fakeAsync(() => { + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + expect(tooltipNativeElement.getAttribute('role')).toBe('status'); + + tooltipTarget.sticky = false; + fix.detectChanges(); + tick(); + expect(tooltipNativeElement.getAttribute('role')).toBe('tooltip'); + })); + + it('should hide sticky tooltip when Escape is pressed', fakeAsync(() => { + tooltipTarget.sticky = true; + fix.detectChanges(); + + hoverElement(button); + flush(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + + // Dispatch Escape key + const escapeEvent = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true + }); + document.dispatchEvent(escapeEvent); + flush() + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false) + })); + + it('should correctly display a sticky tooltip on touchstart', fakeAsync(() => { + tooltipTarget.sticky = true; + fix.detectChanges(); + touchElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + const closeBtn = document.querySelector('.my-close-btn'); + expect(closeBtn).toBeTruthy(); + expect(tooltipNativeElement.getAttribute('role')).toBe('status'); + })); + }); + + describe('IgxTooltip placement and offset', () => { + beforeEach(waitForAsync(() => { + fix = TestBed.createComponent(IgxTooltipSingleTargetComponent); + fix.detectChanges(); + tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement; + tooltipTarget = fix.componentInstance.tooltipTarget as IgxTooltipTargetDirective; + button = fix.debugElement.query(By.directive(IgxTooltipTargetDirective)); + })); + + afterEach(() => { + UIInteractions.clearOverlay(); + }); + + it('should respect custom positive offset', fakeAsync(() => { + const customOffset = 20; + tooltipTarget.positionSettings = { + ...PositionsMap.get(Placement.Bottom), + offset: customOffset + }; + fix.detectChanges(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + verifyTooltipPosition(tooltipNativeElement, button, true, Placement.Bottom, customOffset); + })); + + it('should respect custom negative offset', fakeAsync(() => { + const customOffset = -10; + tooltipTarget.positionSettings = { + ...PositionsMap.get(Placement.Right), + offset: customOffset + }; + fix.detectChanges(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + verifyTooltipPosition(tooltipNativeElement, button, true, Placement.Right, customOffset); + })); + + it('should correctly position arrow based on tooltip placement', fakeAsync(() => { + tooltipTarget.positionSettings = { + ...PositionsMap.get(Placement.BottomStart), + }; + fix.detectChanges(); + + hoverElement(button); + flush(); + + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + verifyTooltipPosition(tooltipNativeElement, button, true, Placement.BottomStart); + + const arrow = tooltipNativeElement.querySelector(TOOLTIP_ARROW_SELECTOR) as HTMLElement; + expect(arrow).not.toBeNull(); + expect(arrow.style.left).toBe(""); + })); + }) }); interface ElementRefLike { @@ -698,25 +1062,89 @@ const verifyTooltipVisibility = (tooltipNativeElement, tooltipTarget, shouldBeVi expect(tooltipTarget?.tooltipHidden).not.toBe(shouldBeVisible); }; -const verifyTooltipPosition = (tooltipNativeElement, actualTarget, shouldBeAligned: boolean) => { - const targetRect = actualTarget.nativeElement.getBoundingClientRect(); - const tooltipRect = tooltipNativeElement.getBoundingClientRect(); +const directionTolerance = 2; +const alignmentTolerance = 2; + + +export const verifyTooltipPosition = ( + tooltipNativeElement: HTMLElement, + actualTarget: { nativeElement: HTMLElement }, + shouldAlign:boolean = true, + placement: Placement = Placement.Bottom, + offset: number = 6 +) => { + const tooltip = tooltipNativeElement.getBoundingClientRect(); + const target = actualTarget.nativeElement.getBoundingClientRect(); + + let directionCheckPassed = false; + let alignmentCheckPassed = false; + + let actualOffset; + + // --- placement check --- + if (placement.startsWith('top')) { + actualOffset = target.top - tooltip.bottom; + directionCheckPassed = Math.abs(actualOffset - offset) <= directionTolerance; + } else if (placement.startsWith('bottom')) { + actualOffset = tooltip.top - target.bottom; + directionCheckPassed = Math.abs(actualOffset - offset) <= directionTolerance; + } else if (placement.startsWith('left')) { + actualOffset = target.left - tooltip.right; + directionCheckPassed = Math.abs(actualOffset - offset) <= directionTolerance; + } else if (placement.startsWith('right')) { + actualOffset = tooltip.left - target.right; + directionCheckPassed = Math.abs(actualOffset - offset) <= directionTolerance; + } + - const targetRectMidX = targetRect.left + targetRect.width / 2; - const tooltipRectMidX = tooltipRect.left + tooltipRect.width / 2; + // --- alignment check --- + if (placement.startsWith('top') || placement.startsWith('bottom')) { + alignmentCheckPassed = horizontalAlignmentMatches(tooltip, target, placement); + } else { + alignmentCheckPassed = verticalAlignmentMatches(tooltip, target, placement); + } - const horizontalOffset = Math.abs(targetRectMidX - tooltipRectMidX); - const verticalOffset = tooltipRect.top - targetRect.bottom; + const result = directionCheckPassed && alignmentCheckPassed; - if (shouldBeAligned) { - // Verify that tooltip and target are horizontally aligned with approximately same center - expect(horizontalOffset >= 0).toBe(true, 'tooltip and target are horizontally MISaligned'); - expect(horizontalOffset <= 0.5).toBe(true, 'tooltip and target are horizontally MISaligned'); - // Verify that tooltip is vertically aligned beneath the target - expect(verticalOffset >= 0).toBe(true, 'tooltip and target are vertically MISaligned'); - expect(verticalOffset <= 6).toBe(true, 'tooltip and target are vertically MISaligned'); + if (shouldAlign) { + expect(result).toBeTruthy( + `Tooltip misaligned for "${placement}": actual offset=${actualOffset}, wanted offset=${offset}, accurate placement=${directionCheckPassed}, accurate alignment=${alignmentCheckPassed}` + ); } else { - // Verify that tooltip and target are NOT horizontally aligned with approximately same center - expect(horizontalOffset > 0.1).toBe(true, 'tooltip and target are horizontally aligned'); + expect(result).toBeFalsy( + `Tooltip was unexpectedly aligned` + ); } }; + +function horizontalAlignmentMatches( + tooltip: DOMRect, + target: DOMRect, + placement: Placement +): boolean { + if (placement.endsWith('start')) { + return Math.abs(tooltip.left - target.left) <= alignmentTolerance; + } else if (placement.endsWith('end')) { + return Math.abs(tooltip.right - target.right) <= alignmentTolerance; + } else { + const tooltipMid = tooltip.left + tooltip.width / 2; + const targetMid = target.left + target.width / 2; + return Math.abs(tooltipMid - targetMid) <= alignmentTolerance; + } +} + +function verticalAlignmentMatches( + tooltip: DOMRect, + target: DOMRect, + placement: Placement +): boolean { + if (placement.endsWith('start')) { + return Math.abs(tooltip.top - target.top) <= alignmentTolerance; + } else if (placement.endsWith('end')) { + return Math.abs(tooltip.bottom - target.bottom) <= alignmentTolerance; + } else { + const tooltipMid = tooltip.top + tooltip.height / 2; + const targetMid = target.top + target.height / 2; + return Math.abs(tooltipMid - targetMid) <= alignmentTolerance; + } +} diff --git a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts index 4bcd4d7aa3c..92bef088822 100644 --- a/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts +++ b/projects/igniteui-angular/src/lib/directives/tooltip/tooltip.directive.ts @@ -1,8 +1,8 @@ import { - Directive, ElementRef, Input, ChangeDetectorRef, Optional, HostBinding, Inject, OnDestroy, inject, DOCUMENT + Directive, ElementRef, Input, ChangeDetectorRef, Optional, HostBinding, Inject, + OnDestroy, inject, DOCUMENT, HostListener, } from '@angular/core'; import { IgxOverlayService } from '../../services/overlay/overlay'; -import { OverlaySettings } from '../../services/public_api'; import { IgxNavigationService } from '../../core/navigation'; import { IgxToggleDirective } from '../toggle/toggle.directive'; import { IgxTooltipTargetDirective } from './tooltip-target.directive'; @@ -83,32 +83,37 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy * ``` */ @HostBinding('attr.role') + @Input() + public set role(value: "tooltip" | "status"){ + this._role = value; + } public get role() { - return 'tooltip'; + return this._role; } /** - * @hidden - */ - public timeoutId; - - /** - * @hidden - * Returns whether close time out has started + * Get the arrow element of the tooltip. + * + * ```typescript + * let tooltipArrow = this.tooltip.arrow; + * ``` */ - public toBeHidden = false; + public get arrow(): HTMLElement { + return this._arrowEl; + } /** * @hidden - * Returns whether open time out has started */ - public toBeShown = false; + public timeoutId; /** * @hidden */ public tooltipTarget: IgxTooltipTargetDirective; + private _arrowEl: HTMLElement; + private _role: 'tooltip' | 'status' = 'tooltip'; private _destroy$ = new Subject(); private _document = inject(DOCUMENT); @@ -128,6 +133,8 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy this.closed.pipe(takeUntil(this._destroy$)).subscribe(() => { this._document.removeEventListener('touchstart', this.onDocumentTouchStart); }); + + this._createArrow(); } /** @hidden */ @@ -137,51 +144,65 @@ export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy this._document.removeEventListener('touchstart', this.onDocumentTouchStart); this._destroy$.next(true); this._destroy$.complete(); + this._removeArrow(); } /** - * If there is open animation in progress this method will finish is. - * If there is no open animation in progress this method will open the toggle with no animation. - * - * @param overlaySettings setting to use for opening the toggle + * @hidden */ - protected forceOpen(overlaySettings?: OverlaySettings) { - const info = this.overlayService.getOverlayById(this._overlayId); - const hasOpenAnimation = info ? info.openAnimationPlayer : false; - if (hasOpenAnimation) { - info.openAnimationPlayer.finish(); - info.openAnimationPlayer.reset(); - info.openAnimationPlayer = null; - } else if (this.collapsed) { - const animation = overlaySettings.positionStrategy.settings.openAnimation; - overlaySettings.positionStrategy.settings.openAnimation = null; - this.open(overlaySettings); - overlaySettings.positionStrategy.settings.openAnimation = animation; - } + @HostListener('mouseenter') + public onMouseEnter() { + this.tooltipTarget?.onMouseEnter(); + } + + /** + * @hidden + */ + @HostListener('mouseleave') + public onMouseLeave() { + this.tooltipTarget?.onMouseLeave(); } /** - * If there is close animation in progress this method will finish is. - * If there is no close animation in progress this method will close the toggle with no animation. + * If there is an animation in progress, this method will reset it to its initial state. + * Optional `force` parameter that ends the animation. * - * @param overlaySettings settings to use for closing the toggle + * @hidden + * @param force if set to `true`, the animation will be ended. */ - protected forceClose(overlaySettings?: OverlaySettings) { + public stopAnimations(force: boolean = false): void { const info = this.overlayService.getOverlayById(this._overlayId); - const hasCloseAnimation = info ? info.closeAnimationPlayer : false; - if (hasCloseAnimation) { - info.closeAnimationPlayer.finish(); + if (!info) return; + + if (info.openAnimationPlayer) { + info.openAnimationPlayer.reset(); + if (force) { + info.openAnimationPlayer.finish(); + info.openAnimationPlayer = null; + } + } + if (info.closeAnimationPlayer) { info.closeAnimationPlayer.reset(); - info.closeAnimationPlayer = null; - } else if (!this.collapsed) { - const animation = overlaySettings.positionStrategy.settings.closeAnimation; - overlaySettings.positionStrategy.settings.closeAnimation = null; - this.close(); - overlaySettings.positionStrategy.settings.closeAnimation = animation; + if (force) { + info.closeAnimationPlayer.finish(); + info.closeAnimationPlayer = null; + } } } + private _createArrow(): void { + this._arrowEl = document.createElement('span'); + this._arrowEl.style.position = 'absolute'; + this._arrowEl.setAttribute('data-arrow', 'true'); + this.element.appendChild(this._arrowEl); + } + + private _removeArrow(): void { + this._arrowEl.remove(); + this._arrowEl = null; + } + private onDocumentTouchStart(event) { this.tooltipTarget?.onDocumentTouchStart(event); } diff --git a/projects/igniteui-angular/src/lib/expansion-panel/expansion-panel-header.component.html b/projects/igniteui-angular/src/lib/expansion-panel/expansion-panel-header.component.html index 685d574bce9..79c26c1e310 100644 --- a/projects/igniteui-angular/src/lib/expansion-panel/expansion-panel-header.component.html +++ b/projects/igniteui-angular/src/lib/expansion-panel/expansion-panel-header.component.html @@ -12,8 +12,7 @@ @if (!iconTemplate) { + [name]="panel.collapsed ? 'expand' : 'collapse'"> }
diff --git a/projects/igniteui-angular/src/lib/grids/columns/column-layout.component.ts b/projects/igniteui-angular/src/lib/grids/columns/column-layout.component.ts index 1e303325cb2..c2a4b25d488 100644 --- a/projects/igniteui-angular/src/lib/grids/columns/column-layout.component.ts +++ b/projects/igniteui-angular/src/lib/grids/columns/column-layout.component.ts @@ -82,15 +82,10 @@ export class IgxColumnLayoutComponent extends IgxColumnGroupComponent implements } const unpinnedColumns = this.grid.unpinnedColumns.filter(c => c.columnLayout && !c.hidden); - const pinnedColumns = this.grid.pinnedColumns.filter(c => c.columnLayout && !c.hidden); - let vIndex = -1; - - if (!this.pinned) { - const indexInCollection = unpinnedColumns.indexOf(this); - vIndex = indexInCollection === -1 ? -1 : pinnedColumns.length + indexInCollection; - } else { - vIndex = pinnedColumns.indexOf(this); - } + const pinnedStart = this.grid.pinnedStartColumns.filter(c => c.columnLayout && !c.hidden); + const pinnedEndColumns = this.grid.pinnedEndColumns.filter(c => c.columnLayout && !c.hidden); + const ordered = pinnedStart.concat(unpinnedColumns, pinnedEndColumns); + let vIndex = ordered.indexOf(this); this._vIndex = vIndex; return vIndex; } @@ -158,7 +153,7 @@ export class IgxColumnLayoutComponent extends IgxColumnGroupComponent implements public override populateVisibleIndexes() { this.childrenVisibleIndexes = []; const columns = this.grid?.pinnedColumns && this.grid?.unpinnedColumns - ? this.grid.pinnedColumns.concat(this.grid.unpinnedColumns) + ? this.grid.pinnedStartColumns.concat(this.grid.unpinnedColumns, this.grid.pinnedEndColumns) : []; const orderedCols = columns .filter(x => !x.columnGroup && !x.hidden) diff --git a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts index 1e044edf6a6..db3e0569c2f 100644 --- a/projects/igniteui-angular/src/lib/grids/columns/column.component.ts +++ b/projects/igniteui-angular/src/lib/grids/columns/column.component.ts @@ -56,7 +56,7 @@ import { IColumnVisibilityChangingEventArgs, IPinColumnCancellableEventArgs, IPi import { isConstructor, PlatformUtil } from '../../core/utils'; import { IgxGridCell } from '../grid-public-cell'; import { NG_VALIDATORS, Validator } from '@angular/forms'; -import { Size } from '../common/enums'; +import { ColumnPinningPosition, Size } from '../common/enums'; import { ExpressionsTreeUtil } from '../../data-operations/expressions-tree-util'; const DEFAULT_DATE_FORMAT = 'mediumDate'; @@ -993,8 +993,9 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy */ public get minWidthPx() { const gridAvailableSize = this.grid.calcWidth; - const isPercentageWidth = this.minWidth && typeof this.minWidth === 'string' && this.minWidth.indexOf('%') !== -1; - return isPercentageWidth ? parseFloat(this.minWidth) / 100 * gridAvailableSize : parseFloat(this.minWidth); + const minWidth = this.minWidth || this.defaultMinWidth; + const isPercentageWidth = minWidth && typeof minWidth === 'string' && minWidth.indexOf('%') !== -1; + return isPercentageWidth ? parseFloat(minWidth) / 100 * gridAvailableSize : parseFloat(minWidth); } /** @@ -1011,8 +1012,9 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy */ public get minWidthPercent() { const gridAvailableSize = this.grid.calcWidth; - const isPercentageWidth = this.minWidth && typeof this.minWidth === 'string' && this.minWidth.indexOf('%') !== -1; - return isPercentageWidth ? parseFloat(this.minWidth) : parseFloat(this.minWidth) / gridAvailableSize * 100; + const minWidth = this.minWidth || this.defaultMinWidth; + const isPercentageWidth = minWidth && typeof minWidth === 'string' && minWidth.indexOf('%') !== -1; + return isPercentageWidth ? parseFloat(minWidth) : parseFloat(minWidth) / gridAvailableSize * 100; } @@ -1040,7 +1042,7 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy this.grid.notifyChanges(true); } public get minWidth(): string { - return !this._defaultMinWidth ? this.defaultMinWidth : this._defaultMinWidth; + return this._defaultMinWidth; } /** @hidden @internal **/ @@ -1064,6 +1066,28 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy return (this.grid as any)._columns.indexOf(this); } + /** + * Gets the pinning position of the column. + * ```typescript + * let pinningPosition = this.column.pinningPosition; + */ + @WatchColumnChanges() + @Input() + public get pinningPosition(): ColumnPinningPosition { + const userSet = this._pinningPosition !== null && this._pinningPosition !== undefined; + return userSet ? this._pinningPosition : this.grid.pinning.columns; + } + + /** + * Sets the pinning position of the column. + *```html + * + * ``` + */ + public set pinningPosition(value: ColumnPinningPosition) { + this._pinningPosition = value; + } + /** * Gets whether the column is `pinned`. * ```typescript @@ -1534,7 +1558,8 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy return this._vIndex; } const unpinnedColumns = this.grid.unpinnedColumns.filter(c => !c.columnGroup); - const pinnedColumns = this.grid.pinnedColumns.filter(c => !c.columnGroup); + const pinnedStartColumns = this.grid.pinnedStartColumns.filter(c => !c.columnGroup); + const pinnedEndColumns = this.grid.pinnedEndColumns.filter(c => !c.columnGroup); let col = this; let vIndex = -1; @@ -1549,15 +1574,13 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy if (!this.pinned) { const indexInCollection = unpinnedColumns.indexOf(col); vIndex = indexInCollection === -1 ? - -1 : - (this.grid.isPinningToStart ? - pinnedColumns.length + indexInCollection : - indexInCollection); + -1 : pinnedStartColumns.length + indexInCollection; } else { - const indexInCollection = pinnedColumns.indexOf(col); - vIndex = this.grid.isPinningToStart ? + const indexInCollection = this.pinningPosition === ColumnPinningPosition.Start ? + pinnedStartColumns.indexOf(col) : pinnedEndColumns.indexOf(col); + vIndex = this.pinningPosition === ColumnPinningPosition.Start ? indexInCollection : - unpinnedColumns.length + indexInCollection; + pinnedStartColumns.length + unpinnedColumns.length + indexInCollection; } this._vIndex = vIndex; return vIndex; @@ -1635,20 +1658,20 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy /** @hidden @internal **/ public get isLastPinned(): boolean { - return this.grid.isPinningToStart && - this.grid.pinnedColumns[this.grid.pinnedColumns.length - 1] === this; + return this.pinningPosition === ColumnPinningPosition.Start && + this.grid.pinnedStartColumns[this.grid.pinnedStartColumns.length - 1] === this; } /** @hidden @internal **/ public get isFirstPinned(): boolean { - const pinnedCols = this.grid.pinnedColumns.filter(x => !x.columnGroup); - return !this.grid.isPinningToStart && pinnedCols[0] === this; + const pinnedCols = this.grid.pinnedEndColumns.filter(x => !x.columnGroup); + return this.pinningPosition === ColumnPinningPosition.End && pinnedCols[0] === this; } /** @hidden @internal **/ public get rightPinnedOffset(): string { - return this.pinned && !this.grid.isPinningToStart ? - - this.grid.pinnedWidth - this.grid.headerFeaturesWidth + 'px' : + return this.pinned && this.pinningPosition === ColumnPinningPosition.End ? + - this.grid.pinnedEndWidth - this.grid.pinnedStartWidth + 'px' : null; } @@ -1841,6 +1864,7 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy protected _applySelectableClass = false; protected _vIndex = NaN; + protected _pinningPosition = null; /** * @hidden */ @@ -2240,20 +2264,19 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy } /** - * Pins the column at the provided index in the pinned area. + * Pins the column in the specified position at the provided index in that pinned area. * Defaults to index `0` if not provided, or to the initial index in the pinned area. * Returns `true` if the column is successfully pinned. Returns `false` if the column cannot be pinned. * Column cannot be pinned if: * - Is already pinned * - index argument is out of range - * - The pinned area exceeds 80% of the grid width * ```typescript * let success = this.column.pin(); * ``` * * @memberof IgxColumnComponent */ - public pin(index?: number): boolean { + public pin(index?: number, pinningPosition?: ColumnPinningPosition): boolean { // TODO: Probably should the return type of the old functions // should be moved as a event parameter. const grid = (this.grid as any); @@ -2262,11 +2285,15 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy } if (this.parent && !this.parent.pinned) { - return this.topLevelParent.pin(index); - } - - const hasIndex = index !== undefined; - if (hasIndex && (index < 0 || index > grid.pinnedColumns.length)) { + return this.topLevelParent.pin(index, pinningPosition); + } + const targetPinPosition = pinningPosition !== null && pinningPosition !== undefined ? pinningPosition : this.pinningPosition; + const pinningVisibleCollection = targetPinPosition === ColumnPinningPosition.Start ? + grid.pinnedStartColumns : grid.pinnedEndColumns; + const pinningCollection = targetPinPosition === ColumnPinningPosition.Start ? + grid._pinnedStartColumns : grid._pinnedEndColumns; + const hasIndex = index !== undefined && index !== null; + if (hasIndex && (index < 0 || index > pinningVisibleCollection.length)) { return false; } @@ -2274,7 +2301,7 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy return false; } - const rootPinnedCols = grid._pinnedColumns.filter((c) => c.level === 0); + const rootPinnedCols = pinningCollection.filter((c) => c.level === 0); index = hasIndex ? index : rootPinnedCols.length; const args: IPinColumnCancellableEventArgs = { column: this, insertAtIndex: index, isPinned: false, cancel: false }; this.grid.columnPin.emit(args); @@ -2286,14 +2313,20 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy this.grid.crudService.endEdit(false); this._pinned = true; + if (pinningPosition !== null && pinningPosition !== undefined) { + // if user has set some position in the params, overwrite the column's position. + this._pinningPosition = pinningPosition; + } + this.pinnedChange.emit(this._pinned); // it is possible that index is the last position, so will need to find target column by [index-1] - const targetColumn = args.insertAtIndex === grid._pinnedColumns.length ? - grid._pinnedColumns[args.insertAtIndex - 1] : grid._pinnedColumns[args.insertAtIndex]; + const targetColumn = args.insertAtIndex === pinningCollection.length ? + pinningCollection[args.insertAtIndex - 1] : pinningCollection[args.insertAtIndex]; - if (grid._pinnedColumns.indexOf(this) === -1) { + if (pinningCollection.indexOf(this) === -1) { if (!grid.hasColumnGroups) { - grid._pinnedColumns.splice(args.insertAtIndex, 0, this); + pinningCollection.splice(args.insertAtIndex, 0, this); + grid._pinnedColumns = grid._pinnedStartColumns.concat(grid._pinnedEndColumns); } else { // insert based only on root collection if (this.level === 0) { @@ -2307,6 +2340,11 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy allPinned = allPinned.concat(group.allChildren); }); grid._pinnedColumns = allPinned; + if (this.pinningPosition === ColumnPinningPosition.Start) { + grid._pinnedStartColumns = allPinned; + } else { + grid._pinnedEndColumns = allPinned; + } } if (grid._unpinnedColumns.indexOf(this) !== -1) { @@ -2316,12 +2354,12 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy } if (hasIndex) { - index === grid._pinnedColumns.length - 1 ? + index === pinningCollection.length - 1 ? grid._moveColumns(this, targetColumn, DropPosition.AfterDropTarget) : grid._moveColumns(this, targetColumn, DropPosition.BeforeDropTarget); } if (this.columnGroup) { - this.allChildren.forEach(child => child.pin()); + this.allChildren.forEach(child => child.pin(null, targetPinPosition)); grid.reinitPinStates(); } @@ -2357,7 +2395,7 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy if (this.parent && this.parent.pinned) { return this.topLevelParent.unpin(index); } - const hasIndex = index !== undefined; + const hasIndex = index !== undefined && index !== null; if (hasIndex && (index < 0 || index > grid._unpinnedColumns.length)) { return false; } @@ -2392,6 +2430,12 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy if (grid._pinnedColumns.indexOf(this) !== -1) { grid._pinnedColumns.splice(grid._pinnedColumns.indexOf(this), 1); } + if (this.pinningPosition === ColumnPinningPosition.Start && grid._pinnedStartColumns.indexOf(this) !== -1) { + grid._pinnedStartColumns.splice(grid._pinnedStartColumns.indexOf(this), 1); + } + if (this.pinningPosition === ColumnPinningPosition.End && grid._pinnedEndColumns.indexOf(this) !== -1) { + grid._pinnedEndColumns.splice(grid._pinnedEndColumns.indexOf(this), 1); + } } if (hasIndex) { @@ -2702,8 +2746,15 @@ export class IgxColumnComponent implements AfterContentInit, OnDestroy, ColumnTy const currentCalcWidth = this.defaultWidth || this.grid.getPossibleColumnWidth(); this._calcWidth = this.getConstrainedSizePx(currentCalcWidth); } else { - const currentCalcWidth = parseFloat(this.width); - this._calcWidth =this.getConstrainedSizePx(currentCalcWidth); + let possibleColumnWidth = ''; + if (!this.widthSetByUser && this.userSetMinWidthPx && this.userSetMinWidthPx < this.grid.minColumnWidth) { + possibleColumnWidth = this.defaultWidth = this.grid.getPossibleColumnWidth(null, this.userSetMinWidthPx); + } else { + possibleColumnWidth = this.width; + } + + const currentCalcWidth = parseFloat(possibleColumnWidth); + this._calcWidth = this.getConstrainedSizePx(currentCalcWidth); } this.calcPixelWidth = parseFloat(this._calcWidth); } diff --git a/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts b/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts index c01f4cd1cd4..fda967afb6c 100644 --- a/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts +++ b/projects/igniteui-angular/src/lib/grids/common/grid.interface.ts @@ -296,19 +296,63 @@ export interface RowType { */ unpin?: () => void; } - +/** + * Describes a field that can be used in the Grid and QueryBuilder components. + */ export interface FieldType { + /** + * Display label for the field. + */ label?: string; + + /** + * The internal field name, used in expressions and queries. + */ field: string; + + /** + * Optional column header for UI display purposes. + */ header?: string; + + /** + * The data type of the field. + */ /* alternateType: GridColumnDataType */ dataType: DataType; + + /** + * Options for the editor associated with this field. + */ editorOptions?: IFieldEditorOptions; + + /** + * Optional filtering operands that apply to this field. + */ filters?: IgxFilteringOperand; + + /** + * Optional arguments for any pipe applied to the field. + */ pipeArgs?: IFieldPipeArgs; + + /** + * Default time format for Date/Time fields. + */ defaultTimeFormat?: string; + + /** + * Default date/time format for Date/Time fields. + */ defaultDateTimeFormat?: string; + /** + * Optional formatter function to transform the value before display. + * + * @param value - The value of the field. + * @param rowData - Optional row data that contains this field. + * @returns The formatted value. + */ formatter?(value: any, rowData?: any): any; } @@ -523,7 +567,7 @@ export interface ColumnType extends FieldType { toggleVisibility(value?: boolean): void; populateVisibleIndexes?(): void; /** Pins the column at the specified index (if not already pinned). */ - pin(index?: number): boolean; + pin(index?: number, pinningPosition?: ColumnPinningPosition): boolean; /** Unpins the column at the specified index (if not already unpinned). */ unpin(index?: number): boolean; } @@ -785,13 +829,13 @@ export interface GridType extends IGridDataBindable { isRowSelectable: boolean; /** Indicates whether the selectors of the rows are visible */ showRowSelectors: boolean; - /** Indicates whether the grid's element is pinned to the start of the grid */ - isPinningToStart: boolean; /** Indicates if the column of the grid is in drag mode */ columnInDrag: any; /** @hidden @internal */ - /** The width of pinned element */ - pinnedWidth: number; + /** The width of pinned element for pinning at start. */ + pinnedStartWidth: number; + /** The width of pinned element for pinning at end. */ + pinnedEndWidth: number; /** @hidden @internal */ /** The width of unpinned element */ unpinnedWidth: number; @@ -926,6 +970,10 @@ export interface GridType extends IGridDataBindable { unpinnedColumns: ColumnType[]; /** An array of columns, but it counts only the ones that are pinned */ pinnedColumns: ColumnType[]; + /** An array of columns, but it counts only the ones that are pinned to the start. */ + pinnedStartColumns: ColumnType[]; + /** An array of columns, but it counts only the ones that are pinned to the end. */ + pinnedEndColumns: ColumnType[]; /** represents an array of the headers of the columns */ /** @hidden @internal */ headerCellList: any[]; @@ -1165,7 +1213,7 @@ export interface GridType extends IGridDataBindable { refreshSearch(): void; getDefaultExpandState(record: any): boolean; trackColumnChanges(index: number, column: any): any; - getPossibleColumnWidth(): string; + getPossibleColumnWidth(baseWidth?: number, minColumnWidth?: number): string; resetHorizontalVirtualization(): void; hasVerticalScroll(): boolean; getVisibleContentHeight(): number; @@ -1502,10 +1550,24 @@ export interface IClipboardOptions { } /** - * An interface describing entity + * Describes an entity in the QueryBuilder. + * An entity represents a logical grouping of fields and can have nested child entities. */ export interface EntityType { + /** + * The name of the entity. + * Typically used as an identifier in expressions. + */ name: string; + + /** + * The list of fields that belong to this entity. + */ fields: FieldType[]; + + /** + * Optional child entities. + * This allows building hierarchical or nested query structures. + */ childEntities?: EntityType[]; } diff --git a/projects/igniteui-angular/src/lib/grids/filtering/excel-style/excel-style-moving.component.ts b/projects/igniteui-angular/src/lib/grids/filtering/excel-style/excel-style-moving.component.ts index 80aa1564310..3604c63b44e 100644 --- a/projects/igniteui-angular/src/lib/grids/filtering/excel-style/excel-style-moving.component.ts +++ b/projects/igniteui-angular/src/lib/grids/filtering/excel-style/excel-style-moving.component.ts @@ -4,6 +4,7 @@ import { BaseFilteringComponent } from './base-filtering.component'; import { IgxIconComponent } from '../../../icon/icon.component'; import { IgxButtonDirective } from '../../../directives/button/button.directive'; import { IgxButtonGroupComponent } from '../../../buttonGroup/buttonGroup.component'; +import { ColumnPinningPosition } from '../../common/enums'; /** * A component used for presenting Excel style column moving UI. @@ -49,25 +50,26 @@ export class IgxExcelStyleMovingComponent { public onMoveButtonClicked(moveDirection) { let targetColumn; if (this.esf.column.pinned) { - if (this.esf.column.isLastPinned && moveDirection === 1 && this.esf.grid.isPinningToStart) { + if (this.esf.column.isLastPinned && moveDirection === 1 && this.esf.column.pinningPosition === ColumnPinningPosition.Start) { targetColumn = this.esf.grid.unpinnedColumns[0]; moveDirection = 0; - } else if (this.esf.column.isFirstPinned && moveDirection === 0 && !this.esf.grid.isPinningToStart) { + } else if (this.esf.column.isFirstPinned && moveDirection === 0 && this.esf.column.pinningPosition === ColumnPinningPosition.End) { targetColumn = this.esf.grid.unpinnedColumns[this.esf.grid.unpinnedColumns.length - 1]; moveDirection = 1; } else { targetColumn = this.findColumn(moveDirection, this.esf.grid.pinnedColumns); } - } else if (this.esf.grid.unpinnedColumns.indexOf(this.esf.column) === 0 && moveDirection === 0 && - this.esf.grid.isPinningToStart) { - targetColumn = this.esf.grid.pinnedColumns[this.esf.grid.pinnedColumns.length - 1]; + } else if (this.esf.grid.unpinnedColumns.indexOf(this.esf.column) === 0 && moveDirection === 0) { + // moving first unpinned, left (into pin start area) + targetColumn = this.esf.grid.pinnedStartColumns[this.esf.grid.pinnedStartColumns.length - 1]; if (targetColumn.parent) { targetColumn = targetColumn.topLevelParent; } moveDirection = 1; } else if (this.esf.grid.unpinnedColumns.indexOf(this.esf.column) === this.esf.grid.unpinnedColumns.length - 1 && - moveDirection === 1 && !this.esf.grid.isPinningToStart) { - targetColumn = this.esf.grid.pinnedColumns[0]; + moveDirection === 1) { + // moving last unpinned, right (into pin end area) + targetColumn = this.esf.grid.pinnedEndColumns[0]; moveDirection = 0; } else { targetColumn = this.findColumn(moveDirection, this.esf.grid.unpinnedColumns); diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts index 5e840192f1a..80883a9302a 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.directive.ts @@ -2664,14 +2664,6 @@ export abstract class IgxGridBaseDirective implements GridType, this._headSelectorTemplate = template; } - /** - * @hidden - * @internal - */ - public get isPinningToStart() { - return this.pinning.columns !== ColumnPinningPosition.End; - } - /** * @hidden * @internal @@ -3170,6 +3162,17 @@ export abstract class IgxGridBaseDirective implements GridType, * @hidden */ protected _pinnedColumns: IgxColumnComponent[] = []; + + /** + * @hidden + */ + protected _pinnedStartColumns: IgxColumnComponent[] = []; + + /** + * @hidden + */ + protected _pinnedEndColumns: IgxColumnComponent[] = []; + /** * @hidden */ @@ -3314,7 +3317,8 @@ export abstract class IgxGridBaseDirective implements GridType, private _totalWidth = NaN; private _pinnedVisible = []; private _unpinnedVisible = []; - private _pinnedWidth = NaN; + private _pinnedStartWidth = NaN; + private _pinnedEndWidth = NaN; private _unpinnedWidth = NaN; private _visibleColumns = []; private _columnGroups = false; @@ -4047,7 +4051,8 @@ export abstract class IgxGridBaseDirective implements GridType, */ public resetCachedWidths() { this._unpinnedWidth = NaN; - this._pinnedWidth = NaN; + this._pinnedStartWidth = NaN; + this._pinnedEndWidth = NaN; this._totalWidth = NaN; } @@ -4574,12 +4579,21 @@ export abstract class IgxGridBaseDirective implements GridType, } /** @hidden @internal */ - public get pinnedWidth() { - if (!isNaN(this._pinnedWidth)) { - return this._pinnedWidth; + public get pinnedStartWidth() { + if (!isNaN(this._pinnedStartWidth)) { + return this._pinnedStartWidth; } - this._pinnedWidth = this.getPinnedWidth(); - return this._pinnedWidth; + this._pinnedStartWidth = this.getPinnedStartWidth(); + return this._pinnedStartWidth; + } + + /** @hidden @internal */ + public get pinnedEndWidth() { + if (!isNaN(this._pinnedEndWidth)) { + return this._pinnedEndWidth; + } + this._pinnedEndWidth = this.getPinnedEndWidth(); + return this._pinnedEndWidth; } /** @hidden @internal */ @@ -4670,6 +4684,30 @@ export abstract class IgxGridBaseDirective implements GridType, return this._pinnedVisible; } + /** + * Gets an array of the pinned to the left `IgxColumnComponent`s. + * + * @example + * ```typescript + * const pinnedColumns = this.grid.pinnedStartColumns. + * ``` + */ + public get pinnedStartColumns(): IgxColumnComponent[] { + return this._pinnedStartColumns.filter(col => !col.hidden); + } + + /** + * Gets an array of the pinned to the right `IgxColumnComponent`s. + * + * @example + * ```typescript + * const pinnedColumns = this.grid.pinnedEndColumns. + * ``` + */ + public get pinnedEndColumns(): IgxColumnComponent[] { + return this._pinnedEndColumns.filter(col => !col.hidden); + } + /* csSuppress */ /** * Gets an array of the pinned `IgxRowComponent`s. @@ -4885,9 +4923,9 @@ export abstract class IgxGridBaseDirective implements GridType, // pinning and unpinning will work correctly even without passing index // but is easier to calclulate the index here, and later use it in the pinning event args if (target.pinned && !column.pinned) { - const pinnedIndex = this._pinnedColumns.indexOf(target); + const pinnedIndex = target.pinningPosition === ColumnPinningPosition.Start ? this.pinnedStartColumns.indexOf(target) : this.pinnedEndColumns.indexOf(target); const index = pos === DropPosition.AfterDropTarget ? pinnedIndex + 1 : pinnedIndex; - column.pin(index); + column.pin(index, target.pinningPosition); } if (!target.pinned && column.pinned) { @@ -4896,6 +4934,11 @@ export abstract class IgxGridBaseDirective implements GridType, column.unpin(index); } + // both are pinned but are in different sides + if (target.pinned && column.pinned && target.pinningPosition !== column.pinningPosition) { + column.pinningPosition = target.pinningPosition; + } + // if (target.pinned && column.pinned && !columnPinStateChanged) { // this._reorderColumns(column, target, pos, this._pinnedColumns); // } @@ -5293,10 +5336,11 @@ export abstract class IgxGridBaseDirective implements GridType, * ``` * @param columnName * @param index + * @param pinningPosition */ - public pinColumn(columnName: string | IgxColumnComponent, index?: number): boolean { + public pinColumn(columnName: string | IgxColumnComponent, index?: number, pinningPosition?: ColumnPinningPosition): boolean { const col = columnName instanceof IgxColumnComponent ? columnName : this.getColumnByName(columnName); - return col.pin(index); + return col.pin(index, pinningPosition); } /** @@ -5639,7 +5683,7 @@ export abstract class IgxGridBaseDirective implements GridType, /** * @hidden @internal */ - public getPossibleColumnWidth(baseWidth: number = null) { + public getPossibleColumnWidth(baseWidth: number = null, minColumnWidth: number = null) { let computedWidth; if (baseWidth !== null) { computedWidth = baseWidth; @@ -5688,9 +5732,11 @@ export abstract class IgxGridBaseDirective implements GridType, } computedWidth -= this.featureColumnsWidth(); + const minColWidth = minColumnWidth || this.minColumnWidth; + const columnWidth = !Number.isFinite(sumExistingWidths) ? - Math.max(computedWidth / columnsToSize, this.minColumnWidth) : - Math.max((computedWidth - sumExistingWidths) / columnsToSize, this.minColumnWidth); + Math.max(computedWidth / columnsToSize, minColWidth) : + Math.max((computedWidth - sumExistingWidths) / columnsToSize, minColWidth); return columnWidth + 'px'; } @@ -5707,29 +5753,48 @@ export abstract class IgxGridBaseDirective implements GridType, } /** - * Gets calculated width of the pinned area. + * Gets calculated width of the pinned areas. * * @example * ```typescript - * const pinnedWidth = this.grid.getPinnedWidth(); + * const pinnedWidth = this.grid.getPinnedStartWidth(); * ``` * @param takeHidden If we should take into account the hidden columns in the pinned area. */ - public getPinnedWidth(takeHidden = false) { - const fc = takeHidden ? this._pinnedColumns : this.pinnedColumns; + public getPinnedStartWidth(takeHidden = false) { + const fc = takeHidden ? this._pinnedStartColumns : this.pinnedStartColumns; let sum = 0; for (const col of fc) { if (col.level === 0) { sum += parseFloat(col.calcWidth); } } - if (this.isPinningToStart) { - sum += this.featureColumnsWidth(); - } + // includes features at start + sum += this.featureColumnsWidth(); return sum; } + /** + * Gets calculated width of the pinned areas. + * + * @example + * ```typescript + * const pinnedWidth = this.grid.getPinnedEndWidth(); + * ``` + * @param takeHidden If we should take into account the hidden columns in the pinned area. + */ + public getPinnedEndWidth(takeHidden = false) { + const fc = takeHidden ? this._pinnedEndColumns : this.pinnedEndColumns; + let sum = 0; + for (const col of fc) { + if (col.level === 0) { + sum += parseFloat(col.calcWidth); + } + } + return sum; + } + /** * @hidden @internal */ @@ -6834,7 +6899,7 @@ export abstract class IgxGridBaseDirective implements GridType, * @hidden */ protected _moveColumns(from: IgxColumnComponent, to: IgxColumnComponent, pos: DropPosition) { - const orderedList = this._pinnedColumns.concat(this._unpinnedColumns); + const orderedList = this._pinnedStartColumns.concat(this._unpinnedColumns, this._pinnedEndColumns); const list = orderedList; this._reorderColumns(from, to, pos, list); const newList = this._resetColumnList(list); @@ -6850,6 +6915,8 @@ export abstract class IgxGridBaseDirective implements GridType, // update internal collections to retain order. this._pinnedColumns = newColumns .filter((c) => c.pinned); + this._pinnedStartColumns = newColumns.filter((c) => c.pinned && c.pinningPosition === ColumnPinningPosition.Start); + this._pinnedEndColumns = newColumns.filter((c) => c.pinned && c.pinningPosition === ColumnPinningPosition.End); this._unpinnedColumns = newColumns.filter((c) => !c.pinned); this._columns = newColumns; if (this._columns && this._columns.length && this._filteringExpressionsTree) { @@ -7003,6 +7070,11 @@ export abstract class IgxGridBaseDirective implements GridType, added = true; if (record.item.pinned) { this._pinnedColumns.push(record.item); + if (record.item.pinningPosition === ColumnPinningPosition.Start) { + this._pinnedStartColumns.push(record.item); + } else { + this._pinnedEndColumns.push(record.item); + } pinning = true; } else { this._unpinnedColumns.push(record.item); @@ -7285,11 +7357,8 @@ export abstract class IgxGridBaseDirective implements GridType, if (this.hasVerticalScroll() && !this.isPercentWidth) { width -= this.scrollSize; } - if (!this.isPinningToStart) { - width -= this.featureColumnsWidth(); - } - return width - this.getPinnedWidth(takeHidden); + return width - (this.getPinnedStartWidth(takeHidden) + this.getPinnedEndWidth(takeHidden)); } /** @@ -7417,6 +7486,10 @@ export abstract class IgxGridBaseDirective implements GridType, protected reinitPinStates() { this._pinnedColumns = this._columns .filter((c) => c.pinned).sort((a, b) => this._pinnedColumns.indexOf(a) - this._pinnedColumns.indexOf(b)); + this._pinnedStartColumns = this._columns.filter((c) => c.pinned && c.pinningPosition === ColumnPinningPosition.Start) + .sort((a, b) => this._pinnedStartColumns.indexOf(a) - this._pinnedStartColumns.indexOf(b)); + this._pinnedEndColumns = this._columns.filter((c) => c.pinned && c.pinningPosition === ColumnPinningPosition.End) + .sort((a, b) => this._pinnedEndColumns.indexOf(a) - this._pinnedEndColumns.indexOf(b)); this._unpinnedColumns = this.hasColumnGroups ? this._columns.filter((c) => !c.pinned) : this._columns.filter((c) => !c.pinned) .sort((a, b) => this._unpinnedColumns.indexOf(a) - this._unpinnedColumns.indexOf(b)); @@ -7690,9 +7763,9 @@ export abstract class IgxGridBaseDirective implements GridType, let columnIndex = typeof column === 'number' ? column : this.getColumnByName(column).visibleIndex; const scrollRow = this.rowList.find(r => !!r.virtDirRow); const virtDir = scrollRow ? scrollRow.virtDirRow : null; - if (this.isPinningToStart && this.pinnedColumns.length) { - if (columnIndex >= this.pinnedColumns.length) { - columnIndex -= this.pinnedColumns.length; + if (this.pinnedStartColumns.length) { + if (columnIndex >= this.pinnedStartColumns.length) { + columnIndex -= this.pinnedStartColumns.length; this.scrollDirective(virtDir, columnIndex); } } else { @@ -8143,6 +8216,8 @@ export abstract class IgxGridBaseDirective implements GridType, } // Assign the applicable collections. this._pinnedColumns = pinnedColumns; + this._pinnedStartColumns = pinnedColumns.filter((c) => c.pinned && c.pinningPosition === ColumnPinningPosition.Start); + this._pinnedEndColumns = pinnedColumns.filter((c) => c.pinned && c.pinningPosition === ColumnPinningPosition.End); this._unpinnedColumns = unpinnedColumns; } diff --git a/projects/igniteui-angular/src/lib/grids/grid/column-resizing.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/column-resizing.spec.ts index 98a1950e2a4..45273b7deae 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/column-resizing.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/column-resizing.spec.ts @@ -11,7 +11,7 @@ import { MultiColumnHeadersComponent } from '../../test-utils/grid-samples.spec' import { GridFunctions } from '../../test-utils/grid-functions.spec'; import { IgxCellHeaderTemplateDirective, IgxCellTemplateDirective } from '../columns/templates.directive'; import { IgxAvatarComponent } from '../../avatar/avatar.component'; -import { IColumnResizeEventArgs, IgxColumnComponent } from '../public_api'; +import { IColumnResizeEventArgs, IgxColumnComponent, IgxGridToolbarComponent, IgxGridToolbarTitleComponent } from '../public_api'; import { Size } from "../common/enums"; import { setElementSize } from '../../test-utils/helper-utils.spec'; import { IgxColumnResizerDirective } from '../resizing/resizer.directive'; @@ -29,6 +29,7 @@ describe('IgxGrid - Deferred Column Resizing #grid', () => { GridFeaturesComponent, LargePinnedColGridComponent, NullColumnsComponent, + MinWidthColumnsComponent, ColGridComponent, ColPercentageGridComponent ] @@ -902,6 +903,77 @@ describe('IgxGrid - Deferred Column Resizing #grid', () => { expect(headers[headers.length - 1].nativeElement.innerText).toEqual("ReleaseDate"); expect(firstRowCells.length).toEqual(11); })); + + it('should use user-provided `minWidth` as default min column width to size columns - #16057.', fakeAsync(() => { + const fixture = TestBed.createComponent(MinWidthColumnsComponent); + fixture.detectChanges(); + + const grid = fixture.componentInstance.grid; + + expect(grid.columnList.get(0).width).toEqual('130px'); + expect(grid.columnList.get(1).width).toEqual('90px'); + expect(grid.columnList.get(2).width).toEqual('90px'); + expect(grid.columnList.get(3).width).toEqual('90px'); + })); + }); + + describe('Resizer tests: ', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(ResizableColumnsWithToolbarComponent); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + })); + + it('should align the resizer top with the grid header top', fakeAsync(() => { + grid.nativeElement.style.marginTop = '40px'; + fixture.detectChanges(); + const headers = GridFunctions.getColumnHeaders(fixture); + const headerResArea = GridFunctions.getHeaderResizeArea(headers[0]).nativeElement; + + const headerRectTop = headerResArea.getBoundingClientRect().top; + + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 100, 15); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + + const resizerRectTop = resizer.getBoundingClientRect().top; + UIInteractions.simulateMouseEvent('mousemove', resizer, 250, 15); + UIInteractions.simulateMouseEvent('mouseup', resizer, 250, 15); + fixture.detectChanges(); + + + expect(Math.abs(resizerRectTop - headerRectTop)).toBeLessThanOrEqual(1); + })); + + it('should align the resizer top with the grid header top when grid is scaled', fakeAsync(() => { + grid.nativeElement.style.transform = 'scale(0.6)'; + fixture.detectChanges(); + + const headers = GridFunctions.getColumnHeaders(fixture); + const headerResArea = GridFunctions.getHeaderResizeArea(headers[1]).nativeElement; + const headerRectTop = headerResArea.getBoundingClientRect().top; + + // Trigger resize to show resizer + UIInteractions.simulateMouseEvent('mousedown', headerResArea, 153, 0); + tick(200); + fixture.detectChanges(); + + const resizer = GridFunctions.getResizer(fixture).nativeElement; + expect(resizer).toBeDefined(); + + const resizerRectTop = resizer.getBoundingClientRect().top; + + UIInteractions.simulateMouseEvent('mouseup', resizer, 200, 5); + fixture.detectChanges(); + + expect(Math.abs(resizerRectTop - headerRectTop)).toBeLessThanOrEqual(1); + })); }); }); @@ -915,6 +987,18 @@ export class ResizableColumnsComponent { public data = SampleTestData.personIDNameRegionData(); } +@Component({ + template: GridTemplateStrings.declareGrid(`width="500px" height="300px"`, ``, + 'Grid Toolbar' + + ColumnDefinitions.resizableThreeOfFour), + imports: [IgxGridComponent, IgxColumnComponent, IgxGridToolbarComponent, IgxGridToolbarTitleComponent] +}) +export class ResizableColumnsWithToolbarComponent { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + + public data = SampleTestData.personIDNameRegionData(); +} + @Component({ template: GridTemplateStrings.declareGrid(`width="618px" height="600px"`, ``, ` + + + `), + imports: [IgxGridComponent, IgxColumnComponent] +}) +export class MinWidthColumnsComponent implements OnInit { + @ViewChild(IgxGridComponent, { static: true }) public grid: IgxGridComponent; + + public data = []; + + public ngOnInit(): void { + this.data = SampleTestData.contactInfoData(); + } +} + @Component({ template: GridTemplateStrings.declareGrid(`width="400px" height="600px" [allowFiltering]="true"`, ``, ` diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-mrl-keyboard-nav.spec.ts b/projects/igniteui-angular/src/lib/grids/grid/grid-mrl-keyboard-nav.spec.ts index dcffd2d8e89..a4b64813680 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-mrl-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-mrl-keyboard-nav.spec.ts @@ -2191,7 +2191,7 @@ describe('IgxGrid Multi Row Layout - Keyboard navigation #grid', () => { const firstUnpinnedCell = grid.gridAPI.get_cell_by_index(0, 'ContactName'); expect(firstUnpinnedCell.active).toBe(true); const diff = firstUnpinnedCell.nativeElement.getBoundingClientRect().left - - grid.pinnedWidth - grid.tbody.nativeElement.getBoundingClientRect().left; + grid.pinnedStartWidth - grid.tbody.nativeElement.getBoundingClientRect().left; expect(diff).toBe(0); // TODO: Rest of the test needs to be finished diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html index 0180b72cee9..825d6951afb 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html +++ b/projects/igniteui-angular/src/lib/grids/grid/grid-row.component.html @@ -25,8 +25,8 @@ @if (!grid.hasColumnLayouts) { - @if (pinnedColumns.length > 0 && grid.isPinningToStart) { - @for (col of pinnedColumns | igxNotGrouped; track trackPinnedColumn(col)) { + @if (pinnedStartColumns.length > 0) { + @for (col of pinnedStartColumns | igxNotGrouped; track trackPinnedColumn(col)) { @if (this.hasMergedCells) {
- @if (pinnedColumns.length > 0 && !grid.isPinningToStart) { - @for (col of pinnedColumns | igxNotGrouped; track trackPinnedColumn(col)) { + @if (pinnedEndColumns.length > 0) { + @for (col of pinnedEndColumns | igxNotGrouped; track trackPinnedColumn(col)) { @if (this.hasMergedCells) {
0 && grid.isPinningToStart) { - + @if (pinnedStartColumns.length > 0) { + }
+ @if (pinnedEndColumns.length > 0) { + } } @@ -102,7 +102,7 @@
0) { + class="igx-grid__scroll-on-drag-pinned" [style.left.px]="pinnedStartWidth"> } @@ -196,12 +197,12 @@
-
+
-
+
} - @if (pinnedColumns.length > 0 && grid.isPinningToStart) { - + @if (pinnedStartColumns.length > 0) { + } @if (this.hasMergedCells) { @@ -37,8 +37,8 @@ } - @if (pinnedColumns.length > 0 && !grid.isPinningToStart) { - + @if (pinnedEndColumns.length > 0) { + } @@ -57,8 +57,8 @@
- - @for (col of pinnedColumns | igxNotGrouped; track trackPinnedColumn(col)) { + + @for (col of columns | igxNotGrouped; track trackPinnedColumn(col)) { @if (this.hasMergedCells) {
0) { + class="igx-grid__scroll-on-drag-pinned" [style.left.px]="pinnedStartWidth"> } @if (mergedDataInView && mergedDataInView.length > 0) { @@ -155,12 +156,12 @@
-
+
-
+
+ + +
Custom Close Button
+
+ + +
Second Custom Close Button
+
`, imports: [IgxTooltipDirective, IgxTooltipTargetDirective] }) @@ -62,6 +70,8 @@ export class IgxTooltipMultipleTargetsComponent { @ViewChild('targetOne', { read: IgxTooltipTargetDirective, static: true }) public targetOne: IgxTooltipTargetDirective; @ViewChild('targetTwo', { read: IgxTooltipTargetDirective, static: true }) public targetTwo: IgxTooltipTargetDirective; @ViewChild(IgxTooltipDirective, { static: true }) public tooltip: IgxTooltipDirective; + @ViewChild('customClose', { static: true }) public customCloseTemplate: TemplateRef; + @ViewChild('secondCustomClose', { static: true }) public secondCustomCloseTemplate: TemplateRef; } @Component({ @@ -120,3 +130,22 @@ export class IgxTooltipWithToggleActionComponent { @ViewChild(IgxTooltipTargetDirective, { static: true }) public tooltipTarget: IgxTooltipTargetDirective; @ViewChild(IgxToggleDirective, { static: true }) public toggleDir: IgxToggleDirective; } + +@Component({ + template: ` + + + + + + +
Test
+ `, + imports: [IgxTooltipDirective, IgxTooltipTargetDirective] +}) +export class IgxTooltipWithCloseButtonComponent { + @ViewChild(IgxTooltipDirective, { static: true }) public tooltip: IgxTooltipDirective; + @ViewChild(IgxTooltipTargetDirective, { static: true }) public tooltipTarget: IgxTooltipTargetDirective; +} diff --git a/src/app/date-range/date-range.sample.html b/src/app/date-range/date-range.sample.html index fceb8e868b9..1a11cd4447d 100644 --- a/src/app/date-range/date-range.sample.html +++ b/src/app/date-range/date-range.sample.html @@ -3,14 +3,15 @@
Without forms
-

Default range picker

- + +

Default range picker with predefined and custom ranges

+

Drop down single input with custom suffix

diff --git a/src/app/date-range/date-range.sample.ts b/src/app/date-range/date-range.sample.ts index 52a26f19be6..75904f6be6a 100644 --- a/src/app/date-range/date-range.sample.ts +++ b/src/app/date-range/date-range.sample.ts @@ -1,7 +1,8 @@ import { Component, ViewChild } from '@angular/core'; import { JsonPipe } from '@angular/common'; import { UntypedFormGroup, UntypedFormBuilder, Validators, UntypedFormControl, ValidatorFn, AbstractControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { DateRange, IgxButtonDirective, IgxDateRangeEndComponent, IgxDateRangePickerComponent, IgxDateRangeStartComponent, IgxDateTimeEditorDirective, IgxIconComponent, IgxInputDirective, IgxLabelDirective, IgxPickerToggleComponent, IgxPrefixDirective, IgxRadioComponent, IgxRippleDirective, IgxSuffixDirective, IGX_INPUT_GROUP_TYPE, IChangeCheckboxEventArgs } from 'igniteui-angular'; +import { DateRange, IgxButtonDirective, IgxDateRangeEndComponent, IgxDateRangePickerComponent, IgxDateRangeStartComponent, IgxDateTimeEditorDirective, IgxIconComponent, IgxInputDirective, IgxLabelDirective, IgxPickerToggleComponent, IgxPrefixDirective, IgxRadioComponent, IgxRippleDirective, IgxSuffixDirective, IGX_INPUT_GROUP_TYPE, IChangeCheckboxEventArgs, CustomDateRange } from 'igniteui-angular'; +import { CalendarDay } from 'igniteui-angular/src/lib/calendar/common/model'; @Component({ @@ -31,6 +32,31 @@ export class DateRangeSampleComponent { public range6: DateRange = { start: this.range6Start, end: this.range6End }; public minDate: Date = new Date(); public maxDate: Date = new Date(new Date().setDate(new Date().getDate() + 25)); + public today = CalendarDay.today; + public previousThreeMonthsStart = new Date( + this.today.native.getFullYear(), + this.today.native.getMonth() - 3, + 1 + ); + public previousThreeMonthsEnd = new Date( + this.today.native.getFullYear(), + this.today.native.getMonth(), + 0 + ); + public nextThreeMonthsStart = new Date( + this.today.native.getFullYear(), + this.today.native.getMonth() + 1, + 1 + ); + public nextThreeMonthsEnd = new Date( + this.today.native.getFullYear(), + this.today.native.getMonth() + 4, + 0 + ); + public customRanges : CustomDateRange[] = [ + { label:"Next three months", dateRange: {start: this.nextThreeMonthsStart, end: this.nextThreeMonthsEnd} }, + { label:"Previous three months", dateRange: {start: this.previousThreeMonthsStart, end: this.previousThreeMonthsEnd} }, + ] public reactiveForm: UntypedFormGroup; diff --git a/src/app/grid-column-moving/grid-column-moving.sample.html b/src/app/grid-column-moving/grid-column-moving.sample.html index f606d724d47..8db969a0d42 100644 --- a/src/app/grid-column-moving/grid-column-moving.sample.html +++ b/src/app/grid-column-moving/grid-column-moving.sample.html @@ -33,7 +33,7 @@

Sample 1

+ [dataType]="c.type" [pinned]='c.pinned' [hidden]='c.hidden' [pinningPosition]="c.pinningPosition"> @if (show) {
{{ cell.value}}
diff --git a/src/app/grid-column-moving/grid-column-moving.sample.ts b/src/app/grid-column-moving/grid-column-moving.sample.ts index 9465501ee7b..2771f54c5ee 100644 --- a/src/app/grid-column-moving/grid-column-moving.sample.ts +++ b/src/app/grid-column-moving/grid-column-moving.sample.ts @@ -3,7 +3,7 @@ import { FormsModule } from '@angular/forms'; import { SAMPLE_DATA } from '../shared/sample-data'; -import { IgxButtonDirective, IgxButtonGroupComponent, IgxCellHeaderTemplateDirective, IgxCellTemplateDirective, IgxCollapsibleIndicatorTemplateDirective, IgxColumnComponent, IgxColumnGroupComponent, IgxGridComponent, IgxGridToolbarActionsComponent, IgxGridToolbarAdvancedFilteringComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent, IgxIconComponent, IgxInputDirective, IgxInputGroupComponent, IgxLabelDirective, IgxPaginatorComponent } from 'igniteui-angular'; +import { ColumnPinningPosition, IgxButtonDirective, IgxButtonGroupComponent, IgxCellHeaderTemplateDirective, IgxCellTemplateDirective, IgxCollapsibleIndicatorTemplateDirective, IgxColumnComponent, IgxColumnGroupComponent, IgxGridComponent, IgxGridToolbarActionsComponent, IgxGridToolbarAdvancedFilteringComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent, IgxIconComponent, IgxInputDirective, IgxInputGroupComponent, IgxLabelDirective, IgxPaginatorComponent } from 'igniteui-angular'; @Component({ providers: [], @@ -76,11 +76,11 @@ export class GridColumnMovingSampleComponent implements OnInit { { field: 'ID', width: 150, resizable: true, sortable: false, filterable: true, groupable: true, summary: true, type: 'string', pinned: false, hidden: true }, { field: 'CompanyName', width: 150, resizable: true, sortable: true, filterable: true, groupable: true, - summary: true, type: 'string'}, + summary: true, type: 'string', pinned: true , pinningPosition: ColumnPinningPosition.End}, { field: 'ContactName', width: 150, resizable: true, sortable: true, filterable: true, groupable: true, summary: true, type: 'string' }, { field: 'ContactTitle', width: 150, resizable: true, sortable: true, filterable: true, groupable: true, - summary: true, type: 'string' }, + summary: true, type: 'string', pinningPosition: ColumnPinningPosition.End }, { field: 'Address', width: 150, resizable: true, sortable: true, filterable: true, groupable: true, summary: true, type: 'string' }, { field: 'City', width: 150, resizable: true, sortable: false, filterable: false, groupable: true, diff --git a/src/app/grid-column-pinning/grid-column-pinning.sample.html b/src/app/grid-column-pinning/grid-column-pinning.sample.html index 7ec8ecc9104..b1df3155ba0 100644 --- a/src/app/grid-column-pinning/grid-column-pinning.sample.html +++ b/src/app/grid-column-pinning/grid-column-pinning.sample.html @@ -21,7 +21,7 @@ } @for (c of columns; track c) { + [hidden]='c.hidden' [pinningPosition]='c.pinningPosition'> } diff --git a/src/app/grid-column-pinning/grid-column-pinning.sample.ts b/src/app/grid-column-pinning/grid-column-pinning.sample.ts index 78cb96eda90..e34efc4adf9 100644 --- a/src/app/grid-column-pinning/grid-column-pinning.sample.ts +++ b/src/app/grid-column-pinning/grid-column-pinning.sample.ts @@ -54,8 +54,8 @@ export class GridColumnPinningSampleComponent implements OnInit { this.columns = [ { field: 'ID', width: '200px', hidden: false }, { field: 'CompanyName', width: '200px' }, - { field: 'ContactName', width: '200px', pinned: true }, - { field: 'ContactTitle', width: '300px', pinned: true }, + { field: 'ContactName', width: '200px', pinned: true, pinningPosition: ColumnPinningPosition.Start }, + { field: 'ContactTitle', width: '300px', pinned: true, pinningPosition: ColumnPinningPosition.End }, { field: 'Address', width: '250px' }, { field: 'City', width: '200px' }, { field: 'Region', width: '300px' }, diff --git a/src/app/grid-multi-row-layout/grid-mrl.sample.html b/src/app/grid-multi-row-layout/grid-mrl.sample.html index 13968ed8760..c4f151383fe 100644 --- a/src/app/grid-multi-row-layout/grid-mrl.sample.html +++ b/src/app/grid-multi-row-layout/grid-mrl.sample.html @@ -3,7 +3,7 @@
+ primaryKey="PostalCode" [width]="'100%'" [height]="'500px'" [moving]="true"> @@ -13,17 +13,17 @@ - + - + - + diff --git a/src/app/grid-multi-row-layout/grid-mrl.sample.ts b/src/app/grid-multi-row-layout/grid-mrl.sample.ts index cffe9e55dd2..1f3821ace4c 100644 --- a/src/app/grid-multi-row-layout/grid-mrl.sample.ts +++ b/src/app/grid-multi-row-layout/grid-mrl.sample.ts @@ -1,5 +1,5 @@ import { Component, HostBinding, ViewChild } from '@angular/core'; -import { GridSelectionMode, IgxActionStripComponent, IgxButtonDirective, IgxButtonGroupComponent, IgxColumnComponent, IgxColumnLayoutComponent, IgxGridComponent, IgxGridEditingActionsComponent, IgxGridPinningActionsComponent, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent } from 'igniteui-angular'; +import { ColumnPinningPosition, GridSelectionMode, IgxActionStripComponent, IgxButtonDirective, IgxButtonGroupComponent, IgxColumnComponent, IgxColumnLayoutComponent, IgxGridComponent, IgxGridEditingActionsComponent, IgxGridPinningActionsComponent, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent } from 'igniteui-angular'; @Component({ @@ -15,6 +15,8 @@ export class GridMRLSampleComponent { @ViewChild(IgxGridComponent, { read: IgxGridComponent, static: true }) private grid: IgxGridComponent; + public pinPos = ColumnPinningPosition.End; + public width = null; public cols: Array = [ { field: 'ID', rowStart: 1, colStart: 1}, diff --git a/src/app/hierarchical-grid-add-row/hierarchical-grid-add-row.sample.html b/src/app/hierarchical-grid-add-row/hierarchical-grid-add-row.sample.html index c5544724b72..8a74b80b056 100644 --- a/src/app/hierarchical-grid-add-row/hierarchical-grid-add-row.sample.html +++ b/src/app/hierarchical-grid-add-row/hierarchical-grid-add-row.sample.html @@ -1,15 +1,22 @@ + [rowEditable]="true" + (columnInit)="columnInit($event)"> + + + + + + - - diff --git a/src/app/hierarchical-grid-add-row/hierarchical-grid-add-row.sample.ts b/src/app/hierarchical-grid-add-row/hierarchical-grid-add-row.sample.ts index b410f8a24ed..b4d4f7e6f6b 100644 --- a/src/app/hierarchical-grid-add-row/hierarchical-grid-add-row.sample.ts +++ b/src/app/hierarchical-grid-add-row/hierarchical-grid-add-row.sample.ts @@ -1,10 +1,11 @@ import { Component, ChangeDetectorRef, AfterViewInit } from '@angular/core'; -import { IgxActionStripComponent, IgxGridEditingActionsComponent, IgxGridPinningActionsComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent } from 'igniteui-angular'; +import { ColumnPinningPosition, IColumnsAutoGeneratedEventArgs, IgxActionStripComponent, IgxColumnComponent, IgxGridEditingActionsComponent, IgxGridPinningActionsComponent, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent, IgxGridToolbarPinningComponent, IgxHierarchicalGridComponent, IgxRowIslandComponent } from 'igniteui-angular'; @Component({ selector: 'app-hierarchical-grid-add-row-sample', templateUrl: 'hierarchical-grid-add-row.sample.html', - imports: [IgxHierarchicalGridComponent, IgxActionStripComponent, IgxGridPinningActionsComponent, IgxGridEditingActionsComponent, IgxRowIslandComponent] + imports: [IgxHierarchicalGridComponent, IgxGridToolbarComponent, IgxGridToolbarActionsComponent, + IgxGridToolbarPinningComponent, IgxGridToolbarHidingComponent, IgxActionStripComponent, IgxGridPinningActionsComponent, IgxGridEditingActionsComponent, IgxRowIslandComponent] }) export class HierarchicalGridAddRowSampleComponent implements AfterViewInit { public localData = []; @@ -23,6 +24,18 @@ export class HierarchicalGridAddRowSampleComponent implements AfterViewInit { this.cdr.detectChanges(); } + public columnInit(e: IgxColumnComponent) { + if(e.field === 'ID') { + e.pinningPosition = ColumnPinningPosition.End; + e.pinned = true; + } + + if(e.field === 'ProductName') { + e.pinningPosition = ColumnPinningPosition.Start; + e.pinned = true; + } + } + public generateDataUneven(count: number, level: number, parendID: string = null) { const prods = []; const currLevel = level; diff --git a/src/app/tooltip/tooltip.sample.css b/src/app/tooltip/tooltip.sample.css index 542f2a717ae..3e6ceca7add 100644 --- a/src/app/tooltip/tooltip.sample.css +++ b/src/app/tooltip/tooltip.sample.css @@ -78,3 +78,11 @@ display: flex; flex-direction: column; } + +.custom-container { + display: flex; + flex-flow: column; + justify-content: space-evenly; + align-items: center; + height: 300px; +} diff --git a/src/app/tooltip/tooltip.sample.html b/src/app/tooltip/tooltip.sample.html index 11e0e3b4ee4..a21360e2634 100644 --- a/src/app/tooltip/tooltip.sample.html +++ b/src/app/tooltip/tooltip.sample.html @@ -1,28 +1,35 @@

Simple tooltip

- + + + +
Her name is Toola Tipa -
+
+

Tooltip input

- + + info -
+
@@ -103,5 +110,5 @@

Grid with tooltips

- +
diff --git a/src/app/tooltip/tooltip.sample.ts b/src/app/tooltip/tooltip.sample.ts index bf0c637c92c..87b45beeaa2 100644 --- a/src/app/tooltip/tooltip.sample.ts +++ b/src/app/tooltip/tooltip.sample.ts @@ -15,7 +15,8 @@ import { IgxSliderComponent, IgxSwitchComponent, IgxTooltipDirective, - IgxTooltipTargetDirective, OverlaySettings + IgxTooltipTargetDirective, + OverlaySettings, } from 'igniteui-angular'; @Component({ @@ -109,10 +110,4 @@ export class TooltipSampleComponent implements OnInit { public hideTooltip() { this.tooltipTarget.hideTooltip(); } - - public showing() { - } - - public hiding() { - } } diff --git a/src/app/tree-grid/tree-grid.sample.html b/src/app/tree-grid/tree-grid.sample.html index 364a0a14454..e5c0fbda7c9 100644 --- a/src/app/tree-grid/tree-grid.sample.html +++ b/src/app/tree-grid/tree-grid.sample.html @@ -3,7 +3,7 @@
- diff --git a/src/app/tree-grid/tree-grid.sample.ts b/src/app/tree-grid/tree-grid.sample.ts index 5fe222208f8..44989f7d246 100644 --- a/src/app/tree-grid/tree-grid.sample.ts +++ b/src/app/tree-grid/tree-grid.sample.ts @@ -3,7 +3,7 @@ import { FormsModule } from '@angular/forms'; import { HIERARCHICAL_SAMPLE_DATA } from '../shared/sample-data'; import { GridSearchBoxComponent } from '../grid-search-box/grid-search-box.component'; -import { IgxButtonGroupComponent, IgxTreeGridComponent, IgxGridToolbarComponent, IgxGridToolbarActionsComponent, IgxGridToolbarPinningComponent, IgxGridToolbarHidingComponent, IgxGridToolbarAdvancedFilteringComponent, IgxGridToolbarExporterComponent, IgxExcelTextDirective, IgxCSVTextDirective, IgxPaginatorComponent, IgxSwitchComponent, IgxButtonDirective, IgxToggleActionDirective, IgxDropDownItemNavigationDirective, IgxDropDownComponent, IgxDropDownItemComponent, GridSelectionMode, TreeGridFilteringStrategy, IgxExcelExporterService, IgxCsvExporterService, IgxExcelExporterOptions, IgxCsvExporterOptions, CsvFileTypes } from 'igniteui-angular'; +import { IgxButtonGroupComponent, IgxTreeGridComponent, IgxGridToolbarComponent, IgxGridToolbarActionsComponent, IgxGridToolbarPinningComponent, IgxGridToolbarHidingComponent, IgxGridToolbarAdvancedFilteringComponent, IgxGridToolbarExporterComponent, IgxExcelTextDirective, IgxCSVTextDirective, IgxPaginatorComponent, IgxSwitchComponent, IgxButtonDirective, IgxToggleActionDirective, IgxDropDownItemNavigationDirective, IgxDropDownComponent, IgxDropDownItemComponent, GridSelectionMode, TreeGridFilteringStrategy, IgxExcelExporterService, IgxCsvExporterService, IgxExcelExporterOptions, IgxCsvExporterOptions, CsvFileTypes, IgxColumnComponent, ColumnPinningPosition } from 'igniteui-angular'; @Component({ @@ -36,6 +36,18 @@ export class TreeGridSampleComponent implements OnInit { } + public columnInit(e: IgxColumnComponent) { + if(e.field === 'ID') { + e.pinningPosition = ColumnPinningPosition.End; + e.pinned = true; + } + + if(e.field === 'CompanyName') { + e.pinningPosition = ColumnPinningPosition.Start; + e.pinned = true; + } + } + public ngOnInit(): void { this.selectionMode = GridSelectionMode.multiple; this.sizes = [ From 4d1c4775dfba4cbfcfc433397381923b90ca9a6c Mon Sep 17 00:00:00 2001 From: Stamen Stoychev Date: Thu, 28 Aug 2025 16:21:51 +0300 Subject: [PATCH 96/99] chore(*): applying modified merge for pinning --- projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts index 6b1b11cab17..15db0b269c1 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts @@ -9,7 +9,7 @@ import { ColumnType, GridType, IGX_GRID_BASE } from '../common/grid.interface'; import { FilterUtil, IFilteringStrategy } from '../../data-operations/filtering-strategy'; import { ISortingExpression } from '../../data-operations/sorting-strategy'; import { IGridSortingStrategy, IGridGroupingStrategy } from '../common/strategy'; -import { GridCellMergeMode } from '../common/enums'; +import { GridCellMergeMode, RowPinningPosition } from '../common/enums'; import { IGridMergeStrategy } from '../../data-operations/merge-strategy'; /** @@ -90,7 +90,7 @@ export class IgxGridCellMergePipe implements PipeTransform { if (colsToMerge.length === 0) { return collection; } - if (!pinned && this.grid.isPinningToStart) { + if (!pinned && this.grid.pinning.rows !== RowPinningPosition.Bottom) { activeRowIndexes = activeRowIndexes.map(x => x - this.grid.pinnedRecordsCount); } const result = DataUtil.merge(cloneArray(collection), colsToMerge, mergeStrategy, activeRowIndexes, this.grid); From 4c7495e1adeca09ca1279baff4ed319c1d31ea62 Mon Sep 17 00:00:00 2001 From: Stamen Stoychev Date: Thu, 28 Aug 2025 16:27:46 +0300 Subject: [PATCH 97/99] fix(merging): applying a performance improv for merging pipe --- projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts index 15db0b269c1..dedd1a26cc3 100644 --- a/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/grid/grid.pipes.ts @@ -90,7 +90,7 @@ export class IgxGridCellMergePipe implements PipeTransform { if (colsToMerge.length === 0) { return collection; } - if (!pinned && this.grid.pinning.rows !== RowPinningPosition.Bottom) { + if (this.grid.hasPinnedRecords && !pinned && this.grid.pinning.rows !== RowPinningPosition.Bottom) { activeRowIndexes = activeRowIndexes.map(x => x - this.grid.pinnedRecordsCount); } const result = DataUtil.merge(cloneArray(collection), colsToMerge, mergeStrategy, activeRowIndexes, this.grid); From 514ad9606aa372c512afce44c2cac793d8240b5d Mon Sep 17 00:00:00 2001 From: Stamen Stoychev Date: Thu, 28 Aug 2025 16:46:04 +0300 Subject: [PATCH 98/99] chore(docs): resolving broken changelog merge --- CHANGELOG.md | 44 ++------------------------------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d76ca06736..779163bf463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,35 +50,11 @@ All notable changes for each version of this project will be documented in this If property `pinningPosition` is not set on a column, the column will default to the position specified on the grid's `pinning` options for `columns`. -- `IgxDateRangePicker` - - Added cancel button to the dialog, allowing the user to cancel the selection. - - - `IgxCarousel` - Added `select` method overload accepting index. ```ts this.carousel.select(2, Direction.NEXT); ``` -- `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid` - - Added ability to pin individual columns to a specific side (start or end of the grid), so that you can now have pinning from both sides. This can be done either declaratively by setting the `pinningPosition` property on the column: - - ```html - - - ``` - - ```ts - public pinningPosition = ColumnPinningPosition.End; - ``` - - Or with the API, via optional parameter: - - ```ts - grid.pinColumn('Col1', 0, ColumnPinningPosition.End); - grid.pinColumn('Col2', 0, ColumnPinningPosition.Start); - ``` - - If property `pinningPosition` is not set on a column, the column will default to the position specified on the grid's `pinning` options for `columns`. - `IgxDateRangePicker` - Now has a complete set of properties to customize the calendar: @@ -89,6 +65,8 @@ All notable changes for each version of this project will be documented in this - `disabledDates` - `specialDates` + - Added cancel button to the dialog, allowing the user to cancel the selection. + - As well as the following templates, available to customize the contents of the calendar header in `dialog` mode: - `igxCalendarHeader` - `igxCalendarHeaderTitle` @@ -125,28 +103,10 @@ All notable changes for each version of this project will be documented in this - `IgxTooltip` - The tooltip now remains open while interacting with it. -- `IgxTooltipTarget` - - Introduced several new properties to enhance customization of tooltip content and behavior. Those include `positionSettings`, `hasArrow`, `sticky`, `closeButtonTemplate`. For detailed usage and examples, please refer to the Tooltip [README](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/directives/tooltip/README.md). - -- `IgxDateRangePicker` - - Added new properties: - - `usePredefinedRanges` - Whether to render built-in predefined ranges - - `customRanges` - Allows the user to provide custom ranges rendered as chips - - `resourceStrings` - Allows the user to provide set of resource strings - -- `IgxPredefinedRangesAreaComponent` - - Added new component for rendering the predefined or custom ranges inside the calendar of the `IgxDateRangePicker` - -- `IgxOverlay` - - Position Settings now accept a new optional `offset` input property of type `number`. Used to set the offset of the element from the target in pixels. - -- `IgxTooltip` - - The tooltip now remains open while interacting with it. - `IgxTooltipTarget` - Introduced several new properties to enhance customization of tooltip content and behavior. Those include `positionSettings`, `hasArrow`, `sticky`, `closeButtonTemplate`. For detailed usage and examples, please refer to the Tooltip [README](https://github.com/IgniteUI/igniteui-angular/blob/master/projects/igniteui-angular/src/lib/directives/tooltip/README.md). - ### General - `IgxDropDown` now exposes a `role` input property, allowing users to customize the role attribute based on the use case. The default is `listbox`. From 7e15c92e7d40626791dea0027adecf0838c4b9b5 Mon Sep 17 00:00:00 2001 From: Stamen Stoychev Date: Thu, 28 Aug 2025 17:32:06 +0300 Subject: [PATCH 99/99] chore(docs): removing duplicated changelog entry. --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 779163bf463..2db63f2bda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,8 +65,6 @@ All notable changes for each version of this project will be documented in this - `disabledDates` - `specialDates` - - Added cancel button to the dialog, allowing the user to cancel the selection. - - As well as the following templates, available to customize the contents of the calendar header in `dialog` mode: - `igxCalendarHeader` - `igxCalendarHeaderTitle`