diff --git a/CHANGELOG.md b/CHANGELOG.md index 879553e5458..2268ee0eef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes for each version of this project will be documented in this - `displayValuePipe` input property is provided that allows developers to additionally transform the value on blur; - `focusedValuePipe` input property is provided that allows developers to additionally transform the value on focus; - `IgxTreeGrid`: + - Batch editing - an injectable transaction provider accumulates pending changes, which are not directly applied to the grid's data source. Those can later be inspected, manipulated and submitted at once. Changes are collected for individual cells or rows, depending on editing mode, and accumulated per data row/record. - You can now export the tree grid both to CSV and Excel. - The hierarchy and the records' expanded states would be reflected in the exported Excel worksheet. diff --git a/projects/igniteui-angular/src/lib/core/utils.ts b/projects/igniteui-angular/src/lib/core/utils.ts index c6209cde679..cb22db31cdb 100644 --- a/projects/igniteui-angular/src/lib/core/utils.ts +++ b/projects/igniteui-angular/src/lib/core/utils.ts @@ -24,13 +24,11 @@ export function cloneHierarchicalArray(array: any[], childDataKey: any): any[] { } for (const item of array) { + const clonedItem = cloneValue(item); if (Array.isArray(item[childDataKey])) { - const clonedItem = cloneValue(item); clonedItem[childDataKey] = cloneHierarchicalArray(clonedItem[childDataKey], childDataKey); - result.push(clonedItem); - } else { - result.push(item); } + result.push(clonedItem); } return result; } diff --git a/projects/igniteui-angular/src/lib/data-operations/data-util.spec.ts b/projects/igniteui-angular/src/lib/data-operations/data-util.spec.ts index 02d32089e2b..4e8559f0273 100644 --- a/projects/igniteui-angular/src/lib/data-operations/data-util.spec.ts +++ b/projects/igniteui-angular/src/lib/data-operations/data-util.spec.ts @@ -14,11 +14,15 @@ import { FilteringStrategy } from './filtering-strategy'; import { IFilteringExpressionsTree, FilteringExpressionsTree } from './filtering-expressions-tree'; import { IFilteringState } from './filtering-state.interface'; import { FilteringLogic } from './filtering-expression.interface'; -import { IgxNumberFilteringOperand, +import { + IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxDateFilteringOperand, - IgxBooleanFilteringOperand } from './filtering-condition'; + IgxBooleanFilteringOperand +} from './filtering-condition'; import { IPagingState, PagingError } from './paging-state.interface'; +import { SampleTestData } from '../test-utils/sample-test-data.spec'; +import { Transaction, TransactionType, HierarchicalTransaction } from '../services'; /* Test sorting */ function testSort() { @@ -266,6 +270,7 @@ function testGroupBy() { }); } /* //Test sorting */ + /* Test filtering */ class CustomFilteringStrategy extends FilteringStrategy { public filter(data: T[], expressionsTree: IFilteringExpressionsTree): T[] { @@ -387,6 +392,7 @@ function testFilter() { }); } /* //Test filtering */ + /* Test paging */ function testPage() { const dataGenerator: DataGenerator = new DataGenerator(); @@ -426,9 +432,144 @@ function testPage() { }); } /* //Test paging */ + +/* Test merging */ +function testMerging() { + describe('Test merging', () => { + it('Should merge add transactions correctly', () => { + const data = SampleTestData.personIDNameData(); + const addRow4 = { ID: 4, Name: 'Peter' }; + const addRow5 = { ID: 5, Name: 'Mimi' }; + const addRow6 = { ID: 6, Name: 'Pedro' }; + const transactions: Transaction[] = [ + { id: addRow4.ID, newValue: addRow4, type: TransactionType.ADD }, + { id: addRow5.ID, newValue: addRow5, type: TransactionType.ADD }, + { id: addRow6.ID, newValue: addRow6, type: TransactionType.ADD }, + ]; + + DataUtil.mergeTransactions(data, transactions, 'ID'); + expect(data.length).toBe(6); + expect(data[3]).toBe(addRow4); + expect(data[4]).toBe(addRow5); + expect(data[5]).toBe(addRow6); + }); + + it('Should merge update transactions correctly', () => { + const data = SampleTestData.personIDNameData(); + const transactions: Transaction[] = [ + { id: 1, newValue: { Name: 'Peter' }, type: TransactionType.UPDATE }, + { id: 3, newValue: { Name: 'Mimi' }, type: TransactionType.UPDATE }, + ]; + + DataUtil.mergeTransactions(data, transactions, 'ID'); + expect(data.length).toBe(3); + expect(data[0].Name).toBe('Peter'); + expect(data[2].Name).toBe('Mimi'); + }); + + it('Should merge delete transactions correctly', () => { + const data = SampleTestData.personIDNameData(); + const secondRow = data[1]; + const transactions: Transaction[] = [ + { id: 1, newValue: null, type: TransactionType.DELETE }, + { id: 3, newValue: null, type: TransactionType.DELETE }, + ]; + + DataUtil.mergeTransactions(data, transactions, 'ID', true); + expect(data.length).toBe(1); + expect(data[0]).toEqual(secondRow); + }); + + it('Should merge add hierarchical transactions correctly', () => { + const data = SampleTestData.employeeSmallTreeData(); + const addRootRow = { ID: 1000, Name: 'Pit Peter', HireDate: new Date(2008, 3, 20), Age: 55 }; + const addChildRow1 = { ID: 1001, Name: 'Marry May', HireDate: new Date(2018, 4, 1), Age: 102 }; + const addChildRow2 = { ID: 1002, Name: 'April Alison', HireDate: new Date(2021, 5, 10), Age: 4 }; + const transactions: HierarchicalTransaction[] = [ + { id: addRootRow.ID, newValue: addRootRow, type: TransactionType.ADD, path: [] }, + { id: addChildRow1.ID, newValue: addChildRow1, type: TransactionType.ADD, path: [data[0].ID, data[0].Employees[1].ID] }, + { id: addChildRow2.ID, newValue: addChildRow2, type: TransactionType.ADD, path: [addRootRow.ID] }, + ]; + + DataUtil.mergeHierarchicalTransactions(data, transactions, 'Employees', 'ID', false); + expect(data.length).toBe(4); + + expect(data[3].Age).toBe(addRootRow.Age); + expect(data[3].Employees.length).toBe(1); + expect(data[3].HireDate).toBe(addRootRow.HireDate); + expect(data[3].ID).toBe(addRootRow.ID); + expect(data[3].Name).toBe(addRootRow.Name); + + expect((data[0].Employees[1] as any).Employees.length).toBe(1); + expect((data[0].Employees[1] as any).Employees[0]).toBe(addChildRow1); + + expect(data[3].Employees[0]).toBe(addChildRow2); + }); + + it('Should merge update hierarchical transactions correctly', () => { + const data = SampleTestData.employeeSmallTreeData(); + const updateRootRow = { Name: 'May Peter', Age: 13 }; + const updateChildRow1 = { HireDate: new Date(2100, 1, 12), Age: 1300 }; + const updateChildRow2 = { HireDate: new Date(2100, 1, 12), Name: 'Santa Claus' }; + + const transactions: HierarchicalTransaction[] = [ + { + id: data[1].ID, + newValue: updateRootRow, + type: TransactionType.UPDATE, + path: [] + }, + { + id: data[2].Employees[0].ID, + newValue: updateChildRow1, + type: TransactionType.UPDATE, + path: [data[2].ID] + }, + { + id: (data[0].Employees[2] as any).Employees[0].ID, + newValue: updateChildRow2, + type: TransactionType.UPDATE, + path: [data[0].ID, data[0].Employees[2].ID] + }, + ]; + + DataUtil.mergeHierarchicalTransactions(data, transactions, 'Employees', 'ID', false); + expect(data[1].Name).toBe(updateRootRow.Name); + expect(data[1].Age).toBe(updateRootRow.Age); + + expect(data[2].Employees[0].HireDate.getTime()).toBe(updateChildRow1.HireDate.getTime()); + expect(data[2].Employees[0].Age).toBe(updateChildRow1.Age); + + expect((data[0].Employees[2] as any).Employees[0].Name).toBe(updateChildRow2.Name); + expect((data[0].Employees[2] as any).Employees[0].HireDate.getTime()).toBe(updateChildRow2.HireDate.getTime()); + }); + + it('Should merge delete hierarchical transactions correctly', () => { + const data = SampleTestData.employeeSmallTreeData(); + const transactions: HierarchicalTransaction[] = [ + // root row with no children + { id: data[1].ID, newValue: null, type: TransactionType.DELETE, path: [] }, + // root row with children + { id: data[2].ID, newValue: null, type: TransactionType.DELETE, path: [] }, + // child row with no children + { id: data[0].Employees[0].ID, newValue: null, type: TransactionType.DELETE, path: [data[0].ID] }, + // child row with children + { id: data[0].Employees[2].ID, newValue: null, type: TransactionType.DELETE, path: [data[0].ID] } + ]; + + DataUtil.mergeHierarchicalTransactions(data, transactions, 'Employees', 'ID', true); + + expect(data.length).toBe(1); + expect(data[0].Employees.length).toBe(1); + }); + }); +} +/* //Test merging */ + describe('DataUtil', () => { testSort(); testGroupBy(); testFilter(); testPage(); + testMerging(); }); 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 8d987bb2aac..05659c75cee 100644 --- a/projects/igniteui-angular/src/lib/data-operations/data-util.ts +++ b/projects/igniteui-angular/src/lib/data-operations/data-util.ts @@ -29,13 +29,13 @@ export enum DataType { * @hidden */ export class DataUtil { - public static sort(data: T[], expressions: ISortingExpression [], sorting: IgxSorting = new IgxSorting()): T[] { + public static sort(data: T[], expressions: ISortingExpression[], sorting: IgxSorting = new IgxSorting()): T[] { return sorting.sort(data, expressions); } public static treeGridSort(hierarchicalData: ITreeGridRecord[], - expressions: ISortingExpression [], - parent?: ITreeGridRecord): ITreeGridRecord[] { + expressions: ISortingExpression[], + parent?: ITreeGridRecord): ITreeGridRecord[] { let res: ITreeGridRecord[] = []; hierarchicalData.forEach((hr: ITreeGridRecord) => { const rec: ITreeGridRecord = DataUtil.cloneTreeGridRecord(hr); @@ -58,8 +58,7 @@ export class DataUtil { children: hierarchicalRecord.children, isFilteredOutParent: hierarchicalRecord.isFilteredOutParent, level: hierarchicalRecord.level, - expanded: hierarchicalRecord.expanded, - path: [...hierarchicalRecord.path] + expanded: hierarchicalRecord.expanded }; return rec; } @@ -189,60 +188,100 @@ export class DataUtil { * @param data Collection to merge * @param transactions Transactions to merge into data * @param primaryKey Primary key of the collection, if any + * @param deleteRows Should delete rows with DELETE transaction type from data + * @returns Provided data collections updated with all provided transactions */ - public static mergeTransactions(data: T[], transactions: Transaction[], primaryKey?: any): T[] { + public static mergeTransactions(data: T[], transactions: Transaction[], primaryKey?: any, deleteRows: boolean = false): T[] { data.forEach((item: any, index: number) => { const rowId = primaryKey ? item[primaryKey] : item; const transaction = transactions.find(t => t.id === rowId); - if (Array.isArray(item.children)) { - this.mergeTransactions(item.children, transactions, primaryKey); - } if (transaction && transaction.type === TransactionType.UPDATE) { data[index] = transaction.newValue; } }); + if (deleteRows) { + transactions + .filter(t => t.type === TransactionType.DELETE) + .forEach(t => { + const index = primaryKey ? data.findIndex(d => d[primaryKey] === t.id) : data.findIndex(d => d === t.id); + if (0 <= index && index < data.length) { + data.splice(index, 1); + } + }); + } + data.push(...transactions .filter(t => t.type === TransactionType.ADD) .map(t => t.newValue)); + return data; } - // TODO: optimize addition of added rows. Should not filter transaction in each recursion!!! - /** @experimental @hidden */ + /** + * Merges all changes from provided transactions into provided hierarchical data collection + * @param data Collection to merge + * @param transactions Transactions to merge into data + * @param childDataKey Data key of child collections + * @param primaryKey Primary key of the collection, if any + * @param deleteRows Should delete rows with DELETE transaction type from data + * @returns Provided data collections updated with all provided transactions + */ public static mergeHierarchicalTransactions( data: any[], transactions: HierarchicalTransaction[], childDataKey: any, primaryKey?: any, - parentKey?: any): any[] { - - for (let index = 0; index < data.length; index++) { - const dataItem = data[index]; - const rowId = primaryKey ? dataItem[primaryKey] : dataItem; - const updateTransaction = transactions.filter(t => t.type === TransactionType.UPDATE).find(t => t.id === rowId); - const addedTransactions = transactions.filter(t => t.type === TransactionType.ADD).filter(t => t.parentId === rowId); - if (updateTransaction || addedTransactions.length > 0) { - data[index] = mergeObjects(cloneValue(dataItem), updateTransaction && updateTransaction.newValue); - } - if (addedTransactions.length > 0) { - if (!data[index][childDataKey]) { - data[index][childDataKey] = []; - } - for (const addedTransaction of addedTransactions) { - data[index][childDataKey].push(addedTransaction.newValue); + deleteRows: boolean = false): any[] { + + for (const transaction of transactions) { + if (transaction.path) { + const parent = this.findParentFromPath(data, primaryKey, childDataKey, transaction.path); + let collection: any[] = parent ? parent[childDataKey] : data; + switch (transaction.type) { + case TransactionType.ADD: + // if there is no parent this is ADD row at root level + if (parent && !parent[childDataKey]) { + parent[childDataKey] = collection = []; + } + collection.push(transaction.newValue); + break; + case TransactionType.UPDATE: + const updateIndex = collection.findIndex(x => x[primaryKey] === transaction.id); + if (updateIndex !== -1) { + collection[updateIndex] = mergeObjects(cloneValue(collection[updateIndex]), transaction.newValue); + } + break; + case TransactionType.DELETE: + if (deleteRows) { + const deleteIndex = collection.findIndex(r => r[primaryKey] === transaction.id); + if (deleteIndex !== -1) { + collection.splice(deleteIndex, 1); + } + } + break; } - } - if (data[index][childDataKey]) { - data[index][childDataKey] = this.mergeHierarchicalTransactions( - data[index][childDataKey], - transactions, - childDataKey, - primaryKey, - rowId - ); + } else { + // if there is no path this is ADD row in root. Push the newValue to data + data.push(transaction.newValue); } } return data; } + + private static findParentFromPath(data: any[], primaryKey: any, childDataKey: any, path: any[]): any { + let collection: any[] = data; + let result: any; + + for (const id of path) { + result = collection && collection.find(x => x[primaryKey] === id); + if (!result) { + break; + } + + collection = result[childDataKey]; + } + + return result; + } } diff --git a/projects/igniteui-angular/src/lib/grids/api.service.ts b/projects/igniteui-angular/src/lib/grids/api.service.ts index c0c6fa98fde..610e9bf08c8 100644 --- a/projects/igniteui-angular/src/lib/grids/api.service.ts +++ b/projects/igniteui-angular/src/lib/grids/api.service.ts @@ -187,7 +187,7 @@ export class GridBaseAPIService { if (!grid) { return -1; } - const data = this.get_all_data(id); + const data = this.get_all_data(id, grid.transactions.enabled); return grid.primaryKey ? data.findIndex(record => record[grid.primaryKey] === rowID) : data.indexOf(rowID); } @@ -276,7 +276,7 @@ export class GridBaseAPIService { rowData: any } { const grid = this.get(id); - const data = this.get_all_data(id); + const data = this.get_all_data(id, grid.transactions.enabled); const isRowSelected = grid.selection.is_item_selected(id, rowID); const editableCell = this.get_cell_inEditMode(id); const column = grid.columnList.toArray()[columnID]; @@ -313,9 +313,9 @@ export class GridBaseAPIService { } const args = { rowID, - oldValue: oldValue, - newValue: editValue, - cancel: false + oldValue: oldValue, + newValue: editValue, + cancel: false }; if (cellObj) { Object.assign(args, { @@ -337,7 +337,7 @@ export class GridBaseAPIService { rowData: any }): void { const grid = this.get(id); - const data = this.get_all_data(id); + // const data = this.get_all_data(id, grid.transactions.enabled); const currentGridEditState = gridEditState || this.create_grid_edit_args(id, rowID, columnID, editValue); const emittedArgs = currentGridEditState.args; const column = grid.columnList.toArray()[columnID]; @@ -357,32 +357,46 @@ export class GridBaseAPIService { // if edit (new) value is same as old value do nothing here if (emittedArgs.oldValue !== undefined && isEqual(emittedArgs.oldValue, emittedArgs.newValue)) { return; } - const transaction: Transaction = { - id: rowID, type: TransactionType.UPDATE, newValue: { [column.field]: emittedArgs.newValue } - }; - if (grid.transactions.enabled) { - grid.transactions.add(transaction, currentGridEditState.rowData); - } else { - const rowValue = this.get_all_data(id)[rowIndex]; - mergeObjects(rowValue, {[column.field]: emittedArgs.newValue }); - } + const rowValue = this.get_all_data(id, grid.transactions.enabled)[rowIndex]; + this.updateData(grid, rowID, rowValue, currentGridEditState.rowData, { [column.field]: emittedArgs.newValue }); if (grid.primaryKey === column.field && currentGridEditState.isRowSelected) { grid.selection.deselect_item(id, rowID); grid.selection.select_item(id, emittedArgs.newValue); } - if (!grid.rowEditable || !grid.rowInEditMode || grid.rowInEditMode.rowID !== rowID) { + if (!grid.rowEditable || !grid.rowInEditMode || grid.rowInEditMode.rowID !== rowID || !grid.transactions.enabled) { (grid as any)._pipeTrigger++; } } } + /** + * Updates related row of provided grid's data source with provided new row value + * @param grid Grid to update data for + * @param rowID ID of the row to update + * @param rowValueInDataSource Initial value of the row as it is in data source + * @param rowCurrentValue Current value of the row as it is with applied previous transactions + * @param rowNewValue New value of the row + */ + protected updateData(grid, rowID, rowValueInDataSource: any, rowCurrentValue: any, rowNewValue: {[x: string]: any}) { + if (grid.transactions.enabled) { + const transaction: Transaction = { + id: rowID, + type: TransactionType.UPDATE, + newValue: rowNewValue + }; + grid.transactions.add(transaction, rowCurrentValue); + } else { + mergeObjects(rowValueInDataSource, rowNewValue); + } + } + public update_row(value: any, id: string, rowID: any, gridState?: { args: IGridEditEventArgs, isRowSelected: boolean, rowData: any }): void { const grid = this.get(id); - const data = this.get_all_data(id); + const data = this.get_all_data(id, grid.transactions.enabled); const currentGridState = gridState ? gridState : this.create_grid_edit_args(id, rowID, null, value); const emitArgs = currentGridState.args; const index = this.get_row_index_in_data(id, rowID); @@ -406,11 +420,7 @@ export class GridBaseAPIService { if (currentRowInEditMode) { grid.transactions.endPending(false); } - if (grid.transactions.enabled && emitArgs.newValue !== null) { - grid.transactions.add({id: rowID, newValue: emitArgs.newValue, type: TransactionType.UPDATE}, emitArgs.oldValue); - } else if (emitArgs.newValue !== null && emitArgs.newValue !== undefined) { - Object.assign(data[index], emitArgs.newValue); - } + this.updateData(grid, rowID, data[index], emitArgs.oldValue, emitArgs.newValue); if (currentGridState.isRowSelected) { grid.selection.deselect_item(id, rowID); const newRowID = (grid.primaryKey) ? emitArgs.newValue[grid.primaryKey] : emitArgs.newValue; @@ -594,9 +604,9 @@ export class GridBaseAPIService { return column.dataType === DataType.Number; } - public get_all_data(id: string, transactions?: boolean): any[] { + public get_all_data(id: string, includeTransactions = false): any[] { const grid = this.get(id); - const data = transactions ? grid.dataWithAddedInTransactionRows : grid.data; + const data = includeTransactions ? grid.dataWithAddedInTransactionRows : grid.data; return data ? data : []; } diff --git a/projects/igniteui-angular/src/lib/grids/grid-base.component.ts b/projects/igniteui-angular/src/lib/grids/grid-base.component.ts index f8197e8a6d9..42be5b53361 100644 --- a/projects/igniteui-angular/src/lib/grids/grid-base.component.ts +++ b/projects/igniteui-angular/src/lib/grids/grid-base.component.ts @@ -2928,10 +2928,7 @@ export abstract class IgxGridBaseComponent extends DisplayDensityBase implements } } - /** - * @hidden - * @param - */ + /** @hidden */ public deleteRowById(rowId: any) { let index: number; const data = this.gridAPI.get_all_data(this.id); @@ -4507,9 +4504,6 @@ export abstract class IgxGridBaseComponent extends DisplayDensityBase implements return rowChanges ? Object.keys(rowChanges).length : 0; } - protected writeToData(rowIndex: number, value: any) { - mergeObjects(this.data[rowIndex], value); - } /** * TODO: Refactor * @hidden @@ -4540,6 +4534,7 @@ export abstract class IgxGridBaseComponent extends DisplayDensityBase implements }); if (!commit) { this.onRowEditCancel.emit(emitArgs); + this.transactions.endPending(commit); } else { this.gridAPI.update_row(emitArgs.newValue, this.id, rowID, currentGridState); } @@ -4547,7 +4542,6 @@ export abstract class IgxGridBaseComponent extends DisplayDensityBase implements this.transactions.startPending(); return; } - this.transactions.endPending(commit); this.closeRowEditingOverlay(); } diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-api.service.ts b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-api.service.ts index 044aca3fc71..82bc73c8877 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-api.service.ts +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-api.service.ts @@ -5,6 +5,8 @@ import { ITreeGridRecord } from './tree-grid.interfaces'; import { IRowToggleEventArgs } from './tree-grid.interfaces'; import { IgxColumnComponent } from '../column.component'; import { first } from 'rxjs/operators'; +import { HierarchicalTransaction, TransactionType } from '../../services'; +import { mergeObjects } from '../../core/utils'; export class IgxTreeGridAPIService extends GridBaseAPIService { public get_all_data(id: string, transactions?: boolean): any[] { @@ -124,6 +126,34 @@ export class IgxTreeGridAPIService extends GridBaseAPIService { configureTestSuite(); @@ -32,9 +39,14 @@ describe('IgxTreeGrid - Integration', () => { IgxTreeGridDateTreeColumnComponent, IgxTreeGridBooleanTreeColumnComponent, IgxTreeGridRowEditingComponent, - IgxTreeGridMultiColHeadersComponent + IgxTreeGridMultiColHeadersComponent, + IgxTreeGridRowEditingTransactionComponent, + IgxTreeGridRowEditingHierarchicalDSTransactionComponent ], - imports: [NoopAnimationsModule, IgxToggleModule, IgxTreeGridModule] + imports: [NoopAnimationsModule, IgxToggleModule, IgxTreeGridModule], + providers: [ + { provide: IgxGridTransaction, useClass: IgxHierarchicalTransactionService } + ] }) .compileComponents(); })); @@ -295,12 +307,7 @@ describe('IgxTreeGrid - Integration', () => { treeGrid = fix.componentInstance.treeGrid; }); - it('banner has no indentation when editing a parent node.', fakeAsync(() => { - // TODO - // Verify the overlay has the same width as the row that is edited - })); - - it('shows the banner below the edited parent node', fakeAsync(() => { + it('should show the banner below the edited parent node', () => { // Collapsed state const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; grid.collapseAll(); @@ -315,7 +322,6 @@ describe('IgxTreeGrid - Integration', () => { function verifyBannerPositioning(columnIndex: number) { const cell = grid.getCellByColumn(columnIndex, 'Name'); cell.inEditMode = true; - tick(); fix.detectChanges(); const editRow = cell.row.nativeElement; @@ -329,15 +335,15 @@ describe('IgxTreeGrid - Integration', () => { // No much space between the row and the banner expect(bannerTop - editRowBottom).toBeLessThan(2); } - })); + }); - it('shows the banner below the edited child node', fakeAsync(() => { + it('should show the banner below the edited child node', () => { const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; grid.expandAll(); fix.detectChanges(); + const cell = grid.getCellByColumn(1, 'Name'); cell.inEditMode = true; - tick(); fix.detectChanges(); const editRow = cell.row.nativeElement; @@ -350,15 +356,17 @@ describe('IgxTreeGrid - Integration', () => { expect(bannerTop).toBeGreaterThanOrEqual(editRowBottom); // No much space between the row and the banner expect(bannerTop - editRowBottom).toBeLessThan(2); - })); + }); - it('shows the banner above the edited parent node if it is the last one', fakeAsync(() => { + it('should show the banner above the last parent node when in edit mode', fakeAsync(() => { const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; grid.height = '200px'; tick(16); // height animationFrame fix.detectChanges(); + grid.collapseAll(); fix.detectChanges(); + const cell = grid.getCellByColumn(2, 'Name'); cell.inEditMode = true; tick(); @@ -376,13 +384,13 @@ describe('IgxTreeGrid - Integration', () => { expect(editRowTop - bannerBottom).toBeLessThan(2); })); - it('shows the banner above the edited child node if it is the last one', fakeAsync(() => { + it('should show the banner above the last child node when in edit mode', () => { const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; grid.expandAll(); fix.detectChanges(); - const cell = grid.getCellByColumn(9, 'Name'); + + const cell = grid.getCellByColumn(grid.rowList.length - 1, 'Name'); cell.inEditMode = true; - tick(); fix.detectChanges(); const editRow = cell.row.nativeElement; @@ -395,69 +403,130 @@ describe('IgxTreeGrid - Integration', () => { expect(bannerBottom).toBeLessThanOrEqual(editRowTop); // No much space between the row and the banner expect(editRowTop - bannerBottom).toBeLessThan(2); - })); + }); - it('banner hides when you expand/collapse the edited row', fakeAsync(() => { + it('should hide banner when edited parent row is being expanded/collapsed', () => { const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; grid.collapseAll(); fix.detectChanges(); + // Edit parent row cell const cell = grid.getCellByColumn(0, 'Name'); cell.inEditMode = true; - tick(); fix.detectChanges(); - const banner = document.getElementsByClassName(CSS_CLASS_BANNER)[0]; - console.log(banner.attributes); + let banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); - // let banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); - // expect(banner.parent.attributes['aria-hidden']).toEqual('false'); + // Expand parent row + grid.expandRow(cell.row.rowID); + fix.detectChanges(); - // const row = cell.row as IgxTreeGridRowComponent; - // grid.expandRow(row.rowID); - // tick(); - // fix.detectChanges(); + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(cell.inEditMode).toBeFalsy(); + expect(banner.parent.attributes['aria-hidden']).toEqual('true'); - // banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); - // expect(cell.inEditMode).toBeFalsy(); - // // expect(banner.parent.attributes['aria-hidden']).toEqual('true'); + // Edit parent row cell + cell.inEditMode = true; + fix.detectChanges(); - // cell = grid.getCellByColumn(0, 'Name'); - // cell.inEditMode = true; - // tick(); - // fix.detectChanges(); + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); - // banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); - // expect(banner.parent.attributes['aria-hidden']).toEqual('false'); + // Collapse parent row + grid.collapseRow(cell.row.rowID); + fix.detectChanges(); - // grid.collapseRow(row.rowID); - // tick(); - // fix.detectChanges(); + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(cell.inEditMode).toBeFalsy(); + expect(banner.parent.attributes['aria-hidden']).toEqual('true'); + }); - // banner = fix.debugElement.query(By.css('.igx-overlay__content')); - // // console.log(banner); - // expect(cell.inEditMode).toBeFalsy(); - // expect(banner.parent.attributes['aria-hidden']).toEqual('true'); + it('should hide banner when edited child row is being expanded/collapsed', () => { + const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + grid.expandAll(); + fix.detectChanges(); + // Edit child row child cell + const childCell = grid.getCellByColumn(4, 'Name'); + childCell.inEditMode = true; + fix.detectChanges(); + let banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); - // TODO - // Verify the changes are preserved - // 1.) Expand a parent row while editing it - // 2.) Collapse an expanded parent row while editing it - // 3.) Collapse an expanded parent row while editing a child (test with more than 2 levels) - })); + // Collapse parent child row + let parentRow = grid.getRowByIndex(3); + grid.collapseRow(parentRow.rowID); + fix.detectChanges(); - it('TAB navigation cannot leave the edited row and the banner.', fakeAsync(() => { + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(childCell.inEditMode).toBeFalsy(); + expect(banner.parent.attributes['aria-hidden']).toEqual('true'); + + // Edit child row cell + const parentCell = grid.getCellByColumn(3, 'Name'); + parentCell.inEditMode = true; + fix.detectChanges(); + + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(banner.parent.attributes['aria-hidden']).toEqual('false'); + + // Collapse parent row + parentRow = grid.getRowByIndex(0); + grid.collapseRow(parentRow.rowID); + fix.detectChanges(); + + banner = fix.debugElement.query(By.css('.' + CSS_CLASS_BANNER)); + expect(parentCell.inEditMode).toBeFalsy(); + expect(banner.parent.attributes['aria-hidden']).toEqual('true'); + }); + + it('TAB navigation should not leave the edited row and the banner.', async () => { const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; - const cell = grid.getCellByColumn(2, 'Name'); - cell.inEditMode = true; - tick(); + const row = grid.getRowByIndex(2); + const dateCell = grid.getCellByColumn(2, 'HireDate'); + const nameCell = grid.getCellByColumn(2, 'Name'); + const idCell = grid.getCellByColumn(2, 'ID'); + const ageCell = grid.getCellByColumn(2, 'Age'); + dateCell.inEditMode = true; + await wait(30); fix.detectChanges(); - // TODO - // Verify the focus do not go to the next row - // Verify non-editable columns are skipped while navigating - })); + + await TreeGridFunctions.moveGridCellWithTab(fix, dateCell); + expect(dateCell.inEditMode).toBeFalsy(); + expect(nameCell.inEditMode).toBeTruthy(); + + await TreeGridFunctions.moveGridCellWithTab(fix, nameCell); + expect(nameCell.inEditMode).toBeFalsy(); + expect(idCell.inEditMode).toBeFalsy(); + expect(ageCell.inEditMode).toBeTruthy(); + + const cancelBtn = fix.debugElement.queryAll(By.css('.igx-button--flat'))[0] as DebugElement; + const doneBtn = fix.debugElement.queryAll(By.css('.igx-button--flat'))[1]; + spyOn(cancelBtn.nativeElement, 'focus').and.callThrough(); + spyOn(grid.rowEditTabs.first, 'move').and.callThrough(); + spyOn(grid.rowEditTabs.last, 'move').and.callThrough(); + + await TreeGridFunctions.moveGridCellWithTab(fix, ageCell); + expect(cancelBtn.nativeElement.focus).toHaveBeenCalled(); + + const mockObj = jasmine.createSpyObj('mockObj', ['stopPropagation', 'preventDefault']); + cancelBtn.triggerEventHandler('keydown.Tab', mockObj); + await wait(30); + fix.detectChanges(); + expect((grid.rowEditTabs.first).move).not.toHaveBeenCalled(); + expect(mockObj.preventDefault).not.toHaveBeenCalled(); + expect(mockObj.stopPropagation).toHaveBeenCalled(); + + doneBtn.triggerEventHandler('keydown.Tab', mockObj); + await wait(30); + fix.detectChanges(); + expect(dateCell.inEditMode).toBeTruthy(); + expect((grid.rowEditTabs.last).move).toHaveBeenCalled(); + expect(mockObj.preventDefault).toHaveBeenCalled(); + expect(mockObj.stopPropagation).toHaveBeenCalled(); + }); it('should preserve updates after removing Filtering', () => { const grid = fix.componentInstance.treeGrid as IgxTreeGridComponent; @@ -493,10 +562,9 @@ describe('IgxTreeGrid - Integration', () => { const childCell = grid.getCellByColumn(0, 'Age'); const childRowID = childCell.row.rowID; + childCell.update(14); const parentCell = grid.getCellByColumn(1, 'Age'); const parentRowID = parentCell.row.rowID; - - childCell.update(14); parentCell.update(80); fix.detectChanges(); @@ -513,6 +581,526 @@ describe('IgxTreeGrid - Integration', () => { }); }); + describe('Batch Editing', () => { + it('Children are transformed into parent nodes after their parent is deleted', fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + + const row: HTMLElement = treeGrid.getRowByIndex(0).nativeElement; + treeGrid.cascadeOnDelete = false; + const trans = treeGrid.transactions; + + treeGrid.deleteRowById(1); + fix.detectChanges(); + tick(); + + expect(row.classList).toContain('igx-grid__tr--deleted'); + expect(treeGrid.getRowByKey(1).index).toBe(0); + expect(treeGrid.getRowByKey(2).index).toBe(1); + expect(treeGrid.getRowByKey(3).index).toBe(2); + trans.commit(treeGrid.data); + tick(); + + expect(row.classList).not.toContain('igx-grid__tr--deleted'); + expect(treeGrid.getRowByKey(2).index).toBe(0); + expect(treeGrid.getRowByKey(3).index).toBe(1); + expect(trans.canUndo).toBe(false); + })); + + it('Children are deleted along with their parent', fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + treeGrid.cascadeOnDelete = true; + const trans = treeGrid.transactions; + + treeGrid.deleteRowById(1); + fix.detectChanges(); + tick(); + + for (let i = 0; i < 5; i++) { + const curRow: HTMLElement = treeGrid.getRowByIndex(i).nativeElement; + expect(curRow.classList).toContain('igx-grid__tr--deleted'); + } + expect(treeGrid.getRowByKey(1).index).toBe(0); + expect(treeGrid.getRowByKey(2).index).toBe(1); + expect(treeGrid.getRowByKey(3).index).toBe(2); + expect(treeGrid.getRowByKey(7).index).toBe(3); + expect(treeGrid.getRowByKey(4).index).toBe(4); + + trans.commit(treeGrid.data); + tick(); + + expect(treeGrid.getRowByKey(1)).toBeUndefined(); + expect(treeGrid.getRowByKey(2)).toBeUndefined(); + expect(treeGrid.getRowByKey(3)).toBeUndefined(); + expect(treeGrid.getRowByKey(7)).toBeUndefined(); + expect(treeGrid.getRowByKey(4)).toBeUndefined(); + + expect(treeGrid.getRowByKey(6).index).toBe(0); + expect(treeGrid.getRowByKey(10).index).toBe(1); + expect(trans.canUndo).toBe(false); + })); + + it('Editing a cell is possible with Hierarchical DS', fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + const trans = treeGrid.transactions; + + const targetCell = treeGrid.getCellByColumn(3, 'Age'); + targetCell.inEditMode = true; + targetCell.update('333'); + fix.detectChanges(); + tick(); + + // ged DONE button and click it + const rowEditingBannerElement = fix.debugElement.query(By.css('.igx-banner')); + const buttonElements = rowEditingBannerElement.queryAll(By.css('.igx-button--flat')); + const doneButtonElement = buttonElements.find(el => el.nativeElement.innerText === 'Done'); + doneButtonElement.nativeElement.click(); + tick(); + + // Verify the value is updated and the correct style is applied before committing + expect(targetCell.inEditMode).toBeFalsy(); + expect(targetCell.value).toBe('333'); + expect(targetCell.nativeElement.classList).toContain('igx-grid__td--edited'); + + // Commit + trans.commit(treeGrid.data, treeGrid.primaryKey, treeGrid.childDataKey); + tick(); + + // Verify the correct value is set + expect(targetCell.value).toBe('333'); + + // Add new root lv row + treeGrid.addRow({ ID: 11, ParentID: -1, Name: 'Dan Kolov', JobTitle: 'wrestler', Age: 32, OnPTO: true }); + tick(); + + // Edit a cell value and check it is correctly updated + const newTargetCell = treeGrid.getCellByColumn(10, 'Age'); + newTargetCell.inEditMode = true; + newTargetCell.update('666'); + fix.detectChanges(); + tick(); + + expect(newTargetCell.value).toBe('666'); + expect(newTargetCell.nativeElement.classList).toContain('igx-grid__td--edited'); + })); + + it('Undo/Redo keeps the correct number of steps with Hierarchical DS', () => { + // TODO: + // 1. Update a cell in three different rows + // 2. Execute "Undo" three times + // 3. Verify the initial state is shown + // 4. Execute "Redo" three times + // 5. Verify all the updates are shown with correct styles + // 6. Press "Commit" + // 7. Verify the changes are comitted + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + const trans = treeGrid.transactions; + const treeGridData = treeGrid.data; + // Get initial data + const rowData = { + 147: Object.assign({}, treeGrid.getRowByKey(147).rowData), + 475: Object.assign({}, treeGrid.getRowByKey(475).rowData), + 19: Object.assign({}, treeGrid.getRowByKey(19).rowData) + }; + const initialData = treeGrid.data.map(e => { + return Object.assign({}, e); + }); + let targetCell: IgxGridCellComponent; + // Get 147 row + targetCell = treeGrid.getCellByKey(147, 'Name'); + expect(targetCell.value).toEqual('John Winchester'); + // Edit 'Name' + targetCell.update('Testy Testington'); + // Get 475 row (1st child of 147) + targetCell = treeGrid.getCellByKey(475, 'Age'); + expect(targetCell.value).toEqual(30); + // Edit Age + targetCell.update(42); + // Get 19 row + targetCell = treeGrid.getCellByKey(19, 'Name'); + // Edit Name + expect(targetCell.value).toEqual('Yang Wang'); + targetCell.update('Old Richard'); + expect(rowData[147].Name).not.toEqual(treeGrid.getRowByKey(147).rowData.Name); + expect(rowData[475].Age).not.toEqual(treeGrid.getRowByKey(475).rowData.Age); + expect(rowData[19].Name).not.toEqual(treeGrid.getRowByKey(19).rowData.Name); + expect(treeGridData[0].Employees[475]).toEqual(initialData[0].Employees[475]); + expect(trans.canUndo).toBeTruthy(); + expect(trans.canRedo).toBeFalsy(); + trans.undo(); + trans.undo(); + trans.undo(); + expect(rowData[147].Name).toEqual(treeGrid.getRowByKey(147).rowData.Name); + expect(rowData[475].Age).toEqual(treeGrid.getRowByKey(475).rowData.Age); + expect(rowData[19].Name).toEqual(treeGrid.getRowByKey(19).rowData.Name); + expect(trans.canUndo).toBeFalsy(); + expect(trans.canRedo).toBeTruthy(); + trans.redo(); + trans.redo(); + trans.redo(); + expect(rowData[147].Name).not.toEqual(treeGrid.getRowByKey(147).rowData.Name); + expect(rowData[475].Age).not.toEqual(treeGrid.getRowByKey(475).rowData.Age); + expect(rowData[19].Name).not.toEqual(treeGrid.getRowByKey(19).rowData.Name); + expect(treeGridData[0].Employees[475]).toEqual(initialData[0].Employees[475]); + trans.commit(treeGridData, treeGrid.primaryKey, treeGrid.childDataKey); + expect(treeGridData[0].Name).toEqual('Testy Testington'); + expect(treeGridData[0].Employees[0].Age).toEqual(42); + expect(treeGridData[1].Name).toEqual('Old Richard'); + }); + + it('Add parent node to a Flat DS tree grid', fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + const trans = treeGrid.transactions; + + treeGrid.addRow({ ID: 11, ParentID: -1, Name: 'Dan Kolov', JobTitle: 'wrestler', Age: 32 }); + fix.detectChanges(); + tick(); + + expect(trans.canUndo).toBe(true); + expect(treeGrid.getRowByKey(11).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + + trans.commit(treeGrid.data); + tick(); + + expect(treeGrid.getRowByKey(11).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(trans.canUndo).toBe(false); + + treeGrid.addRow({ ID: 12, ParentID: -1, Name: 'Kubrat Pulev', JobTitle: 'Boxer', Age: 33 }); + fix.detectChanges(); + tick(); + + expect(trans.canUndo).toBe(true); + expect(treeGrid.getRowByKey(12).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + })); + + it('Add parent node to a Hierarchical DS tree grid', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + const initialDataLength = treeGrid.data.length; + const trans = treeGrid.transactions; + spyOn(trans, 'add').and.callThrough(); + + const addedRowId_1 = treeGrid.rowList.length; + const newRow = { + ID: addedRowId_1, + Name: 'John Dow', + HireDate: new Date(2018, 10, 20), + Age: 22, + OnPTO: false, + Employees: [] + }; + + treeGrid.addRow(newRow); + fix.detectChanges(); + + expect(trans.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + const transParams = { id: addedRowId_1, type: 'add', newValue: newRow }; + expect(trans.add).toHaveBeenCalledWith(transParams); + + expect(treeGrid.records.get(addedRowId_1).level).toBe(0); + expect(treeGrid.getRowByKey(addedRowId_1).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + + trans.commit(treeGrid.data); + fix.detectChanges(); + + expect(treeGrid.data.length).toEqual(initialDataLength + 1); + expect(treeGrid.data[initialDataLength]).toEqual(newRow); + expect(treeGrid.records.get(addedRowId_1).level).toBe(0); + expect(treeGrid.getRowByKey(addedRowId_1).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(trans.getTransactionLog().length).toEqual(0); + expect(trans.canUndo).toBeFalsy(); + + const addedRowId_2 = treeGrid.rowList.length; + const newParentRow = { + ID: addedRowId_2, + Name: 'Brad Pitt', + HireDate: new Date(2016, 8, 14), + Age: 54, + OnPTO: false + }; + + treeGrid.addRow(newParentRow); + fix.detectChanges(); + + expect(treeGrid.records.get(addedRowId_2).level).toBe(0); + expect(treeGrid.getRowByKey(addedRowId_2).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.getRowByKey(addedRowId_1).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + }); + + it('Add a child node to a previously added parent node - Flat DS', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + const rootRow = { ID: 11, ParentID: -1, Name: 'Kubrat Pulev', JobTitle: 'wrestler', Age: 32 }; + const childRow = { ID: 12, ParentID: 11, Name: 'Tervel Pulev', JobTitle: 'wrestler', Age: 30 }; + const grandChildRow = { ID: 13, ParentID: 12, Name: 'Asparuh Pulev', JobTitle: 'wrestler', Age: 14 }; + const trans = treeGrid.transactions; + + treeGrid.addRow(rootRow, 0); + fix.detectChanges(); + + treeGrid.addRow(childRow, 11); + fix.detectChanges(); + + expect(treeGrid.getRowByKey(11).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.getRowByKey(12).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + + trans.commit(treeGrid.data); + + expect(treeGrid.getRowByKey(11).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.getRowByKey(12).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + + treeGrid.addRow(grandChildRow, 12); + fix.detectChanges(); + + expect(treeGrid.getRowByKey(11).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.getRowByKey(12).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.getRowByKey(13).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + }); + + it('Add a child node to a previously added parent node - Hierarchical DS', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + const rowData = { + parent: { ID: 13, Name: 'Dr. Evil', JobTitle: 'Doctor of Evilness', Age: 52 }, + child: { ID: 133, Name: 'Scott', JobTitle: `Annoying Teen, Dr. Evil's son`, Age: 17 }, + grandChild: { ID: 1337, Name: 'Mr. Bigglesworth', JobTitle: 'Evil Cat', Age: 13 } + }; + // 1. Add a row at level 0 to the grid + treeGrid.addRow(rowData.parent); + // 2. Add a child row to that parent + treeGrid.addRow(rowData.child, rowData.parent.ID); + // 3. Verify the new rows are pending with the correct styles + expect(treeGrid.getRowByKey(13).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.getRowByKey(133).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.data.findIndex(e => e.ID === rowData.parent.ID)).toEqual(-1); + expect(treeGrid.data.findIndex(e => e.ID === rowData.child.ID)).toEqual(-1); + expect(treeGrid.transactions.getAggregatedChanges(true).length).toEqual(2); + // 4. Commit + treeGrid.transactions.commit(treeGrid.data, treeGrid.primaryKey, treeGrid.childDataKey); + // 5. verify the rows are committed, the styles are OK + expect(treeGrid.data.findIndex(e => e.ID === rowData.parent.ID)).not.toEqual(-1); + expect(treeGrid.data.findIndex(e => e.ID === rowData.child.ID)).not.toEqual(-1); + expect(treeGrid.getRowByKey(13).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.getRowByKey(133).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.transactions.getAggregatedChanges(true).length).toEqual(0); + // 6. Add another child row at level 2 (grand-child of the first row) + treeGrid.addRow(rowData.grandChild, rowData.child.ID); + // 7. verify the pending styles is applied only to the newly added row + // and not to the previously added rows + expect(treeGrid.getRowByKey(13).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.getRowByKey(133).nativeElement.classList).not.toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.getRowByKey(1337).nativeElement.classList).toContain(CSS_CLASS_ROW_EDITED); + expect(treeGrid.transactions.getAggregatedChanges(true).length).toEqual(1); + }); + + it('Delete a pending parent node - Flat DS', () => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + const trans = treeGrid.transactions; + spyOn(trans, 'add').and.callThrough(); + treeGrid.foreignKey = 'ParentID'; + + const addedRowId = treeGrid.data.length; + const newRow = { + ID: addedRowId, + ParentID: 1, + Name: 'John Dow', + JobTitle: 'Copywriter', + Age: 22 + }; + treeGrid.addRow(newRow, 0); + fix.detectChanges(); + + const addedRow = treeGrid.rowList.filter(r => r.rowID === addedRowId)[0] as IgxTreeGridRowComponent; + treeGrid.selectRows([treeGrid.getRowByIndex(addedRow.index).rowID], true); + fix.detectChanges(); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + const transParams = { id: addedRowId, type: 'add', newValue: newRow }; + expect(trans.add).toHaveBeenCalledWith(transParams); + + treeGrid.deleteRowById(treeGrid.selectedRows()[0]); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.rowID === addedRowId).length).toEqual(0); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(2); + expect(treeGrid.transactions.getTransactionLog()[1].id).toEqual(addedRowId); + expect(treeGrid.transactions.getTransactionLog()[1].type).toEqual('delete'); + expect(treeGrid.transactions.getTransactionLog()[1].newValue).toBeNull(); + + treeGrid.transactions.undo(); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.rowID === addedRowId).length).toEqual(1); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(2); + expect(trans.add).toHaveBeenCalledWith(transParams); + }); + + it('Delete a pending parent node - Hierarchical DS', fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + const trans = treeGrid.transactions; + spyOn(trans, 'add').and.callThrough(); + + const parentRow = treeGrid.getRowByIndex(0) as IgxTreeGridRowComponent; + const addedRowId = treeGrid.rowList.length; + const newRow = { + ID: addedRowId, + Name: 'John Dow', + HireDate: new Date(2018, 10, 20), + Age: 22, + OnPTO: false, + Employees: [] + }; + + treeGrid.addRow(newRow, parentRow.rowID); + fix.detectChanges(); + + const addedRow = treeGrid.rowList.filter(r => r.rowID === addedRowId)[0] as IgxTreeGridRowComponent; + treeGrid.selectRows([treeGrid.getRowByIndex(addedRow.index).rowID], true); + tick(20); + fix.detectChanges(); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + const transParams = { id: addedRowId, path: [parentRow.rowID], newValue: newRow, type: 'add' }; + expect(trans.add).toHaveBeenCalledWith(transParams, null); + + treeGrid.deleteRowById(treeGrid.selectedRows()[0]); + tick(); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.rowID === addedRowId).length).toEqual(0); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(2); + expect(treeGrid.transactions.getTransactionLog()[1].id).toEqual(addedRowId); + expect(treeGrid.transactions.getTransactionLog()[1].type).toEqual('delete'); + expect(treeGrid.transactions.getTransactionLog()[1].newValue).toBeNull(); + + treeGrid.transactions.undo(); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.rowID === addedRowId).length).toEqual(1); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(2); + expect(trans.add).toHaveBeenCalledWith(transParams, null); + })); + + it('Delete a pending child node - Flat DS', () => { + fix = TestBed.createComponent(IgxTreeGridPrimaryForeignKeyComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + const trans = treeGrid.transactions; + spyOn(trans, 'add').and.callThrough(); + treeGrid.foreignKey = 'ParentID'; + + const addedRowId = treeGrid.data.length; + const newRow = { + ID: addedRowId, + ParentID: 1, + Name: 'John Dow', + JobTitle: 'Copywriter', + Age: 22 + }; + treeGrid.addRow(newRow, 1); + fix.detectChanges(); + + const addedRow = treeGrid.rowList.filter(r => r.rowID === addedRowId)[0] as IgxTreeGridRowComponent; + treeGrid.selectRows([treeGrid.getRowByIndex(addedRow.index).rowID], true); + fix.detectChanges(); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + const transParams = { id: addedRowId, type: 'add', newValue: newRow }; + expect(trans.add).toHaveBeenCalledWith(transParams); + + treeGrid.deleteRowById(treeGrid.selectedRows()[0]); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.rowID === addedRowId).length).toEqual(0); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(2); + expect(treeGrid.transactions.getTransactionLog()[1].id).toEqual(addedRowId); + expect(treeGrid.transactions.getTransactionLog()[1].type).toEqual('delete'); + expect(treeGrid.transactions.getTransactionLog()[1].newValue).toBeNull(); + + treeGrid.transactions.undo(); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.rowID === addedRowId).length).toEqual(1); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(2); + expect(trans.add).toHaveBeenCalledWith(transParams); + }); + + it('Delete a pending child node - Hierarchical DS', fakeAsync(() => { + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid; + const trans = treeGrid.transactions; + spyOn(trans, 'add').and.callThrough(); + + const parentRow = treeGrid.getRowByIndex(1) as IgxTreeGridRowComponent; + const addedRowId = treeGrid.rowList.length; + const newRow = { + ID: addedRowId, + Name: 'John Dow', + HireDate: new Date(2018, 10, 20), + Age: 22, + OnPTO: false, + Employees: [] + }; + + treeGrid.addRow(newRow, parentRow.rowID); + fix.detectChanges(); + + const addedRow = treeGrid.rowList.filter(r => r.rowID === addedRowId)[0] as IgxTreeGridRowComponent; + treeGrid.selectRows([treeGrid.getRowByIndex(addedRow.index).rowID], true); + tick(20); + fix.detectChanges(); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(1); + const transPasrams = { + id: addedRowId, + path: [treeGrid.getRowByIndex(0).rowID, parentRow.rowID], + newValue: newRow, + type: 'add' + }; + expect(trans.add).toHaveBeenCalledWith(transPasrams, null); + + treeGrid.deleteRowById(treeGrid.selectedRows()[0]); + tick(); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.rowID === addedRowId).length).toEqual(0); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(2); + expect(treeGrid.transactions.getTransactionLog()[1].id).toEqual(addedRowId); + expect(treeGrid.transactions.getTransactionLog()[1].type).toEqual('delete'); + expect(treeGrid.transactions.getTransactionLog()[1].newValue).toBeNull(); + + treeGrid.transactions.undo(); + fix.detectChanges(); + expect(treeGrid.rowList.filter(r => r.rowID === addedRowId).length).toEqual(1); + expect(treeGrid.transactions.getTransactionLog().length).toEqual(1); + expect(trans.add).toHaveBeenCalled(); + expect(trans.add).toHaveBeenCalledTimes(2); + expect(trans.add).toHaveBeenCalledWith(transPasrams, null); + })); + }); + describe('Multi-column header', () => { beforeEach(() => { fix = TestBed.createComponent(IgxTreeGridMultiColHeadersComponent); @@ -616,5 +1204,90 @@ describe('IgxTreeGrid - Integration', () => { TreeGridFunctions.verifyTreeColumnInMultiColHeaders(fix, 'HireDate', 4); })); + + it('Add rows to empty grid - Hierarchical DS', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingHierarchicalDSTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + // set empty data + treeGrid.data = []; + + const trans = treeGrid.transactions; + const rootRow = { + ID: 11, + Name: 'Kubrat Pulev', + HireDate: new Date(2018, 10, 20), + Age: 32, + OnPTO: false, + Employees: [] + }; + const childRow = { + ID: 12, + Name: 'Tervel Pulev', + HireDate: new Date(2018, 10, 10), + Age: 30, + OnPTO: true, + Employees: [] + }; + const grandChildRow = { + ID: 13, + Name: 'Asparuh Pulev', + HireDate: new Date(2017, 10, 10), + Age: 14, + OnPTO: true, + Employees: [] + }; + treeGrid.addRow(rootRow); + treeGrid.addRow(childRow, 11); + expect(treeGrid.getRowByKey(11).nativeElement.classList).toContain('igx-grid__tr--edited'); + expect(treeGrid.getRowByKey(12).nativeElement.classList).toContain('igx-grid__tr--edited'); + trans.commit(treeGrid.data, treeGrid.primaryKey, treeGrid.childDataKey); + expect(treeGrid.getRowByKey(11).nativeElement.classList).not.toContain('igx-grid__tr--edited'); + expect(treeGrid.getRowByKey(12).nativeElement.classList).not.toContain('igx-grid__tr--edited'); + treeGrid.addRow(grandChildRow, 12); + expect(treeGrid.getRowByKey(11).nativeElement.classList).not.toContain('igx-grid__tr--edited'); + expect(treeGrid.getRowByKey(12).nativeElement.classList).not.toContain('igx-grid__tr--edited'); + expect(treeGrid.getRowByKey(13).nativeElement.classList).toContain('igx-grid__tr--edited'); + expect(treeGrid.records.get(11).level).toBe(0); + expect(treeGrid.records.get(12).level).toBe(1); + expect(treeGrid.records.get(13).level).toBe(2); + }); + + it('Add rows to empty grid - Flat DS', () => { + fix = TestBed.createComponent(IgxTreeGridRowEditingTransactionComponent); + fix.detectChanges(); + treeGrid = fix.componentInstance.treeGrid as IgxTreeGridComponent; + // set empty data + treeGrid.data = []; + + const rootRow = { ID: 11, ParentID: -1, Name: 'Kubrat Pulev', JobTitle: 'wrestler', Age: 32 }; + const childRow = { ID: 12, ParentID: 11, Name: 'Tervel Pulev', JobTitle: 'wrestler', Age: 30 }; + const grandChildRow = { ID: 13, ParentID: 12, Name: 'Asparuh Pulev', JobTitle: 'wrestler', Age: 14 }; + const trans = treeGrid.transactions; + + treeGrid.addRow(rootRow, 0); + fix.detectChanges(); + + treeGrid.addRow(childRow, 11); + fix.detectChanges(); + + expect(treeGrid.getRowByKey(11).nativeElement.classList).toContain('igx-grid__tr--edited'); + expect(treeGrid.getRowByKey(12).nativeElement.classList).toContain('igx-grid__tr--edited'); + + trans.commit(treeGrid.data); + + expect(treeGrid.getRowByKey(11).nativeElement.classList).not.toContain('igx-grid__tr--edited'); + expect(treeGrid.getRowByKey(12).nativeElement.classList).not.toContain('igx-grid__tr--edited'); + + treeGrid.addRow(grandChildRow, 12); + fix.detectChanges(); + + expect(treeGrid.getRowByKey(11).nativeElement.classList).not.toContain('igx-grid__tr--edited'); + expect(treeGrid.getRowByKey(12).nativeElement.classList).not.toContain('igx-grid__tr--edited'); + expect(treeGrid.getRowByKey(13).nativeElement.classList).toContain('igx-grid__tr--edited'); + expect(treeGrid.records.get(11).level).toBe(0); + expect(treeGrid.records.get(12).level).toBe(1); + expect(treeGrid.records.get(13).level).toBe(2); + }); }); }); diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.ts b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.ts index 6b73256892c..f96f54714ad 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.ts +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid-row.component.ts @@ -89,22 +89,24 @@ export class IgxTreeGridRowComponent extends IgxRowComponent IgxTreeGridComponent) }, IgxFilteringService] }) export class IgxTreeGridComponent extends IgxGridBaseComponent { @@ -183,7 +183,7 @@ export class IgxTreeGridComponent extends IgxGridBaseComponent { this.cdr.markForCheck(); } - private _expansionStates: Map = new Map(); + private _expansionStates: Map = new Map(); /** * Returns a list of key-value pairs [row ID, expansion state]. Includes only states that differ from the default one. @@ -266,12 +266,12 @@ export class IgxTreeGridComponent extends IgxGridBaseComponent { return false; } - private cloneMap(mapIn: Map): Map { + private cloneMap(mapIn: Map): Map { const mapCloned: Map = new Map(); mapIn.forEach((value: boolean, key: any, mapObj: Map) => { - mapCloned.set(key, value); + mapCloned.set(key, value); }); return mapCloned; @@ -368,9 +368,13 @@ export class IgxTreeGridComponent extends IgxGridBaseComponent { const childKey = this.childDataKey; if (this.transactions.enabled) { const rowId = this.primaryKey ? data[this.primaryKey] : data; + const path: any[] = []; + path.push(parentRowID); + path.push(...this.generateRowPath(parentRowID)); + path.reverse(); this.transactions.add({ id: rowId, - parentId: parentRowID, + path: path, newValue: data, type: TransactionType.ADD } as HierarchicalTransaction, @@ -393,26 +397,31 @@ export class IgxTreeGridComponent extends IgxGridBaseComponent { } } - /** - * @hidden - */ + /** @hidden */ public deleteRowById(rowId: any) { - if (this.transactions.enabled && this.cascadeOnDelete) { + // if this is flat self-referencing data, and CascadeOnDelete is set to true + // and if we have transactions we should start pending transaction. This allows + // us in case of delete action to delete all child rows as single undo action + const flatDataWithCascadeOnDeleteAndTransactions = + this.primaryKey && + this.foreignKey && + this.cascadeOnDelete && + this.transactions.enabled; + + if (flatDataWithCascadeOnDeleteAndTransactions) { this.transactions.startPending(); } super.deleteRowById(rowId); - if (this.transactions.enabled && this.cascadeOnDelete) { + if (flatDataWithCascadeOnDeleteAndTransactions) { this.transactions.endPending(true); } } - /** - * @hidden - */ + /** @hidden */ protected deleteRowFromData(rowID: any, index: number) { - if (this.primaryKey && this.foreignKey) { + if (this.primaryKey && this.foreignKey) { super.deleteRowFromData(rowID, index); if (this.cascadeOnDelete) { @@ -424,11 +433,12 @@ export class IgxTreeGridComponent extends IgxGridBaseComponent { } } } - } else { + } else { const record = this.records.get(rowID); - const childData = record.parent ? record.parent.data[this.childDataKey] : this.data; - index = this.primaryKey ? childData.map(c => c[this.primaryKey]).indexOf(rowID) : - childData.indexOf(rowID); + const collection = record.parent ? record.parent.data[this.childDataKey] : this.data; + index = this.primaryKey ? + collection.map(c => c[this.primaryKey]).indexOf(rowID) : + collection.indexOf(rowID); const selectedChildren = []; this._gridAPI.get_selected_children(this.id, record, selectedChildren); @@ -437,19 +447,34 @@ export class IgxTreeGridComponent extends IgxGridBaseComponent { } if (this.transactions.enabled) { + const path = this.generateRowPath(rowID); this.transactions.add({ - id: rowID, - type: TransactionType.DELETE, - newValue: null, - parentId: record.parent ? record.parent.rowID : undefined - }, - this.data); + id: rowID, + type: TransactionType.DELETE, + newValue: null, + path: path + }, + collection[index] + ); } else { - childData.splice(index, 1); + collection.splice(index, 1); } } } + /** @hidden */ + public generateRowPath(rowId: any): any[] { + const path: any[] = []; + let record = this.records.get(rowId); + + while (record.parent) { + path.push(record.parent.rowID); + record = record.parent; + } + + return path; + } + /** * @hidden */ @@ -493,14 +518,10 @@ export class IgxTreeGridComponent extends IgxGridBaseComponent { /** * @hidden */ - public getContext(rowData): any { + public getContext(rowData): any { return { $implicit: rowData, templateID: 'dataRow' }; } - - protected writeToData(rowIndex: number, value: any) { - mergeObjects(this.flatData[rowIndex], value); - } } diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.interfaces.ts b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.interfaces.ts index 3789aff836f..0ea55334161 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.interfaces.ts +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.interfaces.ts @@ -7,7 +7,6 @@ export interface ITreeGridRecord { level?: number; isFilteredOutParent?: boolean; expanded?: boolean; - path: any[]; } export interface IRowToggleEventArgs { diff --git a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.pipes.ts b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.pipes.ts index 459b3f91f5b..2518a66dc27 100644 --- a/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.pipes.ts +++ b/projects/igniteui-angular/src/lib/grids/tree-grid/tree-grid.pipes.ts @@ -55,16 +55,11 @@ export class IgxTreeGridHierarchizingPipe implements PipeTransform { const record: ITreeGridRecord = { rowID: this.getRowID(primaryKey, row), data: row, - children: [], - path: [] + children: [] }; const parent = map.get(row[foreignKey]); if (parent) { record.parent = parent; - if (parent) { - record.path.push(...parent.path); - record.path.push(parent.rowID); - } parent.children.push(record); } else { missingParentRecords.push(record); @@ -110,13 +105,8 @@ export class IgxTreeGridHierarchizingPipe implements PipeTransform { rowID: this.getRowID(primaryKey, item), data: item, parent: parent, - level: indentationLevel, - path: [] + level: indentationLevel }; - if (parent) { - record.path.push(...parent.path); - record.path.push(parent.rowID); - } record.expanded = this.gridAPI.get_row_expansion_state(id, record.rowID, record.level); flatData.push(item); map.set(record.rowID, record); @@ -270,26 +260,30 @@ export class IgxTreeGridTransactionPipe implements PipeTransform { transform(collection: any[], id: string, pipeTrigger: number): any[] { const grid: IgxTreeGridComponent = this.gridAPI.get(id); if (collection && grid.transactions.enabled) { - const primaryKey = grid.primaryKey; - if (!primaryKey) { - return collection; - } + const aggregatedChanges = grid.transactions.getAggregatedChanges(true); + if (aggregatedChanges.length > 0) { + const primaryKey = grid.primaryKey; + if (!primaryKey) { + return collection; + } - const foreignKey = grid.foreignKey; - const childDataKey = grid.childDataKey; - - if (foreignKey) { - return DataUtil.mergeTransactions( - cloneArray(collection), - grid.transactions.getAggregatedChanges(true), - grid.primaryKey); - } else if (childDataKey) { - return DataUtil.mergeHierarchicalTransactions( - cloneHierarchicalArray(collection, childDataKey), - grid.transactions.getAggregatedChanges(true), - childDataKey, - grid.primaryKey - ); + const foreignKey = grid.foreignKey; + const childDataKey = grid.childDataKey; + + if (foreignKey) { + const flatDataClone = cloneArray(collection); + return DataUtil.mergeTransactions( + flatDataClone, + aggregatedChanges, + grid.primaryKey); + } else if (childDataKey) { + const hierarchicalDataClone = cloneHierarchicalArray(collection, childDataKey); + return DataUtil.mergeHierarchicalTransactions( + hierarchicalDataClone, + aggregatedChanges, + childDataKey, + grid.primaryKey); + } } } diff --git a/projects/igniteui-angular/src/lib/services/transaction/igx-hierarchical-transaction.ts b/projects/igniteui-angular/src/lib/services/transaction/igx-hierarchical-transaction.ts index 9fbd8f14ad6..2f722f5d4e3 100644 --- a/projects/igniteui-angular/src/lib/services/transaction/igx-hierarchical-transaction.ts +++ b/projects/igniteui-angular/src/lib/services/transaction/igx-hierarchical-transaction.ts @@ -1,6 +1,7 @@ -import { HierarchicalTransaction, HierarchicalState, TransactionType, HierarchicalTransactionNode } from './transaction'; +import { HierarchicalTransaction, HierarchicalState, TransactionType } from './transaction'; import { Injectable } from '@angular/core'; import { IgxTransactionService } from './igx-transaction'; +import { DataUtil } from '../../data-operations/data-util'; /** @experimental @hidden */ @Injectable() @@ -12,19 +13,52 @@ export class IgxHierarchicalTransactionService { const value = mergeChanges ? this.mergeValues(state.recordRef, state.value) : state.value; this.clearArraysFromObject(value); - result.push({ id: key, parentId: state.parentId, newValue: value, type: state.type } as T); + result.push({ id: key, path: state.path, newValue: value, type: state.type } as T); }); return result; } protected updateState(states: Map, transaction: T, recordRef?: any): void { super.updateState(states, transaction, recordRef); + + // if transaction has no path, e.g. flat data source, get out + if (!transaction.path) { + return; + } + const currentState = states.get(transaction.id); - if (currentState && transaction.type === TransactionType.ADD) { - currentState.parentId = transaction.parentId; + if (currentState) { + currentState.path = transaction.path; + } + + // if transaction has path, Hierarchical data source, and it is DELETE + // type transaction for all child rows remove ADD states and update + // transaction type and value of UPDATE states + if (transaction.type === TransactionType.DELETE) { + states.forEach((v: S, k: any) => { + if (v.path && v.path.indexOf(transaction.id) !== -1) { + switch (v.type) { + case TransactionType.ADD: + states.delete(k); + break; + case TransactionType.UPDATE: + states.get(k).type = TransactionType.DELETE; + states.get(k).value = null; + } + } + }); } } + public commit(data: any[], primaryKey?: any, childDataKey?: any): void { + if (childDataKey) { + DataUtil.mergeHierarchicalTransactions(data, this.getAggregatedChanges(true), childDataKey, primaryKey, true); + } else { + super.commit(data); + } + this.clear(); + } + // TODO: remove this method. Force cloning to strip child arrays when needed instead private clearArraysFromObject(obj: {}) { for (const prop of Object.keys(obj)) { @@ -34,3 +68,4 @@ export class IgxHierarchicalTransactionService { describe('IgxTransaction UNIT tests', () => { @@ -571,7 +572,11 @@ describe('IgxTransaction', () => { [ { id: 'Key1', - newValue: { key: 'Key1', value1: 10, value3: 30 }, + newValue: { key: 'Key1', value1: 10 }, + type: 'update' + }, { + id: 'Key1', + newValue: { key: 'Key1', value3: 30 }, type: 'update' } ]); @@ -608,5 +613,75 @@ describe('IgxTransaction', () => { expect(trans.getAggregatedChanges(true)).toEqual([]); }); }); + + describe('IgxHierarchicalTransaction UNIT Test', () => { + it('Should set path for each state when transaction is added in Hierarchical data source', () => { + const transaction = new IgxHierarchicalTransactionService(); + expect(transaction).toBeDefined(); + + const path: any[] = ['P1', 'P2']; + const addTransaction: HierarchicalTransaction = { id: 1, type: TransactionType.ADD, newValue: 'Add row', path }; + transaction.add(addTransaction); + expect(transaction.getState(1).path).toBeDefined(); + expect(transaction.getState(1).path.length).toBe(2); + expect(transaction.getState(1).path).toEqual(path); + + path.push('P3'); + const updateTransaction: HierarchicalTransaction = { id: 1, type: TransactionType.UPDATE, newValue: 'Updated row', path }; + transaction.add(updateTransaction, 'Update row'); + expect(transaction.getState(1).path.length).toBe(3); + expect(transaction.getState(1).path).toEqual(path); + }); + + it('Should remove added transaction from states when deleted in Hierarchical data source', () => { + const transaction = new IgxHierarchicalTransactionService(); + expect(transaction).toBeDefined(); + + const path: any[] = []; + let addTransaction: HierarchicalTransaction = { id: 1, type: TransactionType.ADD, newValue: 'Parent row', path }; + transaction.add(addTransaction); + expect(transaction.getState(1).path).toBeDefined(); + expect(transaction.getState(1).path.length).toBe(0); + expect(transaction.getState(1).path).toEqual(path); + + path.push(addTransaction.id); + addTransaction = { id: 2, type: TransactionType.ADD, newValue: 'Child row', path }; + transaction.add(addTransaction); + expect(transaction.getState(2).path).toBeDefined(); + expect(transaction.getState(2).path.length).toBe(1); + expect(transaction.getState(2).path).toEqual(path); + + const deleteTransaction: HierarchicalTransaction = { id: 1, type: TransactionType.DELETE, newValue: null, path: [] }; + transaction.add(deleteTransaction); + expect(transaction.getState(1)).toBeUndefined(); + expect(transaction.getState(2)).toBeUndefined(); + }); + + it('Should mark update transactions state as deleted type when deleted in Hierarchical data source', () => { + const transaction = new IgxHierarchicalTransactionService(); + expect(transaction).toBeDefined(); + + const path: any[] = []; + let updateTransaction: HierarchicalTransaction = { id: 1, type: TransactionType.UPDATE, newValue: 'Parent row', path }; + transaction.add(updateTransaction, 'Original value'); + expect(transaction.getState(1).path).toBeDefined(); + expect(transaction.getState(1).path.length).toBe(0); + expect(transaction.getState(1).path).toEqual(path); + + path.push(updateTransaction.id); + updateTransaction = { id: 2, type: TransactionType.UPDATE, newValue: 'Child row', path }; + transaction.add(updateTransaction, 'Original Value'); + expect(transaction.getState(2).path).toBeDefined(); + expect(transaction.getState(2).path.length).toBe(1); + expect(transaction.getState(2).path).toEqual(path); + + const deleteTransaction: HierarchicalTransaction = { id: 1, type: TransactionType.DELETE, newValue: null, path: [] }; + transaction.add(deleteTransaction); + expect(transaction.getState(1)).toBeDefined(); + expect(transaction.getState(1).type).toBe(TransactionType.DELETE); + expect(transaction.getState(2)).toBeDefined(); + expect(transaction.getState(2).type).toBe(TransactionType.DELETE); + }); + }); }); diff --git a/projects/igniteui-angular/src/lib/services/transaction/igx-transaction.ts b/projects/igniteui-angular/src/lib/services/transaction/igx-transaction.ts index 9363470f940..c8f6e433956 100644 --- a/projects/igniteui-angular/src/lib/services/transaction/igx-transaction.ts +++ b/projects/igniteui-angular/src/lib/services/transaction/igx-transaction.ts @@ -6,8 +6,8 @@ import { isObject, mergeObjects, cloneValue } from '../../core/utils'; @Injectable() export class IgxTransactionService extends IgxBaseTransactionService { protected _transactions: T[] = []; - protected _redoStack: { transaction: T, recordRef: any, useInUndo?: boolean }[] = []; - protected _undoStack: { transaction: T, recordRef: any, useInUndo?: boolean }[] = []; + protected _redoStack: { transaction: T, recordRef: any }[][] = []; + protected _undoStack: { transaction: T, recordRef: any }[][] = []; protected _states: Map = new Map(); get canUndo(): boolean { @@ -26,14 +26,14 @@ export class IgxTransactionService exten this.addTransaction(transaction, states, recordRef); } - private addTransaction(transaction: T, states: Map, recordRef?: any, useInUndo: boolean = true) { + protected addTransaction(transaction: T, states: Map, recordRef?: any) { this.updateState(states, transaction, recordRef); const transactions = this._isPending ? this._pendingTransactions : this._transactions; transactions.push(transaction); if (!this._isPending) { - this._undoStack.push({ transaction, recordRef, useInUndo }); + this._undoStack.push([{ transaction, recordRef }]); this._redoStack = []; this.onStateUpdate.emit(); } @@ -85,11 +85,16 @@ export class IgxTransactionService exten public endPending(commit: boolean): void { this._isPending = false; if (commit) { - let i = 0; - this._pendingStates.forEach((s: S, k: any) => { - this.addTransaction({ id: k, newValue: s.value, type: s.type } as T, this._states, s.recordRef, i === 0); - i++; - }); + const actions: { transaction: T, recordRef: any }[] = []; + for (const transaction of this._pendingTransactions) { + const pendingState = this._pendingStates.get(transaction.id); + this._transactions.push(transaction); + this.updateState(this._states, transaction, pendingState.recordRef); + actions.push({ transaction, recordRef: pendingState.recordRef }); + } + + this._undoStack.push(actions); + this._redoStack = []; } super.endPending(commit); } @@ -129,35 +134,30 @@ export class IgxTransactionService exten return; } - let action: { transaction: T, recordRef: any, useInUndo?: boolean }; - do { - action = this._undoStack.pop(); - this._transactions.pop(); - this._redoStack.push(action); - } while (!action.useInUndo); + const lastActions: { transaction: T, recordRef: any }[] = this._undoStack.pop(); + this._transactions.splice(this._transactions.length - lastActions.length); + this._redoStack.push(lastActions); this._states.clear(); - this._undoStack.map(a => this.updateState(this._states, a.transaction, a.recordRef)); + for (const currentActions of this._undoStack) { + for (const transaction of currentActions) { + this.updateState(this._states, transaction.transaction, transaction.recordRef); + } + } + this.onStateUpdate.emit(); } public redo(): void { if (this._redoStack.length > 0) { - // remove first item from redo stack (it should always has useInUndo === true) - // and then all next items until there are items and useInUndo === false. - // If there are no more items, or next item's useInUndo === true leave. - let undoItem: { transaction: T, recordRef: any, useInUndo?: boolean }; - undoItem = this._redoStack.pop(); - this.updateState(this._states, undoItem.transaction, undoItem.recordRef); - this._transactions.push(undoItem.transaction); - this._undoStack.push(undoItem); - - while (this._redoStack[this._redoStack.length - 1] && !this._redoStack[this._redoStack.length - 1].useInUndo) { - undoItem = this._redoStack.pop(); - this.updateState(this._states, undoItem.transaction, undoItem.recordRef); - this._transactions.push(undoItem.transaction); - this._undoStack.push(undoItem); + let actions: { transaction: T, recordRef: any, useInUndo?: boolean }[]; + actions = this._redoStack.pop(); + for (const action of actions) { + this.updateState(this._states, action.transaction, action.recordRef); + this._transactions.push(action.transaction); } + + this._undoStack.push(actions); this.onStateUpdate.emit(); } } diff --git a/projects/igniteui-angular/src/lib/services/transaction/transaction.ts b/projects/igniteui-angular/src/lib/services/transaction/transaction.ts index 22dba1606d6..5bc55a04357 100644 --- a/projects/igniteui-angular/src/lib/services/transaction/transaction.ts +++ b/projects/igniteui-angular/src/lib/services/transaction/transaction.ts @@ -14,7 +14,7 @@ export interface Transaction { /** @experimental @hidden */ export interface HierarchicalTransaction extends Transaction { - parentId: any; + path: any[]; } export interface State { @@ -25,14 +25,7 @@ export interface State { /** @experimental @hidden */ export interface HierarchicalState extends State { - parentId: any; -} - -/** @experimental @hidden */ -export interface HierarchicalTransactionNode { - id: any; - parentId?: any; - childNodes: HierarchicalTransactionNode[]; + path: any[]; } export interface TransactionService { diff --git a/projects/igniteui-angular/src/lib/test-utils/tree-grid-components.spec.ts b/projects/igniteui-angular/src/lib/test-utils/tree-grid-components.spec.ts index 78258e65cec..6c3dc147d23 100644 --- a/projects/igniteui-angular/src/lib/test-utils/tree-grid-components.spec.ts +++ b/projects/igniteui-angular/src/lib/test-utils/tree-grid-components.spec.ts @@ -1,6 +1,8 @@ import { Component, ViewChild } from '@angular/core'; import { IgxTreeGridComponent } from '../grids/tree-grid/tree-grid.component'; import { SampleTestData } from './sample-test-data.spec'; +import { IgxTransactionService, IgxHierarchicalTransactionService } from '../../public_api'; +import { IgxGridTransaction } from '../grids/grid-base.component'; @Component({ template: ` @@ -19,7 +21,7 @@ export class IgxTreeGridSortingComponent { @Component({ template: ` - + @@ -258,3 +260,37 @@ export class IgxTreeGridMultiColHeadersComponent { @ViewChild(IgxTreeGridComponent) public treeGrid: IgxTreeGridComponent; public data = SampleTestData.employeeSmallTreeData(); } +@Component({ + template: ` + + + + + + + ` + , providers: [{ provide: IgxGridTransaction, useClass: IgxTransactionService }], +}) +export class IgxTreeGridRowEditingTransactionComponent { + public data = SampleTestData.employeePrimaryForeignKeyTreeData(); + @ViewChild('treeGrid', { read: IgxTreeGridComponent }) public treeGrid: IgxTreeGridComponent; + public paging = false; +} + +@Component({ + template: ` + + + + + + + ` + , providers: [{ provide: IgxGridTransaction, useClass: IgxHierarchicalTransactionService }], +}) +export class IgxTreeGridRowEditingHierarchicalDSTransactionComponent { + public data = SampleTestData.employeeAllTypesTreeData(); + @ViewChild('treeGrid', { read: IgxTreeGridComponent }) public treeGrid: IgxTreeGridComponent; + public paging = false; +} diff --git a/projects/igniteui-angular/src/lib/test-utils/tree-grid-functions.spec.ts b/projects/igniteui-angular/src/lib/test-utils/tree-grid-functions.spec.ts index f2c4512d4c8..707c7be05ab 100644 --- a/projects/igniteui-angular/src/lib/test-utils/tree-grid-functions.spec.ts +++ b/projects/igniteui-angular/src/lib/test-utils/tree-grid-functions.spec.ts @@ -493,4 +493,12 @@ export class TreeGridFunctions { expect(newCell.inEditMode).toBe(true); resolve(); }) + + public static moveGridCellWithTab = + (fix, cell: IgxGridCellComponent) => new Promise(async (resolve, reject) => { + UIInteractions.triggerKeyDownEvtUponElem('Tab', cell.nativeElement, true); + await wait(DEBOUNCETIME); + fix.detectChanges(); + resolve(); + }) } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5fb1cbf6bf2..d9516898be0 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -38,16 +38,16 @@ export class AppComponent implements OnInit { icon: 'error', name: 'Badge' }, - { - link: '/bottom-navigation', - icon: 'tab', - name: 'Bottom Navigation' - }, { link: '/banner', icon: 'banner', name: 'Banner' }, + { + link: '/bottom-navigation', + icon: 'tab', + name: 'Bottom Navigation' + }, { link: '/buttonGroup', icon: 'group_work', @@ -88,16 +88,16 @@ export class AppComponent implements OnInit { icon: 'all_out', name: 'Dialog' }, - { - link: '/dropDown', - icon: 'drop_down', - name: 'DropDown' - }, { link: '/drag-drop', icon: 'view_column', name: 'Drag and Drop' }, + { + link: '/dropDown', + icon: 'view_list', + name: 'DropDown' + }, { link: '/expansionPanel', icon: 'expand_more', @@ -118,11 +118,6 @@ export class AppComponent implements OnInit { icon: 'view_column', name: 'Grid Cell Editing' }, - { - link: '/gridConditionalCellStyling', - icon: 'view_column', - name: 'Grid Cell Styling' - }, { link: '/gridColumnGroups', icon: 'view_column', @@ -144,20 +139,25 @@ export class AppComponent implements OnInit { name: 'Grid Column Resizing' }, { - link: '/gridGroupBy', + link: '/gridConditionalCellStyling', icon: 'view_column', - name: 'Grid GroupBy' + name: 'Grid Cell Styling' }, { - link: '/gridPerformance', + link: '/gridGroupBy', icon: 'view_column', - name: 'Grid Performance' + name: 'Grid GroupBy' }, { link: '/gridPercentage', icon: 'view_column', name: 'Grid Percentage' }, + { + link: '/gridPerformance', + icon: 'view_column', + name: 'Grid Performance' + }, { link: '/gridRemoteVirtualization', icon: 'view_column', @@ -188,16 +188,6 @@ export class AppComponent implements OnInit { icon: 'view_column', name: 'Grid Toolbar Custom Content' }, - { - link: '/treeGrid', - icon: 'view_column', - name: 'Tree Grid' - }, - { - link: '/treeGridFlatData', - icon: 'view_column', - name: 'Tree Grid Flat Data' - }, { link: '/icon', icon: 'android', @@ -230,12 +220,12 @@ export class AppComponent implements OnInit { }, { link: '/overlay', - icon: 'overlay', + icon: 'flip_to_front', name: 'Overlay' }, { link: '/overlay-animation', - icon: 'overlay_animation', + icon: 'flip_to_front', name: 'Overlay Animation' }, { @@ -272,6 +262,16 @@ export class AppComponent implements OnInit { link: '/toast', icon: 'android', name: 'Toast' + }, + { + link: '/treeGrid', + icon: 'view_column', + name: 'Tree Grid' + }, + { + link: '/treeGridFlatData', + icon: 'view_column', + name: 'Tree Grid Flat Data' } ]; @@ -291,25 +291,25 @@ export class AppComponent implements OnInit { icon: 'view_quilt', name: 'Layout' }, - { - link: '/ripple', - icon: 'wifi_tethering', - name: 'Ripple' - }, - { - link: '/virtualForDirective', - icon: 'view_column', - name: 'Virtual-For Directive' - }, { link: '/mask', icon: 'view_column', name: 'Mask Directive' }, + { + link: '/ripple', + icon: 'wifi_tethering', + name: 'Ripple' + }, { link: '/tooltip', icon: 'info', name: 'Tooltip' + }, + { + link: '/virtualForDirective', + icon: 'view_column', + name: 'Virtual-For Directive' } ]; @@ -319,15 +319,15 @@ export class AppComponent implements OnInit { icon: 'color_lens', name: 'Colors' }, - { - link: '/typography', - icon: 'font_download', - name: 'Typography' - }, { link: '/shadows', icon: 'layers', name: 'Shadows' + }, + { + link: '/typography', + icon: 'font_download', + name: 'Typography' } ]; diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5e6cecf79fc..567725a6da1 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -77,6 +77,7 @@ import { TreeGridSampleComponent } from './tree-grid/tree-grid.sample'; import { TreeGridFlatDataSampleComponent } from './tree-grid-flat-data/tree-grid-flat-data.sample'; import { GridColumnPercentageWidthsSampleComponent } from './grid-percentage-columns/grid-percantge-widths.sample'; import { BannerSampleComponent } from './banner/banner.sample'; +import { TreeGridWithTransactionsComponent } from './tree-grid/tree-grid-with-transactions.component'; const components = [ AppComponent, @@ -136,6 +137,7 @@ const components = [ GridWithTransactionsComponent, TreeGridSampleComponent, TreeGridFlatDataSampleComponent, + TreeGridWithTransactionsComponent, CustomContentComponent, ColorsSampleComponent, ShadowsSampleComponent, diff --git a/src/app/tree-grid-flat-data/tree-grid-flat-data.sample.html b/src/app/tree-grid-flat-data/tree-grid-flat-data.sample.html index f05d0896430..4b643fe728c 100644 --- a/src/app/tree-grid-flat-data/tree-grid-flat-data.sample.html +++ b/src/app/tree-grid-flat-data/tree-grid-flat-data.sample.html @@ -36,6 +36,9 @@ + + + diff --git a/src/app/tree-grid-flat-data/tree-grid-flat-data.sample.ts b/src/app/tree-grid-flat-data/tree-grid-flat-data.sample.ts index 5422f863c60..6e08b8011e0 100644 --- a/src/app/tree-grid-flat-data/tree-grid-flat-data.sample.ts +++ b/src/app/tree-grid-flat-data/tree-grid-flat-data.sample.ts @@ -1,10 +1,18 @@ import { Component, Injectable, ViewChild, OnInit } from '@angular/core'; import { Http } from '@angular/http'; -import { IgxTreeGridComponent, IgxExcelExporterService, IgxCsvExporterService, - IgxExcelExporterOptions, IgxCsvExporterOptions, CsvFileTypes } from 'igniteui-angular'; +import { + CsvFileTypes, + IgxCsvExporterOptions, + IgxCsvExporterService, + IgxExcelExporterOptions, + IgxExcelExporterService, + IgxGridTransaction, + IgxHierarchicalTransactionService, + IgxTreeGridComponent, +} from 'igniteui-angular'; @Component({ - providers: [], + providers: [{ provide: IgxGridTransaction, useClass: IgxHierarchicalTransactionService }], selector: 'app-tree-grid-flat-data-sample', styleUrls: ['tree-grid-flat-data.sample.css'], templateUrl: 'tree-grid-flat-data.sample.html' @@ -73,12 +81,18 @@ export class TreeGridFlatDataSampleComponent implements OnInit { } public addRow() { - this.grid1.addRow({ 'employeeID': 24, 'PID': -1, 'firstName': 'John', 'lastName': 'Doe', 'Title': 'Junior Sales Representative' }); + this.grid1.addRow({ + 'employeeID': this.data.length + this.nextRow++, + 'PID': -1, + 'firstName': 'John', + 'lastName': 'Doe', + 'Title': 'Junior Sales Representative' + }); } public addChildRow() { const selectedRowId = this.grid1.selectedRows()[0]; - this.grid1.addRow ( + this.grid1.addRow( { 'employeeID': this.data.length + this.nextRow++, 'firstName': `Added `, @@ -89,13 +103,25 @@ export class TreeGridFlatDataSampleComponent implements OnInit { } public deleteRow() { - this.grid1.deleteRowById(this.grid1.selectedRows()[0]); + this.grid1.deleteRow(this.grid1.selectedRows()[0]); } public selectDensity(event) { this.density = this.displayDensities[event.index].label; } + public undo() { + this.grid1.transactions.undo(); + } + + public redo() { + this.grid1.transactions.redo(); + } + + public commit() { + this.grid1.transactions.commit(this.data); + } + public exportToExcel() { this.excelExporterService.export(this.grid1, new IgxExcelExporterOptions('TreeGrid')); } diff --git a/src/app/tree-grid/tree-grid-with-transactions.component.ts b/src/app/tree-grid/tree-grid-with-transactions.component.ts new file mode 100644 index 00000000000..ad83afcca24 --- /dev/null +++ b/src/app/tree-grid/tree-grid-with-transactions.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; +import { IgxGridTransaction, IgxHierarchicalTransactionService } from 'igniteui-angular'; + +@Component({ + selector: 'app-tree-grid-with-transactions', + template: '', + providers: [{ provide: IgxGridTransaction, useClass: IgxHierarchicalTransactionService }] +}) +export class TreeGridWithTransactionsComponent { } diff --git a/src/app/tree-grid/tree-grid.sample.html b/src/app/tree-grid/tree-grid.sample.html index 369c9418764..2e9ddd641af 100644 --- a/src/app/tree-grid/tree-grid.sample.html +++ b/src/app/tree-grid/tree-grid.sample.html @@ -10,16 +10,18 @@
- - - - + + + + + +
Enable Paging @@ -28,10 +30,13 @@ + + +
- + \ No newline at end of file diff --git a/src/app/tree-grid/tree-grid.sample.ts b/src/app/tree-grid/tree-grid.sample.ts index e8654e4e6b0..8bcd3ebab72 100644 --- a/src/app/tree-grid/tree-grid.sample.ts +++ b/src/app/tree-grid/tree-grid.sample.ts @@ -413,7 +413,7 @@ export class TreeGridSampleComponent implements OnInit { public addRow() { this.grid1.addRow({ - 'ID': 'ASDFG', + 'ID': `ADD${this.nextRow++}`, 'CompanyName': 'Around the Horn', 'ContactName': 'Thomas Hardy', 'ContactTitle': 'Sales Representative', @@ -451,7 +451,19 @@ export class TreeGridSampleComponent implements OnInit { } public deleteRow() { - this.grid1.deleteRowById(this.grid1.selectedRows()[0]); + this.grid1.deleteRow(this.grid1.selectedRows()[0]); + } + + public undo() { + this.grid1.transactions.undo(); + } + + public redo() { + this.grid1.transactions.redo(); + } + + public commit() { + this.grid1.transactions.commit(this.data, this.grid1.primaryKey, this.grid1.childDataKey); } public exportToExcel() {